> For AI agents: the documentation index is at https://sdk.nimbusprivacy.xyz/llms.txt, and every page is available as Markdown at its URL with .md appended.

# Withdraw (/withdraw)

A withdrawal moves tokens out of the private balance to a public Solana address. It can be the
user's own wallet or anyone else's.

In this guide, Bob withdraws 10 USDC to his wallet. Then he sends SOL straight to another address.

## What's public

A relayer sends the withdrawal, so no wallet of Bob's signs it. The address that receives the
tokens is public, like any Solana transfer.

> **Keep the two ends apart**
>
> Avoid withdrawing the exact amount you just deposited, since matching amounts and times suggest
> the same person. A withdrawal to a wallet already tied to you, such as the one you registered,
> shows who withdrew. [Privacy practices](https://docs.nimbusprivacy.xyz/privacy/practices) has more.

## Withdraw

Create a withdrawal context from the session, then call `withdraw`.

```ts
import { address } from "@solana/kit";
import { createWithdrawalContext, withdraw } from "@umbra/nimbus-solana-web";

const usdc = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");

const context = await createWithdrawalContext(session);
const result = await withdraw(context, {
  mint: usdc,
  amount: 10_000_000n,
  recipient: session.walletAddress,
});
```

- **`amount`** is what leaves the private balance, in the token's smallest unit. The fees come out
  of it. Pass `fees: "added"` so the recipient receives exactly `amount`.
- **`recipient`** is the wallet paid. Here it is Bob's own, but any Solana address works.
- **`mint`** is the token. Bob pays from his private balance in it.

Bob's wallet signs nothing. The SDK builds the proof on his device, and a relayer sends the
transaction and pays its network fee.

## Where the tokens go

### A wallet

By default the tokens go to the recipient wallet's associated token account for the mint. If that
account does not exist, the relayer creates it and pays its rent. The relayer charges a creation
fee for that, which comes out of the withdrawal, unless it covers the cost itself.

### A token account

To pay a different token account, pass it as `recipientTokenAccount`. It must already exist and hold
the same token.

### SOL itself

To send SOL rather than wrapped SOL, withdraw the wrapped SOL mint. Pass the receiving wallet's own
address as `recipientTokenAccount` too. The SOL then lands in the wallet itself.

```ts
import { address } from "@solana/kit";
import { withdraw } from "@umbra/nimbus-solana-web";

const wrappedSol = address("So11111111111111111111111111111111111111112");

const result = await withdraw(
  context,
  { mint: wrappedSol, amount: 100_000_000n, recipient: destination },
  { recipientTokenAccount: destination },
);
```

A wallet that holds no SOL must receive at least 890,880 lamports, Solana's rent-exempt minimum.
Without `recipientTokenAccount`, the SDK pays the wallet's wrapped SOL token account instead. If that
account does not exist, `withdraw` throws.

### When the destination cannot be paid

`withdraw` reads the destination from Solana before it asks Nimbus for anything. When it cannot be
paid, `withdraw` throws a `NimbusSolanaFlowError` with reason `payout-unpayable`. Some causes:

- The token account holds a different token.
- The account belongs to neither token program.
- The account does not exist, and it is not the recipient's associated token account.
- A SOL payment would leave the wallet below the rent-exempt minimum.

Nothing is sent. Choose another destination, or a larger amount.

## Follow it to the end

A withdrawal is followed the way a Private Send is. By default `withdraw` waits until it lands, then
syncs the device. Pass `follow: false` to return once the relayer accepts it.

A `relayed` result's `settlement.kind` is `landed`, `failed`, `pending` or `not-followed`.
[Follow it to the end](https://sdk.nimbusprivacy.xyz/transfer#follow-it-to-the-end) says what each means and what to do.

## Handle the result

`withdraw` returns a result instead of throwing for anything your user can cause. Check its `kind`:

| `kind`          | What happened                                                                                                   | What to do                                                                                    |
| --------------- | --------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `relayed`       | The relayer accepted the withdrawal.                                                                            | Read its settlement, above.                                                                   |
| `refused`       | It stopped before anything was sent. `refusal` says why.                                                        | See [the refusals](https://sdk.nimbusprivacy.xyz/transfer#handle-the-result) on Private Send. |
| `relay-refused` | The relayer turned it down, with a `code` and a `detail`. Nothing reached Solana, and its funds are free again. | Check the `code`, then try again.                                                             |
| `unsent`        | This browser would not record the withdrawal as sent, so nothing was sent.                                      | Ask Bob to reload the page and try again.                                                     |
| `merge-failed`  | The balance had to be combined first, and that stopped.                                                         | Try again. It needs fewer steps now.                                                          |

Here is the whole check. TypeScript checks that it covers every case.

```ts
function describeWithdrawal(result: WithdrawalResult): string {
  switch (result.kind) {
    case "relayed":
      switch (result.settlement.kind) {
        case "landed":
          return "Withdrawn.";
        case "failed":
          return `The withdrawal did not go through (${result.settlement.state}).`;
        case "pending":
          return "Sent, and not settled yet. Check again in a minute.";
        case "not-followed":
          return "The relayer accepted the withdrawal.";
      }
    case "refused":
      switch (result.refusal.kind) {
        case "insufficient":
          return `You can withdraw at most ${result.refusal.available} base units.`;
        case "not-registered":
          return "Your registration is not final yet. Try again in a minute.";
        case "lease-refused":
          return "Another payment is using these funds. Try again soon.";
        case "store-refused":
        case "attempt-refused":
        case "stale-handle":
        case "store-unavailable":
          return "This browser could not use your account data. Reload and try again.";
      }
    case "relay-refused":
      return `The relayer refused the withdrawal: ${result.detail}`;
    case "unsent":
      return "Nothing was sent. Reload and try again.";
    case "merge-failed":
      return `Combining your balance stopped after ${result.mergesCompleted} step(s).`;
  }
}
```

## Show progress

Pass `onProgress` to tell Bob what is happening:

```ts
import { withdraw } from "@umbra/nimbus-solana-web";

await withdraw(
  context,
  { mint: usdc, amount: 10_000_000n, recipient },
  { onProgress: (event) => showStatus(event.stage) },
);
```

The stages are `synced`, then any combining steps, then `prepared`, `proved`, `relayed` and
`settled`. [Show progress](https://sdk.nimbusprivacy.xyz/api-design/progress) has a message for each stage.

## Edge cases

### The balance is spread out

One withdrawal draws on at most three separate amounts in the private balance. When those cannot
cover it, the SDK combines amounts first, as a
[Private Send does](https://sdk.nimbusprivacy.xyz/transfer#when-the-balance-is-spread-out). That takes longer and costs a relayer
fee per step.

### A token with a transfer fee

Some Token-2022 tokens keep a fee on every transfer. The recipient then receives less again,
even with `fees: "added"`.

### Trying again after a refusal

After `relay-refused`, the funds it used are free again at once, so a retry can spend them. After a
`failed` settlement they stay reserved for a few minutes. A retry straight away may then come back
`insufficient`. [Reserved funds](https://sdk.nimbusprivacy.xyz/helpers/reserved-funds) shows what is held.

## Common errors

These are thrown, not returned, because your app usually cannot fix them by asking the user.

| Error                   | Reason                 | Cause                                                                                                                                 |
| ----------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `NimbusSolanaFlowError` | `payout-unpayable`     | The destination cannot be paid. See above.                                                                                            |
| `NimbusSolanaFlowError` | `relay-unavailable`    | The relayer will not carry this withdrawal, publishes no creation fee for a missing token account, or the fee you offered is too low. |
| `NimbusSolanaFlowError` | `amount-unfundable`    | The fees would leave the recipient nothing, or the amount is out of range.                                                            |
| `NimbusSolanaFlowError` | `committee-mismatched` | Nimbus's approval could not be put together. Nothing was sent. Try again.                                                             |
| `NimbusSolanaFlowError` | `context-mismatched`   | A Nimbus service answered with data that does not fit the withdrawal. Try again.                                                      |
| `NimbusSdkError`        | `misconfigured`        | An address is not valid base58, or the mint does not exist on this cluster.                                                           |
| `NimbusSdkError`        | `account-unregistered` | Bob's account has not registered. Run `register` first.                                                                               |
| `NimbusSdkError`        | `service-unavailable`  | Solana or a Nimbus service could not be reached. Try again.                                                                           |
| `NimbusSdkError`        | `unauthorized`         | The session has no gateway token, or the gateway refused it.                                                                          |

[Handle errors](https://sdk.nimbusprivacy.xyz/api-design/results-and-errors) shows one function that covers every error the SDK throws.

## Options

| Option                  | Default                                                                  | What it does                                                         |
| ----------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `onProgress`            | none                                                                     | Called at each stage of the withdrawal.                              |
| `recipientTokenAccount` | the associated token account                                             | The token account paid.                                              |
| `follow`                | `true`                                                                   | `false` returns once the relayer accepts the withdrawal.             |
| `commitment`            | `"confirmed"`                                                            | `"finalized"` waits until the withdrawal cannot be rolled back.      |
| `fees`                  | `"deducted"`                                                             | `"added"` pays the fees on top, so the recipient receives `amount`.  |
| `submitterFee`          | the relayer's minimum, plus its creation fee for a missing token account | Offer the relayer more, in base units.                               |
| `mergeTimeout`          | `300n`                                                                   | How many slots each combining step may take.                         |
| `sync`                  | `true`                                                                   | Brings the device up to date first. Pass `false` if you just synced. |

The [API reference](https://sdk.nimbusprivacy.xyz/reference/withdraw#withdraw) lists every option.

## Next

- [Load the SDK](https://sdk.nimbusprivacy.xyz/helpers/load): Every option loadNimbusSdk takes.
- [Show balances](https://sdk.nimbusprivacy.xyz/helpers/balances): Read what the private balance holds after the withdrawal.
- [Private Send](https://sdk.nimbusprivacy.xyz/transfer): Pay another user without leaving the pool.


Last updated 21 October 2026
