Skip to content
Withdraw

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

Anyone can see
  • The address that receives it
  • The token: USDC
  • The amount: 10 USDC
Stays private
  • 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.

Caution

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,
  : .,
});
  • 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.

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:

kindWhat happenedWhat to do
relayedThe relayer accepted the withdrawal.Read its settlement, above.
refusedIt stopped before anything was sent. refusal says why.See the refusals on Private Send.
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 withdrawal as sent, so nothing was sent.Ask Bob 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.

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.

ErrorReasonCause
NimbusSolanaFlowErrorpayout-unpayableThe destination cannot be paid. See above.
NimbusSolanaFlowErrorrelay-unavailableThe relayer will not carry this withdrawal, publishes no creation fee for a missing token account, or the fee you offered is too low.
NimbusSolanaFlowErroramount-unfundableThe fees would leave the recipient nothing, or the amount is out of range.
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 withdrawal. Try again.
NimbusSdkErrormisconfiguredAn address is not valid base58, or the mint does not exist on this cluster.
NimbusSdkErroraccount-unregisteredBob's account has not registered. Run register first.
NimbusSdkErrorservice-unavailableSolana or a Nimbus service could not be reached. Try again.
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 withdrawal.
recipientTokenAccountthe associated token accountThe token account paid.
followtruefalse 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.
submitterFeethe relayer's minimum, plus its creation fee for a missing token accountOffer 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