Withdraw
Move tokens out of the private balance to any Solana address, the user's own or anyone else's.
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
- The address that receives it
- The token: USDC
- The amount: 10 USDC
- That Bob withdrew
- Which private balance paid for it
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 has more.
Withdraw
Create a withdrawal context from the session, then call withdraw.
import { } from "@solana/kit";
import { , } from "@umbra/nimbus-solana-web";
const = ("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const = await ();
const = await (, {
: ,
: 10_000_000n,
: .,
});amountis what leaves the private balance, in the token's smallest unit. The fees come out of it. Passfees: "added"so the recipient receives exactlyamount.recipientis the wallet paid. Here it is Bob's own, but any Solana address works.mintis 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.
import { } from "@solana/kit";
import { } from "@umbra/nimbus-solana-web";
const = ("So11111111111111111111111111111111111111112");
const = await (
,
{ : , : 100_000_000n, : },
{ : },
);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 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 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.
function (: ): string {
switch (.) {
case "relayed":
switch (..) {
case "landed":
return "Withdrawn.";
case "failed":
return `The withdrawal 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 withdrawal.";
}
case "refused":
switch (..) {
case "insufficient":
return `You can withdraw at most ${..} 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: ${.}`;
case "unsent":
return "Nothing was sent. Reload and try again.";
case "merge-failed":
return `Combining your balance stopped after ${.} step(s).`;
}
}Show progress
Pass onProgress to tell Bob 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. Show 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. 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 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 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 lists every option.
Next
Last updated