# Accounts and keys
Source: https://docs.unlink.xyz/accounts-and-keys
Register accounts, read addresses, choose a constructor, derive from a wallet signature, and store encrypted recovery data.
## Register account
Call `ensureRegistered()` once before the first mutating operation. The SDK
caches the registration attempt per client instance.
```ts theme={null}
await client.ensureRegistered();
```
* **Browser:** posts the user's public registration payload to your backend.
The default route is `/api/unlink/register`.
* **Server or custodial:** pass a register callback when constructing the user
client. The callback usually calls `admin.users.register(payload)`.
For direct backend code, call `admin.users.register(payload)` when you receive a
wire payload from a browser register route:
```ts theme={null}
import { createUnlinkAdmin } from "@unlink-xyz/sdk/admin";
const admin = createUnlinkAdmin({
environment: "base-sepolia",
apiKey,
});
await admin.users.register(req.body);
```
## Get address
Get the Bech32m Unlink address for this account.
```ts theme={null}
const address = await client.getAddress();
// "unlink1..."
```
## Account constructors
Every high-level client is bound to a signing-capable account. The constructor
you use decides which operations the account can perform.
| Constructor | Source | Transfer / withdraw | Execute |
| ------------------------------- | ----------------------------- | ------------------- | ------- |
| `account.fromMnemonic` | BIP-39 mnemonic | Yes | Yes |
| `account.fromSeed` | 64-byte seed | Yes | Yes |
| `account.fromEthereumSignature` | EOA `personal_sign` signature | Yes | Yes |
| `account.fromWallet` | Wallet signature (browser) | Yes | Yes |
| `account.fromKeys` | Raw keys | Yes | No |
`execute()` needs a seed-backed account, so derive with `fromMnemonic`,
`fromSeed`, `fromEthereumSignature`, or `fromWallet`. `fromKeys` can transfer
and withdraw but cannot execute.
`account.fromMetaMask` remains available as a deprecated alias for
`account.fromWallet`.
## Derive account from a wallet signature
The SDK can derive an Unlink account from an EOA `personal_sign` signature
instead of a mnemonic. The [Quickstart](/quickstart) shows the one-shot
`account.fromWallet` wrapper. This section is the lower-level reference.
### Message format
The `buildDeriveSeedMessage` helper returns the exact string a wallet must sign:
```ts theme={null}
import { buildDeriveSeedMessage } from "@unlink-xyz/sdk/crypto";
const message = buildDeriveSeedMessage({
appId: "your-app-id",
chainId: 84532,
});
// "Unlink: derive identity\nTenant: your-app-id\nChain: 84532\nVersion: 1"
```
Rules:
* The message is the **single source of truth**. Bumping its format is a breaking change for every existing account.
* `appId` is embedded verbatim. The SDK rejects empty strings, LF/CR, or anything over 64 bytes UTF-8 but does not canonicalise casing or whitespace. Use a stable app identifier.
* `chainId` must be a positive integer.
* The message uses the literal label `Tenant:` for address compatibility, even though the parameter is named `appId`. Do not change the message text, or existing accounts derive differently.
### Sign and derive
If you control the signing path yourself, build the message, sign it, and pass
the signature into `account.fromEthereumSignature` with the same `appId` and
`chainId`.
```ts theme={null}
import { account, buildDeriveSeedMessage } from "@unlink-xyz/sdk/crypto";
const message = buildDeriveSeedMessage({
appId: "your-app-id",
chainId: 84532,
});
const signature = await walletClient.signMessage({
account: evmAddress,
message,
});
const unlinkAccount = account.fromEthereumSignature({
signature,
appId: "your-app-id",
chainId: 84532,
});
```
The signature is canonicalised internally and expanded with HKDF-SHA256 into the
64-byte seed consumed by `account.fromSeed`. `appId` and `chainId` are bound
into the HKDF salt. Any mismatch derives a fresh, empty account instead of a
silent account swap.
Stability across wallets relies on RFC-6979 deterministic ECDSA, which every mainstream wallet uses today. For long-term recovery, prefer the keystore export (`account.export(keys)`) over re-deriving from a wallet signature each session.
## Get public key
Most integrations do not need the raw account public key. Use this only for
custom account storage.
```ts theme={null}
const [x, y] = await unlinkAccount.getPublicKey();
```
## User storage
`userStorage` stores up to two opaque base64 payloads for a generic application
`userId`. Engine does not interpret the payload. If `mode` is `"encrypted"`,
encrypt and decrypt client-side before calling Engine.
Create the client with the application user id. Dynamic, your own auth, or any
other session provider should sit behind your app's `requireUser()` boundary and
only supply this generic id to Engine:
```ts theme={null}
const client = createUnlinkClient({
environment: "base-sepolia",
account,
userId: session.userId,
authorizationToken: {
body: ({ subjectType, unlinkAddress, userId }) =>
subjectType === "user_storage"
? {
subject_type: "user_storage",
user_id: userId,
}
: {
subject_type: "unlink_address",
unlink_address: unlinkAddress,
},
},
});
```
For encrypted objects, store a base64-encoded envelope. The wrapping key is derived
locally by an unlock adapter, such as a passphrase adapter today or a WebAuthn
PRF adapter when available:
```ts theme={null}
import type { EncryptedStorageEnvelope } from "@unlink-xyz/sdk/browser";
const envelope: EncryptedStorageEnvelope = {
version: 1,
method: "passphrase:v1", // or "webauthn-prf:v1"
alg: "AES-256-GCM",
kdf: "PBKDF2-SHA256",
salt,
iv,
ciphertext,
};
await client.userStorage.put("recovery", {
mode: "encrypted",
data: btoa(JSON.stringify(envelope)),
});
const { objects } = await client.userStorage.list();
const recovery = await client.userStorage.get("recovery");
await client.userStorage.delete("recovery");
```
Object keys and `userId` values may contain letters, numbers, `.`, `_`, and `-`,
but cannot be exactly `.` or `..`. Each payload is capped at 16,384 base64
characters.
Never derive encryption keys from Dynamic JWTs, Dynamic user ids, email,
wallet addresses, Dynamic signatures, or any other identity-provider value.
For WebAuthn PRF, derive a client-side KEK from the authenticator PRF output
and decrypt locally; Engine must never receive PRF output, KEKs, plaintext
seeds, or decrypted object contents. Use passphrase unlock as the fallback
when PRF support is unavailable.
# Custody models
Source: https://docs.unlink.xyz/custody-models
Choose where user accounts live, which SDK import to use, and how the backend auth routes work.
Choose where the user's Unlink account lives first. That choice decides which
SDK import you use.
## Choose a model
* **Non-custodial browser app:** import from `@unlink-xyz/sdk/browser`. The
user's spending key is created in the browser and never leaves it.
* **Custodial server app:** import from `@unlink-xyz/sdk/client`. Your server
holds the account and signs on the user's behalf.
Both models use `@unlink-xyz/sdk/admin` on your backend for registration,
authorization tokens, and backend reads. Keep the admin API key server-side.
Use `@unlink-xyz/sdk/browser` in browser bundles. Keep `@unlink-xyz/sdk/admin`
on your backend.
Pick a hosted deployment by `environment` name on the
[Supported chains](/supported-chains) page before wiring either model.
## Browser non-custodial
The user signs in the browser. Your backend only registers the user and issues
auth tokens.
* `POST /api/unlink/register`
* `POST /api/unlink/authorization-token`
```ts theme={null}
import { account, createUnlinkClient } from "@unlink-xyz/sdk/browser";
const { account: unlinkAccount } = await account.fromWallet({
provider: window.ethereum,
appId: "your-app-id",
chainId: 84532,
});
const client = createUnlinkClient({
environment: "base-sepolia",
account: unlinkAccount,
});
await client.ensureRegistered();
```
The user's spending key stays in the browser.
## App backend
Mount these routes behind your normal app login. This is the canonical wiring for
the two routes the browser client calls.
```ts theme={null}
import {
createUnlinkAdmin,
createUnlinkAuthRoutes,
} from "@unlink-xyz/sdk/admin";
const admin = createUnlinkAdmin({
environment: "base-sepolia",
apiKey: process.env.UNLINK_API_KEY!,
});
const routes = createUnlinkAuthRoutes({
admin,
authenticate: async (request) => getAppSession(request),
onRegister: async ({ session, registration }) => {
await db.linkUnlinkAddress(session.userId, registration.address);
},
authorizeUnlinkAddress: async ({ session, unlinkAddress }) =>
db.userOwnsUnlinkAddress(session.userId, unlinkAddress),
});
app.post("/api/unlink/register", (c) => routes.register(c.req.raw));
app.post("/api/unlink/authorization-token", (c) =>
routes.authorizationToken(c.req.raw),
);
```
The two routes have distinct jobs. `register` links a newly derived Unlink
address to your authenticated app user. `authorization-token` issues the
short-lived tokens the client sends on later calls. Tokens are scoped by
`subject_type`: `unlink_address` authorizes account actions, and `user_storage`
authorizes the [encrypted storage](/accounts-and-keys#user-storage) namespace for
one app `userId`.
## Server or custodial
Use this model when your server is allowed to hold user accounts.
```ts theme={null}
import { createUnlinkAdmin } from "@unlink-xyz/sdk/admin";
import { account, createUnlinkClient } from "@unlink-xyz/sdk/client";
const admin = createUnlinkAdmin({
environment: "base-sepolia",
apiKey: process.env.UNLINK_API_KEY!,
});
const unlinkAccount = account.fromMnemonic({ mnemonic });
const unlinkAddress = await unlinkAccount.getAddress();
const client = createUnlinkClient({
environment: "base-sepolia",
account: unlinkAccount,
register: (payload) => admin.users.register(payload),
authorizationToken: {
provider: () => admin.authorizationTokens.issue({ unlinkAddress }),
},
});
await client.ensureRegistered();
```
The account passed to `createUnlinkClient` signs the user's private actions, and
the constructor you choose decides which operations it can perform. See
[Accounts and keys](/accounts-and-keys#account-constructors) for the full
constructor reference.
## Backend reads
Use admin reads for backend dashboards and support tooling, and the user client
for signed actions. See
[Reading data and status](/reading-data#backend-reads).
# Deposit
Source: https://docs.unlink.xyz/deposit
Move ERC-20 tokens from an EVM wallet into the unlink contract.
Deposit ERC-20 tokens into the unlink contract. The deposit goes into the account bound to your user client. See [Quickstart](/quickstart). Call `client.ensureRegistered()` once before depositing. A deposit's amount, token, and source wallet are public; see [How Unlink works](/how-unlink-works).
Before opening the EVM wallet signature prompt, the SDK verifies that the
prepared note belongs to the client's bound Unlink address, matches the
requested token and amount, and reconstructs the `notes_hash` included in the
Permit2 witness. A malformed or redirected prepare response is rejected without
being signed.
The SDK currently hash-binds the Engine-returned ciphertext but does not
decrypt and validate its plaintext. Invalid ciphertext cannot redirect the
note after the recipient NPK check, but it can make the deposited note
undiscoverable. The SDK also currently trusts the Engine's reported chain,
pool, and Permit2 addresses; see [Trust
model](/trust-model#remaining-engine-trust-boundaries).
The prepare response must include `prepared_artifacts`. An SDK with deposit
verification fails closed against an older Engine that omits them. Operators
must deploy the compatible Engine to every tier before publishing or adopting
the protected SDK release. Older SDK versions remain unprotected until the
integrator upgrades them.
The recommended path is `depositWithApproval()`. It runs the Permit2 approval
when needed, waits for confirmation, and then runs the deposit in one call. It
requires an EVM provider. See [EVM provider setup](/quickstart#evm-provider).
Use `deposit()` directly only when the token is already approved or when you
want raw control over the approval flow.
```ts theme={null}
const tx = await client.depositWithApproval({
token: "0xTokenAddress",
amount: "1000000000000000000",
});
const confirmed = await tx.wait();
console.log(confirmed.confirmationStatus); // "confirmed" | "processed" | "failed"
```
## Parameters (`depositWithApproval`)
Required fields:
* `token`: ERC-20 token address.
* `amount`: amount in wei.
Optional fields:
* `deadline`: Permit2 deadline. Defaults to one hour from now.
* `nonce`: override Permit2 nonce. The SDK manages this by default.
* `evm`: override the EVM provider for this call.
* `waitForApproval`: override the approval-confirmation wait.
**Returns:** a `TransactionHandle`. See [Transaction status](/reading-data#transaction-status).
## Advanced: separate approval + deposit
If you've already arranged Permit2 approval out of band, or you want to integrate the approval into a custom receipt-polling pipeline, use the lower-level helpers:
```ts theme={null}
const approval = await client.ensureErc20Approval({
token: "0xTokenAddress",
amount: "1000000000000000000",
});
if (approval.status === "submitted") {
// Wait for tx to be mined before depositing
console.log("Approval tx:", approval.txHash);
}
// If approval.status === "already-approved", no transaction was needed
const tx = await client.deposit({
token: "0xTokenAddress",
amount: "1000000000000000000",
});
await tx.wait(); // resolves at user-facing confirmation by default
```
You can also inspect approval state or build the approval transaction manually:
```ts theme={null}
const state = await client.getApprovalState({ token, amount });
// state.isApproved: boolean
const approvalTx = await client.buildApprovalTx({ token, amount });
// approvalTx: { to: string; data: string; value?: bigint }
```
Approval methods require an EVM provider with `getErc20Allowance`.
`ensureErc20Approval` and `depositWithApproval` also require
`sendTransaction`. See [Quickstart](/quickstart#evm-provider) for a compact
browser setup.
# Error handling
Source: https://docs.unlink.xyz/errors
Handle the UnlinkError hierarchy, including timeouts and transient connection loss.
All SDK errors extend `UnlinkError` with a `code` discriminator. Four subclasses
ship: `ApiError`, `CapabilityError`, `ValidationError`, and `TimeoutError`.
```ts theme={null}
import { ApiError, TimeoutError } from "@unlink-xyz/sdk/browser";
// Same shape from /client or /admin.
try {
const tx = await client.deposit({ token, amount });
await tx.wait({ timeoutMs: 60_000 });
} catch (err) {
if (err instanceof TimeoutError) {
console.error("Confirmation timed out:", err.txId);
} else if (err instanceof ApiError) {
console.error("Engine rejected:", err.code, err.detail);
} else {
throw err;
}
}
```
Use `CapabilityError` for missing local setup, such as a missing EVM provider or
an account that cannot execute. Use `ValidationError` for invalid SDK inputs.
The `ApiError` class also covers transport failures. If a connection drops
mid-response, the SDK throws `ApiError` with `code: "CONNECTION_LOST"` instead
of leaking a raw `TypeError`. It is distinct from an Engine rejection,
transient, and safe to retry. The underlying error is attached as `err.cause`.
# Execute
Source: https://docs.unlink.xyz/execute
Use private funds in an external EVM call.
Use `execute()` when a user wants to take tokens from their private Unlink
balance, run one or more EVM calls from an ExecutionAccount, and optionally
return the resulting tokens privately to the pool.
A single call is a one-item `calls` array. For a DeFi action, put each step in
order, such as approve then supply. The batch is atomic: if one call reverts,
the whole batch reverts.
## One atomic UserOperation
`execute()` runs everything as a **single all-or-nothing UserOperation**. When a
session withdraws from the pool and returns tokens to it, the ExecutionAccount:
1. withdraws the funding amount from the pool into itself,
2. runs your `calls` (the DeFi action),
3. approves the pool for each `returnToPool` token, and
4. deposits the returned tokens back into the pool.
These steps are one sponsored ERC-4337 batch. If any step reverts, the entire
operation reverts — including the withdrawal — so funds can never be left
stranded in the ExecutionAccount. There is no separate, second deposit
transaction to fail.
While the funding withdrawal is in flight, its whole-note input leaves
`spendable` and the expected owned change appears in `pending_change`; render
`amount` to avoid a temporary zero-balance dip. An atomic revert restores the
input to `spendable`. This no-dip guarantee applies while `sync_status` is
`current`; `syncing` is a conservative lower bound. See
[Reading data and status](/reading-data#read-balances).
## Gas is sponsored
`execute()` runs through an **ExecutionAccount**, an ERC-4337 smart account that
belongs to the user. The SDK sends the call batch and locally derived account
candidate to Unlink; the backend builds the UserOperation and Unlink's paymaster
sponsors it. The operation is submitted through a bundler and the gas is paid by
Unlink, not by the user.
* The user does not need to hold native gas tokens to call `execute()`. The
private balance you pass as `withdrawFromPool` funds the calls; Unlink
sponsors the gas.
* You do not set gas fields. The backend builds the operation, the user signs
the prepared ExecutionIntent, and Unlink sponsors and submits it.
* Sponsored execution is subject to per-call gas limits. A batch that exceeds the
sponsorship caps is rejected before it is submitted.
## Basic DeFi call
Use your app's contract definitions for `erc20Abi` and `vaultAbi`. This snippet
uses viem for calldata encoding. Use ethers or your own encoder if that is
already your app stack.
```ts theme={null}
import { account, createUnlinkClient, evm } from "@unlink-xyz/sdk/browser";
import { encodeFunctionData } from "viem";
const client = createUnlinkClient({
environment: "base-sepolia",
account: account.fromMnemonic({ mnemonic }),
evm: evm.fromEip1193({ provider: window.ethereum }),
});
const token = "0xTokenAddress";
const vault = "0xVaultAddress";
const amount = 1_000_000_000_000_000_000n;
const approveCall = {
target: token,
value: "0",
data: encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [vault, amount],
}),
label: "approve",
};
const supplyCall = {
target: vault,
value: "0",
data: encodeFunctionData({
abi: vaultAbi,
functionName: "supply",
args: [token, amount],
}),
label: "supply",
};
const result = await client.execute({
withdrawFromPool: [{ token, amount: amount.toString() }],
calls: [approveCall, supplyCall],
});
console.log(result.status, result.executionId);
```
The target contract sees the ExecutionAccount as `msg.sender`. Set each call
value to `"0"`. Use WETH or an ERC-20 flow for actions that need native ETH.
By default, the helper returns when the execute session is `confirmed`: the
handleOps receipt/effects have been observed by the backend and are visible to
API reads. A receipt hash alone is not enough. Pass `waitUntil: "processed"` if
your flow must wait for the terminal execution session state.
## Return tokens privately
Pass `returnToPool` when the batch leaves ERC-20 tokens in the ExecutionAccount
and you want to return them to the user's private Unlink balance. The deposit
back into the pool happens inside the **same** UserOperation as the withdrawal
and your calls.
`returnToPool` is a typed section of the execution plan, not extra calls you
write. Entries are grouped by token, and the ExecutionAccount constructs the
per-token `approve` → deposit → allowance-reset on-chain, inside the same
UserOperation. You do not add those calls yourself, and you do not manage a
Permit2 signature or nonce: the ExecutionAccount is the depositor and the pool
authorizes it directly through its `depositFromExecutionAccount` entry point.
```ts theme={null}
const returnAmount = 900_000_000_000_000_000n;
const result = await client.execute({
withdrawFromPool: [{ token, amount: amount.toString() }],
calls: [approveCall, supplyCall],
returnToPool: [{ token, amount: returnAmount.toString() }],
});
console.log(result.status, result.executionId);
```
Your `calls` must leave at least the requested `returnToPool` amount of each
token in the ExecutionAccount when the batch reaches the deposit step. If the
batch swaps into a different ERC-20, set the `returnToPool` `token` to whatever
token remains in the account.
Funding, calls, and return-to-pool are three separate plan sections with their
own limits — they are not one combined batch. Each section is capped
independently: at most 16 `calls`, at most 8 `withdrawFromPool` tokens, and at
most 8 `returnToPool` token groups with at most 4 output notes per token. Adding
a return-to-pool group does not consume your 16-call budget. These are the
structural maximums; the effective size of one plan is also bounded by the
network's sponsored gas budget, so a plan near several caps at once (for
example, many funding tokens plus many calls) can be rejected at prepare with a
gas-budget error even though each section is under its cap.
`returnToPool` is the API/SDK name for the private return leg. On-chain the
ExecutionAccount settles it through the pool's `depositFromExecutionAccount`
entry point.
To return assets from an already funded ExecutionAccount without another private
withdrawal, use `executeAccountCall({ returnToPool })`. See
[Advanced execute](/execute-advanced).
## Knowing when the returned funds are spendable
`GET /execute/{execution_id}` reports `usable`. It is true once the execute's
client-visible effects are ready for follow-up use: its on-chain effects are
observed by the deployment, every note it returned to the pool has materialized
as a spendable, owner-attributed note, and every funding withdrawal's own
outputs are usable.
Wait on `usable`, not on `status == "completed"`. They answer different
questions, and neither implies the other:
* A session can be `completed` while its returned notes are still unattributed.
Spending them then fails.
* A session can be usable **before** the terminal transition on a deployment
that treats optimistic observation as spendable.
When `usable` is false, `unusable_reason` says what is being waited on —
`awaiting_return_to_pool_evidence` while the pool deposit has not been observed,
`awaiting_output_attribution` once it has but the notes are not yet attributed,
and `terminal_failure` when the effects are never coming.
A pure sweep that realizes zero returns nothing, so the pool emits no event
for it. That shape becomes usable on the completion transition alone — there
is no evidence to wait for, and waiting for some would hang forever.
Like the transaction-level `usable`, this is point-in-time rather than a
finality guarantee: a reorg that invalidates the optimistic overlay can turn it
back to false.
## Recovery is a retry
Because the withdrawal, calls, and return-to-pool deposit are one atomic
UserOperation, there is no intermediate state where funds sit unrecoverably in
the ExecutionAccount. If the operation reverts, the withdrawal is rolled back and
the notes remain spendable in the pool. The remedy is to **retry** the
`execute()` call — not to recover stranded funds.
A reverted operation surfaces as a non-`completed` terminal status. Check
`result.status` and re-issue the call (adjusting calldata if the revert was
caused by the DeFi action itself).
## Follow-up calls from the same account
Use `executeAccountCall()` when a user already has assets or state in an
ExecutionAccount and you want to run more calldata from that same account
without another private withdrawal.
This is not `withdraw(0)`. It prepares a sponsored ERC-4337 UserOperation from
an ExecutionAccount without a new private withdrawal. If the reserved account is
cold, the SDK supplies verified factory initCode so the first account-call can
deploy and bind it during submit. The target contract still sees the
ExecutionAccount as `msg.sender`.
```ts theme={null}
const first = await client.execute({
withdrawFromPool: [{ token, amount: amount.toString() }],
calls: [approveCall, supplyCall],
// Default waits resolve on confirmed evidence, often before the terminal
// status — wait for "processed" when you gate on `completed`.
waitUntil: "processed",
});
if (first.status !== "completed") {
throw new Error(`execute ended with status ${first.status}`);
}
const followUp = await client.executeAccountCall({
accountIndex: first.execution.account_index,
calls: [
{
target: vault,
value: "0",
data: encodeFunctionData({
abi: vaultAbi,
functionName: "claimRewards",
args: [],
}),
},
],
});
console.log(followUp.status, followUp.executionId);
```
An `executeAccountCall()` can also return assets privately by passing
`returnToPool`; the deposit is settled inside the same account-call
UserOperation. For a public fallback that transfers ERC-20 tokens from the
ExecutionAccount to the connected EVM wallet and then uses
`depositWithApproval()` to deposit them, see
[Advanced execute](/execute-advanced#public-eoa-deposit-fallback).
## Parameters
Required fields:
* `calls`: ordered batch of zero to sixteen EVM calls. May be empty only when
`returnToPool` is non-empty.
Optional fields:
* `withdrawFromPool`: array of `{ token, amount }` withdrawn privately into the
ExecutionAccount before the calls run — one entry per token, at most 8, tokens
unique. Each becomes its own private spend proof, and all are batched into a
single pool withdrawal. Omit it to run calls from an already funded account
without a new withdrawal.
* `returnToPool`: array of per-token outputs returned privately to the pool
inside the same UserOperation. A fixed output sets `{ token, amount }`; a sweep
output sets `{ token, sweep: true, max_total }` (see below). At most 8 token
groups, at most 4 outputs per token.
* `accountPolicy`: account selection policy. Defaults to `"fresh"`.
Use `"fresh"`, `"reuseLatest"`, or `{ slotIndex }`.
`withdrawFromPool[].amount` and each fixed `returnToPool` `amount` are base-unit
token amounts as decimal strings (at most 2^120−1). A funding `amount` above that
bound is rejected on-chain, so keep withdrawals within the note-field maximum.
### Sweep returns
When you cannot predict the exact leftover of a token — a swap output, staking
rewards — use a sweep output instead of a fixed `amount`. A sweep re-shields the
runtime remainder the ExecutionAccount holds, so no dust is stranded:
```ts theme={null}
const result = await client.execute({
withdrawFromPool: [{ token, amount: amount.toString() }],
calls: [approveCall, swapCall],
returnToPool: [
{
token: outputToken,
sweep: true,
// Cap: deposit up to this much, keep any surplus liquid in the account.
max_total: maxOut.toString(),
// Optional floor: revert the whole execution if less than this is returned.
min_total: minOut.toString(),
},
],
});
```
`max_total` is required when `sweep` is true and caps the deposited total to
`min(runtime balance, max_total)`. `min_total` (optional, default 0) reverts the
whole atomic execution if the returned total falls below it — a slippage floor.
`baseline` (optional) excludes a pre-existing balance of the token from the
sweep. At most one sweep output per token; fixed and sweep outputs can be mixed
for the same token.
The `execute()` method requires a seed-backed account. `fromKeys` can transfer
and withdraw but cannot execute. See
[Account constructors](/accounts-and-keys#account-constructors).
## Account discovery
`client.executionAccounts` exposes per-user discovery and reservation of
ExecutionAccounts. It surfaces identity and lifecycle metadata only — never
balances, positions, or note metadata. Scoping is enforced for capability
tokens; tenant API keys select the user (the SDK forwards the bound address).
When you list accounts for a specific `environment`, the response also includes
an allocation hint with `tenant_index`, `chain_index`, `next_slot_index`, and an
optional `latest_slot_index` when a reusable bound account exists. The high-level
`execute()` helper uses that hint to derive the candidate account locally, then
`/execute/prepare` atomically binds it.
```ts theme={null}
// Paginated list of the caller's accounts (stable keyset cursor).
const page = await client.executionAccounts.list({
environment, // optional filter
limit, // 1–100, default 50
cursor, // page.next_cursor from a previous call
});
// One account by id.
const byId = await client.executionAccounts.get(accountId);
// One account by its on-chain address (or null), scoped to the caller.
const byAddress = await client.executionAccounts.getByAddress({
environment,
address,
});
// Reserve (or reuse) backend-authoritative account indices.
const reserved = await client.executionAccounts.reserve({
policy: "fresh", // "fresh" | "reuseLatest" | { slotIndex }
});
```
Each `ExecutionAccount` carries `account_id`, `tenant_index`, `chain_index`, `account_index`, `environment`, `account_address`, `owner_address`, `status` (`reserved` | `active` | `retired`), `deployed_at`, `created_at`, `updated_at`, and `last_execution_at` (the `created_at` of the account's most recent execution session, or `null`).
The reservation `policy` maps onto the backend allocation policy: `"fresh"` →
`first_unused`, `"reuseLatest"` → `most_recent_active`, and `{ slotIndex }` →
`by_index`. `client.reserveExecutionAccount(...)` remains as a deprecated alias.
# Advanced execute
Source: https://docs.unlink.xyz/execute-advanced
Understand ExecutionAccount boundaries, atomic return-to-pool, and lower-level execute helpers.
This page covers the edge cases around `execute()`: what the ExecutionAccount
can do, how assets are returned to the private pool atomically, and what the
advanced SDK exposes for custom orchestration and follow-up calls.
## ExecutionAccount boundary
An `execute()` session is a single sponsored ERC-4337 UserOperation built around
the ExecutionAccount. Depending on the request it contains:
* Optional private withdrawals from the user's Unlink balance into the
ExecutionAccount (`withdrawFromPool`, one entry per token).
* The user `calls`, run as the `userCalls` section of the plan.
* An optional atomic `returnToPool` deposit that returns ERC-20 tokens from the
ExecutionAccount back into the user's private Unlink balance.
The account runs all three as one typed plan
(`executeUnlinkPlan(withdrawFromPool, userCalls, returnToPool)`). The withdrawals,
the calls, and the deposit back into the pool either all succeed or all revert
together — there is no separate return transaction and no partial-settlement
state.
The ExecutionAccount is a smart account, not an EOA. The owner key signs an
ExecutionIntent for ERC-4337 validation. It does not send normal EVM
transactions directly from the ExecutionAccount.
## Session shape: funded vs unfunded
The number of signatures and round trips depends on whether the session
withdraws from the pool.
* **Funded session** (`withdrawFromPool` present) — funding mode
`private_withdrawal`. Three steps: `prepare` → `finalize` → `submit`. Prepare
returns one withdrawal signing request **per `withdrawFromPool` entry** (one
private spend proof per token); the user signs each; `finalize` proves the
withdrawals, materializes the atomic `withdrawToExecutionAccount` funding
section, and returns the final ExecutionIntent; the user signs that intent and
`submit` broadcasts the UserOperation. The ExecutionIntent is null until
finalize because the plan (and its `planHash`) is not complete until the
funding section is materialized.
* **Unfunded session** (`withdrawFromPool` omitted, including every
`executeAccountCall`) — funding mode `existing_execution_account`. Two steps:
`prepare` → `submit`. Prepare returns the ExecutionIntent directly because no
funding section has to be materialized.
The high-level `client.execute()` / `client.executeAccountCall()` helpers run
the whole sequence for you, including the withdrawal and intent signatures. The
advanced helpers below expose the individual steps.
## Advanced SDK surface
The `@unlink-xyz/sdk/advanced` subpath exports lower-level helpers for custom
orchestration:
```ts theme={null}
import {
createExecutionAccountClient,
executeAccountCall,
finalizeExecute,
getExecuteSession,
pollExecuteStatus,
prepareExecute,
prepareExecuteAccountCall,
reserveExecutionAccount,
submitExecute,
submitExecuteAccountCall,
} from "@unlink-xyz/sdk/advanced";
```
`prepareExecute` / `finalizeExecute` / `submitExecute` are the funded
private-withdrawal execute-session steps. Direct `prepareExecute` callers pass an
account candidate (`accountPolicy` plus `executionAccount.slotIndex`, address,
owner, and initCode); `/execute/prepare` is the atomic bind-or-conflict boundary.
`execute()` handles the normal discovery, local derivation, and retry loop for
you. `prepareExecuteAccountCall` / `submitExecuteAccountCall` prepare and
submit a sponsored UserOperation from an ExecutionAccount without preparing a
withdrawal. The prepare request may include factory initCode for the account's
first bind/deploy; the durable account binding is completed on submit after the
backend verifies the owner's `ExecutionIntent` signature. Direct low-level
callers that use first-bind evidence in prepare must send the same factory
initCode to `submitExecuteAccountCall`. `executeAccountCall` is the high-level
helper that performs the same account-call flow for you. `finalizeExecute`
applies only to funded sessions; unfunded sessions submit the intent returned by
prepare directly.
`reserveExecutionAccount` is still exported for explicit discovery/reservation
tools and legacy custom flows. The high-level private-withdrawal `execute()`
path does not call it; it lists accounts for an environment, derives the
candidate account from the returned allocation hint, then prepares that
candidate.
By default, `execute()` and `executeAccountCall()` resolve when the backend
reports `execution.confirmation_status: "confirmed"`: the handleOps
receipt/effects required by the session have been observed and are visible to
API reads. Pass `waitUntil: "processed"` (or `"finalized"`) if your integration
needs the durable terminal execution-session state before continuing.
The withdrawless account-call flow does not create a pool withdrawal, and it
is not a `withdraw(0)` workaround. Cold first use is supported only for the
verified ExecutionAccount factory initCode derived by the SDK.
Pass an `evm` provider when constructing the user client to enable the SDK's
client-side deployed-code check for warm account calls. The backend always
performs the authoritative factory/initCode derivation checks before preparing a
withdrawless account-call session, and requires deployed code only when the
request is reusing an account without first-bind initCode evidence.
## Return assets with returnToPool
Pass `returnToPool` in the same `execute()` call when the batch leaves ERC-20
tokens in the ExecutionAccount and those tokens should go back into the user's
private balance.
The return is atomic and typed. `returnToPool` is the plan's `returnToPool`
section, grouped by token; the ExecutionAccount constructs each token group's
`approve` → `depositFromExecutionAccount` → allowance-reset on-chain, inside the
same UserOperation as the funding withdrawals and your calls. The ExecutionAccount
is the depositor and the pool authorizes it directly, so there is no Permit2
signature, nonce, or deadline to manage — and no separate deposit transaction that
can fail on its own.
```ts theme={null}
const amount = 1_000_000_000_000_000_000n;
const result = await client.execute({
withdrawFromPool: [{ token, amount: amount.toString() }],
calls: [
// protocol calls first, if any
],
returnToPool: [{ token, amount: amount.toString() }],
});
```
Your batch must leave at least the requested amount of each `returnToPool` token
in the ExecutionAccount when the batch reaches the deposit step. If the batch
swaps into a different ERC-20, set the `returnToPool` `token` to whatever token
remains in the account.
Funding, calls, and return-to-pool are three independently-capped plan sections,
not one combined batch: at most 16 `calls`, at most 8 `withdrawFromPool` tokens,
and at most 8 `returnToPool` token groups with at most 4 output notes per token.
Return-to-pool groups do not consume the 16-call budget. The structural caps sit
alongside a per-network sponsored gas budget checked at prepare, so a plan
stacking several sections near their caps can receive a gas-budget rejection
even with every section within bounds. Each token group can mix
fixed `amount` outputs with one `sweep` output that re-shields the runtime
remainder (`sweep: true` + required `max_total`, optional `min_total` floor and
`baseline`). `recipient` is reserved for future use; v1 defaults every output to
the authenticated Unlink address and rejects a supplied recipient.
## Follow-up calls without withdrawal
If a previous execute session completed without `returnToPool`, those assets can
remain in the ExecutionAccount. Use `client.executeAccountCall` to run another
call from the same account index without moving funds through the pool first.
The same recovery path applies when a known terminal failure such as
`user_op_reverted` leaves tokens in the ExecutionAccount.
This still uses the ExecutionAccount owner signature and Unlink's sponsored
ERC-4337 path. The session has funding mode `existing_execution_account`, and its
withdrawal fields are empty arrays: `withdraw_from_pool: []` and
`withdrawal_tx_ids: []`.
```ts theme={null}
const accountIndex = previousResult.execution.account_index;
const stuckAmount = 1_000_000_000_000_000_000n;
await client.executeAccountCall({
accountIndex,
calls: [
{
target: stuckToken,
value: "0",
data: "0x...", // optional follow-up calldata from the ExecutionAccount
},
],
returnToPool: [{ token: stuckToken, amount: stuckAmount.toString() }],
});
```
You can also use it for a non-deposit follow-up call, as long as the target
calldata can execute from the ExecutionAccount and does not need a new private
withdrawal. When `returnToPool` is omitted, `calls` must be non-empty.
## Public EOA deposit fallback
`depositWithApproval()` deposits from the connected EVM wallet, not from the
ExecutionAccount. If atomic `returnToPool` is not suitable for a flow, transfer
the tokens from the ExecutionAccount to the user's EOA in a follow-up
`executeAccountCall()`, then call `depositWithApproval()` from that EOA.
This fallback is public. The ERC-20 transfer from the ExecutionAccount to the
EOA and the later deposit source wallet are visible on-chain.
```ts theme={null}
import { account, createUnlinkClient, evm } from "@unlink-xyz/sdk/browser";
import { encodeFunctionData, erc20Abi } from "viem";
const evmProvider = evm.fromEip1193({ provider: window.ethereum });
const client = createUnlinkClient({
environment: "base-sepolia",
account: account.fromMnemonic({ mnemonic }),
evm: evmProvider,
});
await client.ensureRegistered();
const token = "0xTokenAddress";
const protocol = "0xProtocolAddress";
const firstAmount = 1_000_000_000_000_000_000n;
const first = await client.execute({
withdrawFromPool: [{ token, amount: firstAmount.toString() }],
calls: [
{
target: token,
value: "0",
data: encodeFunctionData({
abi: erc20Abi,
functionName: "approve",
args: [protocol, firstAmount],
}),
},
{
target: protocol,
value: "0",
data: "0x...", // initial protocol calldata
},
],
waitUntil: "processed",
});
if (first.status !== "completed") {
throw new Error(`execute ended with status ${first.status}`);
}
const accountIndex = first.execution.account_index;
const recipientEoa = await evmProvider.getAddress();
const amountToDeposit = 500_000_000_000_000_000n;
const followUp = await client.executeAccountCall({
accountIndex,
calls: [
{
target: protocol,
value: "0",
data: "0x...", // optional follow-up calldata from the ExecutionAccount
},
{
target: token,
value: "0",
data: encodeFunctionData({
abi: erc20Abi,
functionName: "transfer",
args: [recipientEoa, amountToDeposit],
}),
},
],
waitUntil: "processed",
});
if (followUp.status !== "completed") {
throw new Error(`account call ended with status ${followUp.status}`);
}
const deposit = await client.depositWithApproval({
token,
amount: amountToDeposit.toString(),
});
const confirmed = await deposit.wait();
console.log(confirmed.confirmationStatus);
```
The pool, circuits, and contracts still require positive note amounts for real
withdrawals and outputs. A zero-amount withdrawal is not the supported escape
hatch.
# Faucet
Source: https://docs.unlink.xyz/faucet
Fund test accounts on configured testnet environments.
Use the faucet helpers to fund test accounts during onboarding, demos, and QA.
Faucets are per-environment and testnet-only.
There are two different flows:
* `requestTestTokens()` mints ERC-20 test tokens to an EVM wallet outside the unlink contract
* `requestPrivateTokens()` transfers shielded test tokens directly into an Unlink account inside the unlink contract
## Token
Use a test token configured for the environment you selected. In hosted
projects, this comes from your project or environment config.
In the Unlink dashboard, the faucet token cards show each hosted mock token's
truncated address. Use the copy button on the token card to copy the full
address for SDK or API calls.
## Mint to an EVM wallet
```ts theme={null}
const result = await client.faucet.requestTestTokens({
token: testToken,
});
console.log(result.tx_hash);
```
If you omit `evmAddress`, the SDK uses the connected EVM provider address.
```ts theme={null}
const result = await client.faucet.requestTestTokens({
token: testToken,
evmAddress: "0xRecipient",
});
```
Omitting `evmAddress` requires an EVM provider on the client.
By default the faucet sends its configured amount. Pass `amount` (in wei, as a
decimal string) to request a specific amount, up to the faucet's configured
maximum. Requests above the maximum, or for `0`, are rejected.
```ts theme={null}
const result = await client.faucet.requestTestTokens({
token: testToken,
amount: "1000000000000000000", // 1 token (18 decimals), in wei
});
```
## Fund a private Unlink account
```ts theme={null}
const result = await client.faucet.requestPrivateTokens({
token: testToken,
});
console.log(result.tx_id, result.status);
```
If you omit `unlinkAddress`, the SDK automatically registers the caller if needed and uses the caller's own Unlink address.
You can also target another Unlink account explicitly:
```ts theme={null}
const result = await client.faucet.requestPrivateTokens({
token: testToken,
unlinkAddress: "unlink1recipient...",
});
```
`amount` works the same way here — wei as a decimal string, capped by the
faucet's configured maximum:
```ts theme={null}
const result = await client.faucet.requestPrivateTokens({
token: testToken,
amount: "1000000000000000000",
});
```
## When to use which
* Use `requestTestTokens()` when the user needs public ERC-20 balance for an approval or public wallet flow
* Use `requestPrivateTokens()` when the user should start with funds already inside the unlink contract
## Responses
The `requestTestTokens()` method returns an on-chain transaction hash:
```ts theme={null}
type FaucetMintResponse = {
tx_hash: string;
};
```
The `requestPrivateTokens()` method returns an internal transfer result:
```ts theme={null}
type FaucetTransferResponse = {
tx_id: string;
status: TransactionStatus;
};
```
`TransactionStatus` is `"accepted" | "prepared" | "proving" | "proved" |
"broadcasting" | "relayed" | "processed" | "failed"` (see [Transaction
status](/reading-data#transaction-status)).
The faucet `tx_id` is scoped to the faucet and cannot be polled via
`pollTransactionStatus()`. It is not the same as transaction IDs returned by
`deposit()`, `transfer()`, or `withdraw()`. To confirm private tokens have
arrived, use `getBalances()` (see [Reading
data](/reading-data#read-balances)).
# How Unlink works
Source: https://docs.unlink.xyz/how-unlink-works
The shielding model behind Unlink and what stays public or private at each step.
Unlink is a smart contract deployed on the blockchain itself. There is no bridge
and no separate chain. The SDK generates the zero-knowledge proofs and signs the
operations. From a builder's perspective you call `depositWithApproval()`,
`transfer()`, `withdraw()`, and `execute()`.
Balances inside the contract are held as a set of encrypted UTXO notes, and every
private operation is proven with a Groth16 zero-knowledge proof. The contract
verifies the proof without learning the sender, recipient, or amount of a private
transfer.
## The flow
1. **Deposit** moves ERC-20 tokens from a public EVM wallet into the Unlink
contract, creating a private balance for a `unlink1` account.
2. **Transfer** moves value privately between `unlink1` accounts. Sender,
recipient, and amount are hidden by the proof.
3. **Withdraw** moves tokens back out of the contract to any public EVM address.
4. **Execute** spends a private balance in external EVM calls, such as a DeFi
action, and can return the result to a private balance.
The [Introduction](/) shows a diagram of this flow.
## What's private, what's public
| | Deposit | Transfer | Withdraw | Execute |
| -------------- | -------------------- | -------------------- | -------------------- | -------------------- |
| **Amount** | Public | Private | Public | Public |
| **Sender** | Public | Private | Private | Private |
| **Recipient** | Private | Private | Public | Public |
| **Token type** | Public | Private | Public | Public |
A deposit reveals who funded which `unlink1` account and how much. A withdrawal
reveals the destination address and amount, but not which private account funded
it. `execute()` has the same exposure as a withdrawal: the on-chain calls,
amounts, and target contracts are public, while the private account that funded
them stays hidden, and any tokens returned with deposit-back are private again.
Only transfers between `unlink1` accounts hide all four properties. Plan your
deposit and withdrawal amounts and timing accordingly. The
[private payment tutorial](/partner-integrations) walks through a flow that
minimizes linkability.
## Who pays gas
How gas is paid depends on the operation.
| Operation | Who pays gas |
| ------------------ | ------------------------------------------------- |
| Deposit | The user, from their EVM wallet |
| Transfer, withdraw | Unlink, which relays the transaction for the user |
| Execute | Unlink's paymaster, which sponsors the operation |
A deposit is a normal on-chain transaction sent from the user's EVM wallet, so
that wallet needs native gas. Transfers and withdrawals are submitted by Unlink's
relayer, and `execute()` runs as a sponsored ERC-4337 UserOperation (see
[Execute](/execute#gas-is-sponsored)). The user needs no native gas token for
these private actions.
## Where Unlink runs
Pick a hosted deployment by `environment` name on the
[Supported chains](/supported-chains) page.
# Introduction
Source: https://docs.unlink.xyz/index
Build private applications on blockchains.
Unlink lets you add private blockchain accounts to your applications. You can now own, send, receive, and interact with smart contracts, all without exposing balances, tokens, amounts or transaction history.
## What you can build
* **Private payouts** - Pay teams, creators, and vendors without exposing history
* **Stablecoin apps** - Add private balances and transfers to user wallets
* **Treasury operations** - Move and rebalance funds without leaking strategy
* **Private DeFi** - Swap, lend, borrow, or allocate from private balances
* **AI agent wallets** - Give agents scoped funds without public strategy leaks
* **Trade settlement** - Settle OTC, RFQ, and market-maker flows privately
* **Grants and rewards** - Fund contributors without linking identity to payments
## How Unlink works
Public wallet
0x user wallet
Deposit
Unlink contract
unlink1 private account
Private transfer
unlink1 recipient
Withdraw
Public recipient
0x destination
Unlink is a smart contract deployed on the blockchain itself. No bridging, no
separate chain. The SDK signs each transaction locally with your spending key —
which never reaches the Unlink Engine — and the Engine generates the
zero-knowledge proofs from that signature. You call `depositWithApproval()`,
`transfer()`, `withdraw()`, and `execute()`. See
[How Unlink works](/how-unlink-works) for the privacy model and the
[trust model](/trust-model) for which secrets live where.
## Where Unlink runs
Unlink is multichain. SDK clients choose a hosted deployment with the
`environment` option. See [Supported chains](/supported-chains) for the current
production environments.
## How to integrate
Choose where user keys live first. Browser apps import from `/browser`.
Custodial server apps import from `/client`. Your backend imports from `/admin`
for registration, auth tokens, and backend reads.
```ts theme={null}
// Browser client.
import { account, createUnlinkClient } from "@unlink-xyz/sdk/browser";
const { account: unlinkAccount } = await account.fromWallet({
provider,
appId,
chainId: 84532,
});
const client = createUnlinkClient({
environment: "base-sepolia",
account: unlinkAccount,
});
const tx = await client.transfer({ recipientAddress, token, amount });
await tx.wait();
```
```ts theme={null}
// Backend.
import { createUnlinkAdmin } from "@unlink-xyz/sdk/admin";
const admin = createUnlinkAdmin({
environment: "base-sepolia",
apiKey,
});
await admin.users.register(payload);
await admin.authorizationTokens.issue({ unlinkAddress });
```
**Browser app:** your backend hosts two routes for registration and auth tokens.
The user's spending key stays in the browser.
**Custodial server app:** your server creates one user client per account it is
allowed to hold.
Use `@unlink-xyz/sdk/browser` in browser bundles. Keep `@unlink-xyz/sdk/admin`
on your backend.
Install the SDK and make your first deposit, transfer, and withdrawal.
The privacy model and what stays public or private at each step.
Choose between non-custodial browser and custodial server integration.
Hosted environments and the environment name to pass to the SDK.
Move ERC-20 tokens from an EVM wallet into the unlink contract.
Send tokens privately between Unlink addresses.
# Create Unlink accounts with Openfort
Source: https://docs.unlink.xyz/openfort-accounts
Use an Openfort embedded EOA to derive, register, and authorize a non-custodial Unlink account.
Use Openfort as the wallet layer for Unlink accounts. Openfort signs the user
in, creates a passkey-secured embedded EOA, and Unlink derives a non-custodial
`unlink1...` account from that wallet.
By the end, the browser has registered a private Unlink account, the backend
has issued only short-lived authorization tokens, and no server has handled
the user's Unlink spending key.
## Prerequisites
* An Openfort project with an **Openfort publishable key**, **Openfort secret key**, and **Shield publishable key**.
* An Unlink project for `monad-testnet`, with the backend-only `UNLINK_API_KEY` stored on your server.
* Packages: `@unlink-xyz/sdk@canary`, `@openfort/react`, `@openfort/openfort-node`, `wagmi`, `@wagmi/core`, `@wagmi/connectors`, `viem`, and `@tanstack/react-query`.
* A Monad testnet RPC URL. If you continue into deposits, faucet funding, or private payments, also copy the token address configured for your Unlink project.
## Configure Openfort
Wrap your React app with Openfort and wagmi. For this flow, configure an
embedded **EOA** on Monad testnet.
```tsx theme={null}
import {
AccountTypeEnum,
ChainTypeEnum,
OpenfortProvider,
RecoveryMethod,
} from "@openfort/react";
import { getDefaultConfig, OpenfortWagmiBridge } from "@openfort/react/wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { monadTestnet } from "viem/chains";
import { createConfig, http, WagmiProvider } from "wagmi";
const MONAD_CHAIN_ID = 10143;
const queryClient = new QueryClient();
const wagmiConfig = createConfig(
getDefaultConfig({
appName: "Openfort + Unlink",
chains: [monadTestnet],
transports: {
[monadTestnet.id]: http(import.meta.env.VITE_MONAD_RPC_URL),
},
}),
);
export function Providers({ children }: { children: ReactNode }) {
return (
{children}
);
}
```
## Build the Unlink client
After Openfort connects the embedded EOA, pass its EIP-1193 provider to
`account.fromWallet`. Keep `appId` and `chainId` stable for returning users.
```ts theme={null}
import { account, createUnlinkClient, evm } from "@unlink-xyz/sdk/browser";
const MONAD_CHAIN_ID = 10143;
const UNLINK_ENVIRONMENT = "monad-testnet";
const UNLINK_APP_ID = "your-app-openfort";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
type Eip1193Provider = {
request(args: { method: string; params?: unknown[] }): Promise;
};
export async function createOpenfortUnlinkClient(opts: {
provider: Eip1193Provider;
getAccessToken: () => Promise;
}) {
const { provider, getAccessToken } = opts;
const { account: unlinkAccount, address: eoaAddress } =
await account.fromWallet({
provider,
appId: UNLINK_APP_ID,
chainId: MONAD_CHAIN_ID,
});
const customFetch: typeof fetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
if (!url.includes("/api/unlink/")) {
return fetch(input, init);
}
const token = await getAccessToken();
const headers = new Headers(init?.headers);
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
return fetch(input, { ...init, headers });
};
const client = createUnlinkClient({
environment: UNLINK_ENVIRONMENT,
account: unlinkAccount,
evm: evm.fromEip1193({ provider, address: eoaAddress }),
registerUrl: `${API_BASE_URL}/api/unlink/register`,
authorizationToken: {
url: `${API_BASE_URL}/api/unlink/authorization-token`,
},
customFetch,
});
await client.ensureRegistered();
const unlinkAddress = await client.getAddress();
return { client, eoaAddress, unlinkAddress };
}
```
Call the helper from a screen where Openfort has connected the embedded wallet.
```tsx theme={null}
import { useUser } from "@openfort/react";
import { useEthereumEmbeddedWallet } from "@openfort/react/ethereum";
import { createOpenfortUnlinkClient } from "./unlink";
const MONAD_CHAIN_ID = 10143;
type OpenfortUnlinkSetup = Awaited<
ReturnType
>;
export function OpenfortUnlinkSetupButton({
onReady,
}: {
onReady: (setup: OpenfortUnlinkSetup) => void;
}) {
const wallet = useEthereumEmbeddedWallet({ chainId: MONAD_CHAIN_ID });
const { getAccessToken } = useUser();
async function setupUnlinkAccount() {
if (wallet.status !== "connected") return;
const setup = await createOpenfortUnlinkClient({
provider: wallet.provider,
getAccessToken,
});
onReady(setup);
}
return ;
}
```
## Mount the backend routes
Keep the Unlink admin API key on your server. Validate the Openfort bearer token,
then mount the Unlink auth routes.
```ts theme={null}
import Openfort from "@openfort/openfort-node";
import {
createUnlinkAdmin,
createUnlinkAuthRoutes,
} from "@unlink-xyz/sdk/admin";
const openfort = new Openfort(process.env.OPENFORT_SECRET_KEY!, {
publishableKey: process.env.OPENFORT_PUBLISHABLE_KEY!,
});
const admin = createUnlinkAdmin({
environment: "monad-testnet",
apiKey: process.env.UNLINK_API_KEY!,
});
async function authenticateOpenfort(request: Request) {
const header = request.headers.get("authorization");
const token = header?.replace("Bearer ", "");
if (!token) throw new Error("Missing Openfort access token");
const { user } = await openfort.iam.getSession({ accessToken: token });
return { openfortUserId: user.id };
}
const routes = createUnlinkAuthRoutes({
admin,
authenticate: authenticateOpenfort,
onRegister: async ({ session, registration }) => {
await db.linkUnlinkAddress({
openfortUserId: session.openfortUserId,
unlinkAddress: registration.address,
});
},
authorizeUnlinkAddress: async ({ session, unlinkAddress }) =>
db.userOwnsUnlinkAddress(session.openfortUserId, unlinkAddress),
});
app.post("/api/unlink/register", (c) => routes.register(c.req.raw));
app.post("/api/unlink/authorization-token", (c) =>
routes.authorizationToken(c.req.raw),
);
```
Do not return `true` unconditionally from `authorizeUnlinkAddress` outside a
local throwaway demo. Persist the Openfort user id to Unlink address mapping
in `onRegister`, and only mint tokens for addresses owned by the authenticated
Openfort user.
## What you built
The browser now has a non-custodial Unlink account backed by an Openfort
embedded EOA:
```ts theme={null}
await client.ensureRegistered();
const unlinkAddress = await client.getAddress();
```
From here, use the same client for balances, faucet funding, deposits, transfers,
withdrawals, and `execute()`. For a complete invoice-payment demo that builds on
this account setup, see Openfort's
[private payments recipe](https://www.openfort.io/docs/recipes/unlink-private-payments).
For the security model behind these routes, see [Custody models](/custody-models)
and [Accounts and keys](/accounts-and-keys#derive-account-from-a-wallet-signature).
# Build a private nanopayment app
Source: https://docs.unlink.xyz/partner-integrations
End-to-end tutorial. Sign in with Dynamic, fund a private Unlink account, and pay an x402 resource on Arc Testnet through Circle Gateway.
This tutorial builds one runnable flow that pays for an x402 resource without
linking the payment back to the user's funding wallet. It combines four tools:
* **Dynamic** signs the user in and secures their wallet.
* **Unlink** holds the user's private balance and breaks the on-chain link.
* **Circle Gateway** sends gasless x402 nanopayments from an EOA.
* **Arc Testnet** settles fast and uses USDC as its gas token.
By the end you will have signed a user in with Dynamic, created and registered
a Dynamic-bound Unlink account on `arc-testnet`, funded it, withdrawn a smaller
amount to a payer EOA, and paid an x402 resource through Circle Gateway, with
the payer EOA unlinkable from the original funding wallet.
## Prerequisites
* A Dynamic account or sandbox from the [Dynamic dashboard](https://app.dynamic.xyz/), with an environment ID. Enable Arc in Dynamic's chains list if your app switches wallets to Arc.
* An Unlink API key for `arc-testnet`, created in the [Quickstart](/quickstart#create-an-api-key) and stored as `UNLINK_API_KEY` on your backend only.
* Arc Testnet USDC for the payer EOA from the [Circle faucet](https://faucet.circle.com/). Arc uses USDC as its gas token, so the payer needs USDC for both the Gateway deposit and the payment. See [Supported chains](/supported-chains).
* Packages: `@unlink-xyz/sdk@canary` and `@circle-fin/x402-batching`.
* The payer EOA's keypair (`payerAddress`, `payerPrivateKey`) and an Arc RPC URL (`rpcUrl`), which you supply in the final Gateway step.
Throughout, `usdc` is the Arc USDC token address configured for your environment.
[Dynamic](https://docs.dynamic.xyz) signs users in and secures their wallet.
Create a Dynamic app or sandbox in the
[Dynamic dashboard](https://app.dynamic.xyz/), sign the user in, and read the
session token. The Dynamic user id (the JWT `sub`) becomes the Unlink user id.
```ts theme={null}
const dynamicToken = dynamicClient.token; // Dynamic session JWT
const userId = dynamicUserIdFromToken(dynamicToken);
```
Use the Dynamic user id as the Unlink `userId`. Recover or create the
encrypted recovery envelope, then create the client and register the private
account on `arc-testnet`.
```ts theme={null}
import { account, createUnlinkClient } from "@unlink-xyz/sdk/browser";
const mnemonic = await recoverOrCreateUnlinkMnemonic({ userId, dynamicToken });
const client = createUnlinkClient({
environment: "arc-testnet",
account: account.fromMnemonic({ mnemonic }),
userId,
});
await client.ensureRegistered();
```
Implement `recoverOrCreateUnlinkMnemonic` in your app: create a temporary
Unlink client with the Dynamic `sub` as `userId`, use `client.userStorage` to
store only an encrypted recovery envelope, decrypt locally, and return the
mnemonic. Treat the envelope as wallet material. Encrypt it with a key derived
locally, never from the Dynamic JWT or user id, and follow Dynamic's
[storage best practices](https://www.dynamic.xyz/docs/react/wallets/embedded-wallets/mpc/delegated-access/storage-best-practices#security-requirements-checklist).
See [Accounts and keys](/accounts-and-keys#user-storage) for the envelope
shape and key-safety rules.
Mount the Unlink auth routes behind Dynamic JWT verification. This is the
Dynamic-flavored version of the routes in
[Custody models](/custody-models#app-backend): authenticate with the verified
Dynamic `sub`, and authorize user storage only for the matching id.
```ts theme={null}
import {
createUnlinkAdmin,
createUnlinkAuthRoutes,
} from "@unlink-xyz/sdk/admin";
const admin = createUnlinkAdmin({
environment: "arc-testnet",
apiKey: process.env.UNLINK_API_KEY!,
});
const routes = createUnlinkAuthRoutes({
admin,
authenticate: async (request) => {
const userId = await requireDynamicUserId(request);
return { userId };
},
onRegister: async ({ session, registration }) => {
await db.linkUnlinkAddress(session.userId, registration.address);
},
authorizeUnlinkAddress: async ({ session, unlinkAddress }) =>
db.userOwnsUnlinkAddress(session.userId, unlinkAddress),
authorizeUserStorage: async ({ session, userId }) =>
session.userId === userId,
});
```
Storage tokens must authorize only the matching Dynamic user id.
Seed the private account with Arc USDC using the faucet helper, then confirm
with `getBalances`.
```ts theme={null}
await client.faucet.requestPrivateTokens({ token: usdc });
const { balances } = await client.getBalances();
```
The faucet `tx_id` is not pollable, so balances are the confirmation signal.
See [Faucet](/faucet).
Optionally move funds privately between Unlink accounts before withdrawing. A
private hop strengthens unlinkability. See [Transfer](/transfer) for multiple
recipients and parameters.
```ts theme={null}
const tx = await client.transfer({
recipientAddress: "unlink1recipient...",
token: usdc,
amount: "1000000", // 1 USDC, base units
});
await tx.wait();
```
Withdraw a smaller amount privately to the payer EOA. Unlink amounts are in
base units. See [Withdraw](/withdraw) for the parameter reference.
```ts theme={null}
const withdrawal = await client.withdraw({
recipientEvmAddress: payerAddress,
token: usdc,
amount: "2000000", // 2 USDC, base units
});
await withdrawal.wait();
```
For privacy hygiene, avoid a same-size deposit and withdrawal in the same
payment flow. Amount and timing correlation can weaken unlinkability. Keep a
larger balance in the private pool, optionally transfer privately, then
withdraw smaller payer amounts later.
After the payer EOA receives the withdrawal, deposit into Circle Gateway and
pay. Gateway deposit amounts are decimal USDC, while Unlink withdrawals are
base units.
```ts theme={null}
import { GatewayClient } from "@circle-fin/x402-batching/client";
const gateway = new GatewayClient({
chain: "arcTestnet",
privateKey: payerPrivateKey,
rpcUrl,
});
await gateway.deposit("1.99");
await gateway.pay("https://seller.example/premium-data");
```
The Gateway payer must be a plain EOA. Do not use an Unlink execution account
or smart account as the payer. Keep some withdrawn USDC on the payer EOA
because USDC is also the gas token for the Gateway deposit transaction. In
Unlink, Arc Testnet is the SDK environment `arc-testnet`; in Circle Gateway it
is the chain name `arcTestnet`. See Circle's
[nanopayments buyer guide](https://developers.circle.com/gateway/nanopayments/howtos/x402-buyer)
and
[supported chains](https://developers.circle.com/gateway/nanopayments/supported-networks).
## What you built
You signed a user in with Dynamic, created and registered a Dynamic-bound Unlink
account on Arc Testnet, funded it, withdrew a smaller amount to a payer EOA, and
paid an x402 resource through Circle Gateway. The payer EOA cannot be linked back
to the original funding wallet.
Fund testnet wallets with Arc USDC.
Optional if your app switches Dynamic wallets to Arc.
For the privacy model behind this flow, see
[How Unlink works](/how-unlink-works).
# Quickstart
Source: https://docs.unlink.xyz/quickstart
Install the SDK and make your first private transaction in minutes.
## Install SDK
```bash npm theme={null}
npm install @unlink-xyz/sdk@canary
```
```bash pnpm theme={null}
pnpm add @unlink-xyz/sdk@canary
```
```bash yarn theme={null}
yarn add @unlink-xyz/sdk@canary
```
```bash bun theme={null}
bun add @unlink-xyz/sdk@canary
```
The current SDK surface is published on the `canary` npm dist-tag.
## Choose a chain
The SDK connects to a hosted deployment by `environment` name. The examples below
use `base-sepolia`. See [Supported chains](/supported-chains) for the full list.
## Choose custody model
* Non-custodial browser apps import from `@unlink-xyz/sdk/browser`.
* Custodial servers, CLIs, workers, and agents import from `@unlink-xyz/sdk/client`.
Both models use `@unlink-xyz/sdk/admin` on your backend for registration,
authorization tokens, and backend reads. See [Custody models](/custody-models) for
the details.
## Create an API key
Create an API key before wiring your backend examples below:
1. Sign in at [dashboard.unlink.xyz](https://dashboard.unlink.xyz).
2. Create or select an organization.
3. Create a project and choose the target chain.
4. Open the project's **API Keys** page.
5. Click **Create key**, enter a label, and copy the revealed key.
6. Store it as `UNLINK_API_KEY` in your backend or server environment.
API keys are scoped to one project. The full key is shown exactly once, so copy
it before closing the dialog. Never ship this key to browser bundles.
## Browser non-custodial client
The browser owns the user's spending key. Your backend exposes two routes:
* `POST /api/unlink/register`
* `POST /api/unlink/authorization-token`
```ts theme={null}
import { account, createUnlinkClient } from "@unlink-xyz/sdk/browser";
const { account: unlinkAccount } = await account.fromWallet({
provider: window.ethereum,
appId: "your-app-id",
chainId: 84532,
});
const client = createUnlinkClient({
environment: "base-sepolia",
account: unlinkAccount,
});
await client.ensureRegistered();
```
By default, `createUnlinkClient` posts registration to `/api/unlink/register`
and authorization-token requests to `/api/unlink/authorization-token` on your
backend. Pass `registerUrl` or `authorizationToken.url` only if your app mounts
those routes somewhere else.
## App backend routes
Use the backend-only API key from [Create an API key](#create-an-api-key).
```ts theme={null}
import { createUnlinkAdmin, createUnlinkAuthRoutes } from "@unlink-xyz/sdk/admin";
const admin = createUnlinkAdmin({
environment: "base-sepolia",
apiKey: process.env.UNLINK_API_KEY!,
});
const routes = createUnlinkAuthRoutes({
admin,
authenticate: async (request) => getAppSession(request),
onRegister: async ({ session, registration }) => {
await db.linkUnlinkAddress(session.userId, registration.address);
},
authorizeUnlinkAddress: async ({ session, unlinkAddress }) =>
db.userOwnsUnlinkAddress(session.userId, unlinkAddress),
});
app.post("/api/unlink/register", (c) => routes.register(c.req.raw));
app.post("/api/unlink/authorization-token", (c) =>
routes.authorizationToken(c.req.raw),
);
```
Keep this API key on your backend. See
[Custody models](/custody-models#app-backend) for the canonical explanation of
these routes.
## Local demo routes
Browser clients call the register and authorization-token routes even in local
demos. For throwaway UI work, you can stub the routes, then replace the stubs
with `createUnlinkAuthRoutes` before testing real funds.
```ts theme={null}
app.post("/api/unlink/register", (c) => c.body(null, 204));
app.post("/api/unlink/authorization-token", (c) =>
c.json({
token: "demo.authorization.token",
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
}),
);
```
This is only a local mock. A real backend should authenticate the app user,
call `admin.users.register`, check address ownership, and issue the token
through `admin.authorizationTokens.issue`.
## Server or custodial client
When your server is allowed to hold user accounts, wire the user client directly
to the admin client. Use the backend-only API key from
[Create an API key](#create-an-api-key).
```ts theme={null}
import { createUnlinkAdmin } from "@unlink-xyz/sdk/admin";
import { account, createUnlinkClient } from "@unlink-xyz/sdk/client";
const admin = createUnlinkAdmin({
environment: "base-sepolia",
apiKey: process.env.UNLINK_API_KEY!,
});
const unlinkAccount = account.fromMnemonic({ mnemonic });
const unlinkAddress = await unlinkAccount.getAddress();
const client = createUnlinkClient({
environment: "base-sepolia",
account: unlinkAccount,
register: (payload) => admin.users.register(payload),
authorizationToken: {
provider: () => admin.authorizationTokens.issue({ unlinkAddress }),
},
});
await client.ensureRegistered();
```
The account passed to `createUnlinkClient` signs private actions. See
[Account constructors](/accounts-and-keys#account-constructors) for which helper
supports `execute()`.
## EVM provider
Deposits and `execute()` need an EVM provider. Use the adapter that matches your
wallet stack. Browser apps can start with the injected EIP-1193 provider.
```ts theme={null}
import { evm } from "@unlink-xyz/sdk/browser";
await window.ethereum.request({
method: "eth_requestAccounts",
});
const evmProvider = evm.fromEip1193({
provider: window.ethereum,
});
```
Pass `evm: evmProvider` when constructing the client, or pass it per call.
The SDK also includes `evm.fromViem`, `evm.fromEthers`, and `evm.fromSigner`
for apps that already use those stacks.
## Fund with test tokens
Faucet helpers are available only on configured testnet environments.
Use a test token configured for the environment you selected.
```ts theme={null}
await client.faucet.requestPrivateTokens({
token: testToken,
});
const { balances } = await client.getBalances();
console.log(balances);
```
## Transfer
```ts theme={null}
const tx = await client.transfer({
recipientAddress: "unlink1recipient...",
token: testToken,
amount: "250000000000000000",
});
const confirmed = await tx.wait();
console.log(confirmed.confirmationStatus); // "confirmed" | "processed" | "failed"
```
## Deposit from an EVM wallet
```ts theme={null}
const tx = await client.depositWithApproval({
token: testToken,
amount: "1000000000000000000",
});
await tx.wait(); // resolves at user-facing confirmation by default
```
See [Deposit](/deposit) for the full parameter reference.
## Derive account from a wallet signature
`account.fromWallet` (shown above) asks the wallet to sign a deterministic
message and derives the Unlink account from that signature. For the message
format, `fromEthereumSignature`, and recovery guidance, see
[Accounts and keys](/accounts-and-keys#derive-account-from-a-wallet-signature).
# Reading data and status
Source: https://docs.unlink.xyz/reading-data
Read balances and transaction history, track transaction status, and use admin backend reads.
## Read balances
```ts theme={null}
const { balances } = await client.getBalances();
```
Each token includes a display balance and its accounting breakdown:
```ts theme={null}
const [{ amount, spendable, pending_change }] = balances;
```
* `amount` is the wallet-facing balance: `spendable + pending_change`.
* `spendable` is backed by unspent, unreserved notes and is the value to use
when deciding whether a new transfer, withdrawal, or execute can be prepared.
* `pending_change` is the user's expected private output from unresolved
spends, usually change. It does not include another user's pending transfer
or an unconfirmed deposit.
Private spends consume whole notes and mint change. While a spend is in flight,
the consumed inputs are reserved and disappear from `spendable`, but their
expected owned outputs remain in `pending_change`. A wallet rendering `amount`
therefore does not show the temporary input-note dip. If the spend fails or an
atomic execute reverts, the input reservation is released and the full amount
returns to `spendable`.
The guarantee applies when `sync_status` is `current`. When it is `syncing`, the
service has failed closed while its optimistic index catches up. It never adds
optimistic-only notes or pending outputs. It may still use an already observed
output as negative evidence that an older canonical claim was consumed, so the
returned amount is a conservative lower bound rather than an overstatement.
Show a syncing state until reads return to `current`.
Filter by token:
```ts theme={null}
const { balances } = await client.getBalances({ token: "0xTokenAddress" });
```
## Read transactions
```ts theme={null}
const { transactions } = await client.getTransactions({
status: "processed", // optional
type: "transfer", // optional: "deposit" | "transfer" | "withdraw"
limit: 20, // optional
cursor: "...", // optional, for pagination
});
```
## Transaction status
`deposit()`, `transfer()`, and `withdraw()` return a `TransactionHandle` with
`txId`, `status`, `txHash`, and `wait()`. `status` is the backend processing
state. The wait result also includes `confirmationStatus`, the user-facing
lifecycle state.
Public lifecycle states:
* `pending`: preparing, proving, or waiting for an on-chain hash.
* `relayed`: submitted on chain, but private effects are not observed yet.
* `confirmed`: receipt or indexed evidence has observed the transaction effects,
and those effects are visible to API reads.
* `processed`: durable state persistence has completed.
* `failed`: terminal failure.
By default, `wait()` resolves at user-facing confirmation (`confirmed`,
`processed`, or `failed`). It does not wait for durable canonical processing
unless you ask for it.
```ts theme={null}
const tx = await client.transfer({ recipientAddress, token, amount });
const confirmed = await tx.wait({
intervalMs: 2000, // optional, default 2s
timeoutMs: 60000, // optional, default 60s
signal: ac.signal, // optional AbortSignal
onStatus: (s) => log(s), // optional progress callback
});
console.log(confirmed.confirmationStatus); // "confirmed" | "processed" | "failed"
```
The API now reports usability as `usable`: true once every private output the
transaction creates is a spendable, owner-attributed note. It replaces
`funds_usable`, which was only ever true for deposit owners and transfer
recipients — never for a transfer's sender holding the change note, and never
for a withdrawal. The SDK's `fundsUsable` is not yet wired to it and currently
falls back to `status === "processed"`, so it reads `false` during the
`confirmed` window. Wait on `processed` rather than branching on it.
Wait for durable canonical processing when a backend reliability check needs
finality:
```ts theme={null}
const finalized = await tx.wait({ until: "finalized" });
console.log(finalized.confirmationStatus); // "processed" | "failed"
```
`finalized` is an alias for backend `processed`, which is reached at the
configured canonical boundary.
If you already have a transaction ID, poll directly:
```ts theme={null}
const result = await client.pollTransactionStatus(txId, {
until: "confirmed", // optional; default. Use "processed" or "finalized" for durability.
intervalMs: 2000,
timeoutMs: 60000,
signal: ac.signal,
});
```
Polling throws `TimeoutError` if the timeout elapses before the selected wait
target.
See [Error handling](/errors).
## Backend reads
Use admin reads for backend dashboards and support tooling, and the user client
for signed actions.
```ts theme={null}
const user = await admin.users.get({ address: unlinkAddress });
const { balances } = await admin.users.getBalances({
address: unlinkAddress,
});
const { transactions } = await admin.users.getTransactions({
address: unlinkAddress,
type: "transfer",
status: "processed",
limit: 20,
});
const auth = await admin.authorizationTokens.issue({
unlinkAddress,
expiresInSeconds: 900,
});
```
Call `admin.users.register(payload)` from your backend when a browser or server
client registers. See [Custody models](/custody-models) for how the admin client
is wired.
# Supported chains
Source: https://docs.unlink.xyz/supported-chains
Hosted Unlink environments and the environment name to pass when creating a client.
Unlink is multichain. SDK clients connect to a hosted deployment by its
`environment` name. Pass that name when you create a client, and use the matching
chain ID when you derive an account with `account.fromWallet`.
## Environments
Only production environments are listed here.
### Testnets
| Environment | Network | Chain ID | Status |
| ------------------ | ---------------- | -------- | --------- |
| `arc-testnet` | Arc Testnet | 5042002 | Available |
| `avalanche-fuji` | Avalanche Fuji | 43113 | Available |
| `base-sepolia` | Base Sepolia | 84532 | Available |
| `bsc-testnet` | BSC Testnet | 97 | Available |
| `ethereum-sepolia` | Ethereum Sepolia | 11155111 | Available |
| `monad-testnet` | Monad Testnet | 10143 | Available |
### Mainnets
Mainnet environments currently require an access grant before use.
| Environment | Network | Chain ID | Status |
| ----------- | --------------- | -------- | --------------------------------------------------------------------- |
| `base` | Base | 8453 | [Contact us for access](https://cal.com/team/unlink/chat-with-unlink) |
| `bsc` | BNB Smart Chain | 56 | [Contact us for access](https://cal.com/team/unlink/chat-with-unlink) |
| `monad` | Monad | 143 | [Contact us for access](https://cal.com/team/unlink/chat-with-unlink) |
```ts theme={null}
const client = createUnlinkClient({
environment: "base-sepolia",
account: unlinkAccount,
});
```
The chain ID you pass to `account.fromWallet` must match the environment's
chain. Deriving with the wrong chain ID produces a different account. See
[Accounts and keys](/accounts-and-keys) for how derivation binds to `chainId`.
## Get testnet gas
Use these public faucets to fund wallets with native testnet gas.
| Network | Environment | Gas token | Faucet |
| ---------------- | ------------------ | --------- | ----------------------------------------------------------------------------- |
| Arc Testnet | `arc-testnet` | USDC | [Circle faucet](https://faucet.circle.com/) |
| Avalanche Fuji | `avalanche-fuji` | AVAX | [Avalanche faucet](https://build.avax.network/console/primary-network/faucet) |
| Base Sepolia | `base-sepolia` | ETH | [Alchemy faucet](https://www.alchemy.com/faucets/base-sepolia) |
| BSC Testnet | `bsc-testnet` | tBNB | [BNB Chain faucet](https://www.bnbchain.org/en/testnet-faucet) |
| Ethereum Sepolia | `ethereum-sepolia` | ETH | [Alchemy faucet](https://www.alchemy.com/faucets/ethereum-sepolia) |
| Monad Testnet | `monad-testnet` | MON | [Monad faucet](https://faucet.monad.xyz/) |
# Transfer
Source: https://docs.unlink.xyz/transfer
Send tokens privately between Unlink addresses.
Transfer tokens privately to one or more Unlink addresses. The sender is the account bound to your user client. See [Quickstart](/quickstart). Sender, recipient, and amount are all hidden by a zero-knowledge proof; see [How Unlink works](/how-unlink-works).
The `transfer()` method signs with the spending key from the account bound to
`createUnlinkClient`. All public account constructors support private
transfers.
## Single recipient
```ts theme={null}
const tx = await client.transfer({
recipientAddress: "unlink1...",
token: "0xTokenAddress",
amount: "500000000000000000",
});
const confirmed = await tx.wait();
console.log(confirmed.confirmationStatus); // "confirmed" | "processed" | "failed"
```
`confirmed` means the transaction has been observed; it does not always mean
new private funds are spendable. If your next action spends funds from a recent
deposit or transfer, wait with `tx.wait({ until: "processed" })`.
`fundsUsable` on the wait result is being replaced by the API's `usable`,
which covers the sender's change note and withdrawals as well — cases
`fundsUsable` never covered. Until the SDK is updated, `fundsUsable` falls
back to `status === "processed"`, so it reads `false` during the `confirmed`
window rather than `true`. Prefer waiting on `processed`; do not branch on
`fundsUsable`.
## Multiple recipients
```ts theme={null}
const tx = await client.transfer({
token: "0xTokenAddress",
transfers: [
{ recipientAddress: "unlink1aaa...", amount: "100000000000000000" },
{ recipientAddress: "unlink1bbb...", amount: "200000000000000000" },
],
});
await tx.wait(); // resolves at user-facing confirmation by default
```
## Parameters
**Single recipient:**
* `recipientAddress`: recipient Unlink address.
* `token`: ERC-20 token address.
* `amount`: amount in the token's smallest unit.
**Multiple recipients:**
* `token`: ERC-20 token address.
* `transfers`: list of recipients and amounts.
**Returns:** a `TransactionHandle`. See [Transaction status](/reading-data#transaction-status).
# Trust model
Source: https://docs.unlink.xyz/trust-model
Where each secret lives and which process it crosses.
Unlink splits work between a **Client** surface (the user's process, which holds the spending key) and an **Engine** (the Unlink backend, which builds Groth16 proofs and broadcasts on-chain). This page describes which secrets live where so you can pick the right integration shape — the SDK enforces these boundaries; you can verify the claim by reading the code in [`protocol/sdk`](https://github.com/unlink-xyz/monorepo/tree/main/protocol/sdk).
## Data flow
```mermaid theme={null}
flowchart LR
subgraph Client["Client process (browser, CLI, agent)"]
K1["Spending key (never leaves)"]
K2["Viewing + nullifying keys (sent at registration)"]
S1["SDK signs SigningRequest locally"]
end
subgraph Engine["Unlink Engine"]
E1["Groth16 proof generation"]
E2["Transaction assembly"]
end
subgraph Relayer["Relayer"]
R1["Gas sponsorship"]
R2["EVM broadcast"]
end
subgraph Chain["Public blockchain"]
C1["Unlink contract verifies proof"]
end
K1 -->|"signed request"| S1
S1 -->|"signature + plan"| E1
K2 -.->|"once, at registration"| E1
E1 --> E2
E2 -->|"calldata"| R1
R1 --> R2
R2 --> C1
style Client fill:#ffffff,stroke:#1a1a1a,color:#1a1a1a
style Engine fill:#ffffff,stroke:#b8b6b1,color:#1a1a1a
style Relayer fill:#ffffff,stroke:#b8b6b1,color:#1a1a1a
style Chain fill:#ffffff,stroke:#b8b6b1,color:#1a1a1a
```
The two key nodes are grouped by **trust profile**: the spending key never leaves the client, while the viewing and nullifying keys both cross to the Engine exactly once, during registration.
## Where each secret lives
| Secret | Lives in | Crosses to | Notes |
| ---------------------------------------------- | ---------------------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Spending key** (EdDSA on BabyJubJub) | Client process only | Never leaves | Signs the `SigningRequest` locally after the SDK verifies the prepared operation intent and output artifacts; the SDK sends only the resulting signature to the Engine. |
| **Viewing + nullifying keys** (Ed25519 + hash) | Client process | Engine, once at registration | Both sent during the one-time `register` step. After registration the Engine retains them to decrypt note memos and track which notes are spendable for the user. |
| **Admin API key** | Server process (your backend) | Engine, on every authenticated request | Held by `createUnlinkAdmin` (`@unlink-xyz/sdk/admin`). Never ship it in a browser bundle — the browser uses `createUnlinkClient` from `@unlink-xyz/sdk/browser`, which only ever sees short-lived authorization tokens. |
| **Authorization token** | Browser session (rotated \~15 min) | Engine, on every browser request | Minted by your backend's `/api/unlink/authorization-token` route from the admin API key. Scoped to one `unlinkAddress`. |
## What the Engine does and doesn't see
* **Sees**: the public Unlink address, the signed `SigningRequest`, viewing/nullifying keys after registration (so it can decrypt memos and reason about which notes are spendable).
* **Does not see**: your spending key. Every authorisation is signed in the client process after the SDK checks the prepared operation and output artifacts against the requested transfer, withdrawal, or execution funding withdrawal. Before an EVM wallet signs a deposit or execution deposit-back, the SDK also derives the intended recipient note and reconstructs the prepared `notes_hash`. This prevents the Engine from substituting a different note owner, token, or amount while preserving the client-signed hash.
* **Generates**: the Groth16 proof. Proof generation is heavy and runs on the Engine for performance; the witness is built from public state plus the client-supplied signature, so the proof is sound without the spending key.
* **Broadcasts via the relayer**: gas sponsorship is paid by the relayer using the tenant's relay balance.
## Remaining Engine trust boundaries
Deposit verification is deterministic and stateless, but it does not make the
Engine fully untrusted:
* The SDK binds the returned ciphertext into `notes_hash`, but does not yet
decrypt it and verify that its plaintext is the requested nonce, token, and
amount. A compromised Engine cannot redirect note ownership after the NPK
check, but malformed ciphertext can make the note undiscoverable.
* The SDK currently obtains chain ID, pool address, and Permit2 address from the
Engine's environment endpoint. A named hosted environment pins the Engine
URL, not those signature- and approval-critical values. Treat deployment
configuration as trusted until the SDK independently pins and checks it.
## Choosing the right integration shape
* **Browser (non-custodial)** — use [`createUnlinkClient` from `@unlink-xyz/sdk/browser`](/quickstart#browser-non-custodial-client). The admin API key stays on your backend; the spending key stays in the browser, which only ever holds short-lived authorization tokens.
* **Server / custodial** — use `createUnlinkClient` from `@unlink-xyz/sdk/client` with a server-held spending key. The admin API key and the spending key live in the same trusted process. You can still route signing to a remote key holder via a `signSigningRequest` callback so your backend never holds the spending key.
See [Custody models](/custody-models) for the full comparison.
This page is the integrator-facing trust model. The team-internal spec for
auditors (full property list, adversaries, threat model) lives at
`docs/internal/spec/trust-model.md` and is not published.
# Withdraw
Source: https://docs.unlink.xyz/withdraw
Move tokens from the unlink contract to any EVM address.
Withdraw tokens from the unlink contract to any EVM address. Tokens are withdrawn from the account bound to your user client. See [Quickstart](/quickstart). The destination address and amount are public, but the source private account is not; see [How Unlink works](/how-unlink-works).
The `withdraw()` method signs with the spending key from the account bound to
`createUnlinkClient`.
```ts theme={null}
const tx = await client.withdraw({
recipientEvmAddress: "0xRecipient",
token: "0xTokenAddress",
amount: "500000000000000000",
});
const confirmed = await tx.wait();
console.log(confirmed.confirmationStatus); // "confirmed" | "processed" | "failed"
```
`confirmed` means the withdrawal has been observed; use
`tx.wait({ until: "processed" })` when your flow needs durable canonical
processing before continuing.
## Parameters
* `recipientEvmAddress`: destination EVM address.
* `token`: ERC-20 token address.
* `amount`: amount in wei.
**Returns:** a `TransactionHandle`. See [Transaction status](/reading-data#transaction-status).