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
- That a relayer sent a transaction
- The relayer's tip, if one is paid
- 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,
: ,
});amountis in the token's smallest unit, so 10 USDC is10_000_000n. The fees come out of it, so Bob receives a little less. Passfees: "added"to pay them on top.recipientis Bob's wallet address. Bob must have registered.mintis 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: falsereturns 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 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.
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.
| 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 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 lists every option.
Next
Last updated