# 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).