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

# Private Send (/transfer)

A Private Send pays another user from the private balance. The tokens never leave the pool. They
move from the sender's private balance to the recipient's.

In this guide, Alice pays Bob 10 USDC. Anyone watching the chain sees that a payment happened, but
not who paid, who was paid or how much.

## What's public

A relayer sends the transaction, so neither wallet appears on chain. Bob himself can tell the
payment came from Alice. Each payment records who funded it, and Bob's keys can read that.
[How privacy is lost](https://docs.nimbusprivacy.xyz/privacy/leaks) explains why.

## Send

Create a transfer context from the session, then call `transfer` with Bob's wallet address.

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

const usdc = address("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");

const context = await createTransferContext(session);
const result = await transfer(context, {
  mint: usdc,
  amount: 10_000_000n,
  recipient: bob,
});
```

- **`amount`** is in the token's smallest unit, so 10 USDC is `10_000_000n`. The fees come out of
  it, so Bob receives a little less. Pass `fees: "added"` to pay them on top.
- **`recipient`** is Bob's wallet address. Bob must have [registered](https://sdk.nimbusprivacy.xyz/registration).
- **`mint`** is the token. Alice pays from her private balance in it.

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

## Follow it to the end

By default, `transfer` follows the payment until it lands, then syncs the device. Two options change
that:

- **`follow: false`** returns as soon as the relayer accepts the payment.
- **`commitment: "finalized"`** waits until the payment cannot be rolled back. The default is
  `"confirmed"`.

A `relayed` result carries how following it went, in `result.settlement.kind`:

| `kind`         | What happened                                                                        | What to do                               |
| -------------- | ------------------------------------------------------------------------------------ | ---------------------------------------- |
| `landed`       | The payment landed, and the device synced. `settlement.sync` says how the sync went. | Done. Bob sees it after his next sync.   |
| `failed`       | The relayer reported it `rejected`, `expired` or `failed`. Nothing moved.            | Alice can try again in a few minutes.    |
| `pending`      | The SDK stopped following before it settled. It may still land.                      | Sync again in a minute to see it settle. |
| `not-followed` | You passed `follow: false`.                                                          | Sync later to see it settle.             |

After `failed`, the funds the payment used stay reserved for a few minutes. After `pending` or
`not-followed`, they stay reserved until a sync sees the payment settle.
[Reserved funds](https://sdk.nimbusprivacy.xyz/explainers/reserved-funds) explains why.

## Handle the result

`transfer` 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 payment.                                                                               | Read its settlement, above.                 |
| `refused`       | It stopped before anything was sent. `refusal` says why.                                                        | See the refusals below.                     |
| `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 payment as sent, so nothing was sent.                                         | Ask Alice 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.        |

A `refused` result carries a `refusal`, whose `kind` says what stopped it:

| `refusal.kind`                                                          | What happened                                                                               | What to do                                  |
| ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------- |
| `insufficient`                                                          | Alice cannot cover the amount and its fees. `available` is what she can spend of the token. | Show `available`, and ask for less.         |
| `not-registered`                                                        | Alice's own registration is not final on Solana yet.                                        | Try again in a minute.                      |
| `lease-refused`                                                         | Another payment, perhaps in another tab, took some of the same funds.                       | Try again once it finishes.                 |
| `store-refused`, `attempt-refused`, `stale-handle`, `store-unavailable` | This browser's account data could not be used.                                              | Ask Alice to reload the page and try again. |

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

```ts
function describeSettlement(settlement: SpendSettlement): string {
  switch (settlement.kind) {
    case "landed":
      return "Sent.";
    case "failed":
      return `The payment did not go through (${settlement.state}).`;
    case "pending":
      return "Sent, and not settled yet. Check again in a minute.";
    case "not-followed":
      return "The relayer accepted the payment.";
  }
}

function describeRefusal(refusal: SpendRefusal): string {
  switch (refusal.kind) {
    case "insufficient":
      return `You can send at most ${refusal.available} base units, fees included.`;
    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.";
  }
}

function describeTransfer(result: TransferResult): string {
  switch (result.kind) {
    case "relayed":
      return describeSettlement(result.settlement);
    case "refused":
      return describeRefusal(result.refusal);
    case "relay-refused":
      return `The relayer refused the payment: ${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).`;
  }
}
```

A withdrawal returns the same type, so `describeTransfer` works for `withdraw` too.

## When the balance is spread out

Alice's private balance in a token is made of separate amounts, one for each payment she received.
A single Private Send can draw on at most three of them.

If her three largest amounts cannot cover 10 USDC, but all of them together can, the SDK combines
them first. Each combining step is a Private Send to herself, sent by the relayer. The SDK waits
for each step to land and reach her device before the next.

That takes longer, and each step pays the relayer's fee. Each step may take up to `mergeTimeout`
slots, 300 by default. `onProgress` reports each step as `merge-prepared`, `merge-relayed` and
`merge-settled`, numbered from 1 in `merge`.

If a step does not finish, the result is `merge-failed`. The steps that finished stay combined.
`cause` says why the next one stopped.

## Show progress

Pass `onProgress` to tell Alice what is happening:

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

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

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

## Edge cases

### Not enough available balance

The result is `refused` with `insufficient`. Its `available` counts only what Alice can spend right
now. Funds held by an unfinished payment are left out.

### Funds reserved by another tab

A payment running in another tab holds the funds it uses. This payment cannot use them, and
`available` leaves them out. If both tabs reach for the same funds at once, one gets
`lease-refused`. [Reserved funds](https://sdk.nimbusprivacy.xyz/helpers/reserved-funds) lists what is held and by what.

### Bob is not registered

`transfer` throws a `NimbusSdkError` with reason `recipient-unregistered`, and nothing is sent.
Ask Bob to open your app and register first.

## Common errors

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

| Error                   | Reason                   | Cause                                                                                    |
| ----------------------- | ------------------------ | ---------------------------------------------------------------------------------------- |
| `NimbusSdkError`        | `recipient-unregistered` | Bob has not registered.                                                                  |
| `NimbusSdkError`        | `account-unregistered`   | Alice has not registered. Run `register` first.                                          |
| `NimbusSdkError`        | `service-unavailable`    | Solana or a Nimbus service could not be reached. Try again.                              |
| `NimbusSolanaFlowError` | `relay-unavailable`      | The relayer will not carry this payment: a token it does not relay, or a fee it refuses. |
| `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 payment. Try again.            |
| `NimbusSolanaFlowError` | `amount-unfundable`      | The fees would leave Bob nothing, or the amount is out of range.                         |
| `NimbusSolanaFlowError` | `zone-unauthorised`      | You passed a `zone` option, and its signer refused.                                      |
| `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 payment.                                 |
| `follow`       | `true`                | `false` returns once the relayer accepts the payment.                |
| `commitment`   | `"confirmed"`         | `"finalized"` waits until the payment cannot be rolled back.         |
| `fees`         | `"deducted"`          | `"added"` pays the fees on top, so Bob receives `amount`.            |
| `submitterFee` | the relayer's minimum | 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/transfer#transfer) lists every option.

## Next

- [Withdraw](https://sdk.nimbusprivacy.xyz/withdraw): Take tokens out of the private balance to any address.
- [Show history](https://sdk.nimbusprivacy.xyz/helpers/history): How Bob sees the payment arrive.
- [Reserved funds](https://sdk.nimbusprivacy.xyz/explainers/reserved-funds): Why a payment can hold funds for a few minutes.


Last updated 21 October 2026
