Skip to content
Transfer

Private Send

Pay another user from the private balance, so the tokens arrive in theirs and the chain shows neither name nor amount.

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

Anyone can see
  • That a relayer sent a transaction
  • The relayer's tip, if one is paid
Stays private
  • Alice, who paid
  • Bob, who was paid
  • The token: USDC
  • The amount: 10 USDC

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

Send

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

import {  } from "@solana/kit";
import { ,  } from "@umbra/nimbus-solana-web";

const  = ("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");

const  = await ();
const  = await (, {
  : ,
  : 10_000_000n,
  : ,
});
  • 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.
  • 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:

kindWhat happenedWhat to do
landedThe payment landed, and the device synced. settlement.sync says how the sync went.Done. Bob sees it after his next sync.
failedThe relayer reported it rejected, expired or failed. Nothing moved.Alice can try again in a few minutes.
pendingThe SDK stopped following before it settled. It may still land.Sync again in a minute to see it settle.
not-followedYou 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 explains why.

Handle the result

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

kindWhat happenedWhat to do
relayedThe relayer accepted the payment.Read its settlement, above.
refusedIt stopped before anything was sent. refusal says why.See the refusals below.
relay-refusedThe 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.
unsentThis browser would not record the payment as sent, so nothing was sent.Ask Alice to reload the page and try again.
merge-failedThe 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.kindWhat happenedWhat to do
insufficientAlice cannot cover the amount and its fees. available is what she can spend of the token.Show available, and ask for less.
not-registeredAlice's own registration is not final on Solana yet.Try again in a minute.
lease-refusedAnother payment, perhaps in another tab, took some of the same funds.Try again once it finishes.
store-refused, attempt-refused, stale-handle, store-unavailableThis 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.

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

function (: ): string {
  switch (.) {
    case "insufficient":
      return `You can send at most ${.} 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 (: ): string {
  switch (.) {
    case "relayed":
      return (.);
    case "refused":
      return (.);
    case "relay-refused":
      return `The relayer refused the payment: ${.}`;
    case "unsent":
      return "Nothing was sent. Reload and try again.";
    case "merge-failed":
      return `Combining your balance stopped after ${.} 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:

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

await (
  ,
  { : , : 10_000_000n, :  },
  { : () => (.) },
);

The stages are synced, then any combining steps, then prepared, proved, relayed and settled. settled comes only while following. Show 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 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.

ErrorReasonCause
NimbusSdkErrorrecipient-unregisteredBob has not registered.
NimbusSdkErroraccount-unregisteredAlice has not registered. Run register first.
NimbusSdkErrorservice-unavailableSolana or a Nimbus service could not be reached. Try again.
NimbusSolanaFlowErrorrelay-unavailableThe relayer will not carry this payment: a token it does not relay, or a fee it refuses.
NimbusSolanaFlowErrorcommittee-mismatchedNimbus's approval could not be put together. Nothing was sent. Try again.
NimbusSolanaFlowErrorcontext-mismatchedA Nimbus service answered with data that does not fit the payment. Try again.
NimbusSolanaFlowErroramount-unfundableThe fees would leave Bob nothing, or the amount is out of range.
NimbusSolanaFlowErrorzone-unauthorisedYou passed a zone option, and its signer refused.
NimbusSdkErrorunauthorizedThe session has no gateway token, or the gateway refused it.

Handle errors shows one function that covers every error the SDK throws.

Options

OptionDefaultWhat it does
onProgressnoneCalled at each stage of the payment.
followtruefalse 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.
submitterFeethe relayer's minimumOffer the relayer more, in base units.
mergeTimeout300nHow many slots each combining step may take.
synctrueBrings the device up to date first. Pass false if you just synced.

The API reference lists every option.

Next

Last updated

On this page