Quickstart
Go from an empty Vite or Next.js app to Alice depositing, paying Bob privately, and Bob withdrawing.
What you'll build
Alice deposits 25 USDC into her private balance, then pays Bob 10 USDC from it. Bob takes his USDC back out to his own wallet.
Everything runs on mainnet, so it moves real USDC. Each step below is a few lines of browser code. Each one ends by checking that it worked, before the next step starts.
Before you start
- A gateway token. It lets your app use Nimbus's services on mainnet. To get one, email dev@umbraprivacy.com or reach out to any Umbra team member.
- Chrome on a desktop. It is the only browser the SDK is tested in. See Browser support.
- A wallet with real funds. Alice needs at least 25 USDC, and a little SOL for network fees.
- A second wallet for Bob. He registers it on his own device before Alice pays him.
Install
pnpm add @umbra-privacy/nimbus-solana-web @solana/kitYou use @solana/kit for its address function, which turns a base58 string into an Address.
Serve the SDK's files
The SDK ships WebAssembly and a worker script that your app serves itself. Copy them into your static folder after every install, and keep that folder out of git:
{
"scripts": {
"postinstall": "nimbus-solana-web copy-assets public/nimbus"
}
}You have already installed, so the script has not run yet. Run it once now:
npx nimbus-solana-web copy-assets public/nimbusCheck that public/nimbus now holds the SDK's files.
The page that loads the SDK also needs two headers. They let the proofs use shared memory.
const = [
{ : "Cross-Origin-Opener-Policy", : "same-origin" },
{ : "Cross-Origin-Embedder-Policy", : "require-corp" },
];
export default { : async () => [{ : "/:path*", : }] };Install and set up explains both, and covers Vercel and Cloudflare.
Load the SDK
Load it once per page, in code that runs in the browser. It needs no wallet yet.
import { } from "@umbra-privacy/nimbus-solana-web";
const = await (
{ : "mainnet" },
{ : "/nimbus/" },
);assetsBaseUrl is the URL of the folder you copied the files into. In React, load it from an
effect; Use it with React shows how.
Connect a wallet
listWallets returns the installed wallets that can sign on mainnet. Show them, and connect the
one Alice picks.
import { , } from "@umbra-privacy/nimbus-solana-web";
const [] = ();
if ( === ) throw new ("Install a Solana wallet first.");
const = await (, { });
switch (.) {
case "declined":
throw new ("Alice declined to connect.");
case "no-account":
throw new ("The wallet has no account on Solana mainnet.");
case "connected":
break;
}
const { , } = .;Keep transactionVersion. It says which transactions the wallet can sign, and every payment her
wallet signs takes it.
Open Alice's private account
Opening a session asks Alice's wallet to sign one message. That signature unlocks her private account on this device. No money moves, and it costs nothing.
import { } from "@umbra-privacy/nimbus-solana-web";
const = await (
,
{ : },
{ },
);
if (. === "declined") throw new ("Alice declined to sign.");
const { } = ;gatewayToken is the token from Before you start.
The mainnet gateway token shows how to pass it.
Register her
Each wallet registers once, before its first deposit. session.registration says whether Alice
already has.
import { , } from "@umbra-privacy/nimbus-solana-web";
if (.. === "unregistered") {
const = await (
await (),
{},
{ },
);
switch (.) {
case "complete":
break;
case "submitted":
if (... !== "landed") {
throw new ("The registration did not land. Try again.");
}
break;
case "declined":
case "store-refused":
throw new ("Nothing was sent. Try again.");
}
}Her wallet approves one transaction and pays its network fee. Registration covers every result.
Wait until Nimbus sees it
A registration counts once Nimbus's indexer reaches it, a few seconds after it lands. Until then, a
deposit throws recipient-unregistered. readRegistration asks the indexer, so wait for it:
import { } from "@umbra-privacy/nimbus-solana-web";
async function (: NimbusSession): <void> {
for (let = 0; < 30; += 1) {
const = await (, {});
if (. === "registered") return;
await new (() => (, 2_000));
}
throw new ("Nimbus has not seen the registration yet. Try again shortly.");
}
await ();session.registration does not change after the session opens. Read the indexer instead.
Deposit 25 USDC
USDC has 6 decimals, so 25 USDC is 25_000_000n. Alice deposits into her own private balance.
import { } from "@solana/kit";
import { , } from "@umbra-privacy/nimbus-solana-web";
const = ("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
const = await ();
const = await (
,
{ : , : 25_000_000n, : . },
{ },
);
if (. !== "submitted") {
throw new (`The deposit was not sent: ${.}.`);
}The protocol fee comes out of the 25 USDC. Deposit explains every result that is not
submitted.
Wait until Solana decides
A submitted deposit says where its transaction ended up. Sometimes the SDK stops waiting before
Solana decides, and the outcome is unknown. The deposit may still land. Never deposit again
until you know, or Alice could deposit twice.
resolvePendingDeposit waits for the answer:
import { } from "@umbra-privacy/nimbus-solana-web";
let : string = ...;
if ( === "unknown") {
const = await (, {
: .,
});
= .;
}
if ( === "unknown" || === "store-refused") {
throw new ("The deposit is still pending. Do not deposit again yet.");
}
if ( !== "landed") {
throw new (`The deposit did not land: ${}.`);
}store-refused means this browser could not check, so the deposit is still pending too. Either
way, keep result.submission and call resolvePendingDeposit again later.
Survive a page reload shows how to store it.
Read her balance
The private balance lives on Alice's device. Sync it with Solana first, then read it.
import type { Balance } from "@umbra-privacy/nimbus-solana-web";
import { , } from "@umbra-privacy/nimbus-solana-web";
await (, {});
const = await (, {});
const = (: Balance["state"]): bigint =>
.(() => . === && . === )?. ??
0n;
.("Available:", ("available"));
.("Reserved:", ("reserved"));Each row is one token in one state. A new deposit reads as pending until Solana finalizes it,
then as available. reserved is what a payment still in progress holds.
Balances explains every state.
Wait until it is available
Only an available balance can pay. The deposit landed at confirmed, so it reads as pending
for a few more seconds. Sync and read until enough is available:
import { , } from "@umbra-privacy/nimbus-solana-web";
async function (
: NimbusSession,
: ,
: bigint,
): <void> {
for (let = 0; < 30; += 1) {
await (, {});
const = await (, {});
const = .(
() => . === && . === "available",
);
if ((?. ?? 0n) >= ) return;
await new (() => (, 2_000));
}
throw new ("The deposit is not available yet. Try again shortly.");
}
await (, , 10_000_000n);Passing commitment: "finalized" to deposit makes the wait shorter, but keep it. The device
still needs a sync before the balance reads as available.
Pay Bob 10 USDC privately
A Private Send moves value from Alice's private balance to Bob's. bob is Bob's wallet address,
which he gives Alice. Bob must have registered too. If he has not, transfer throws a
NimbusSdkError with reason recipient-unregistered.
import { , } from "@umbra-privacy/nimbus-solana-web";
import type { } from "@umbra-privacy/nimbus-solana-web";
const = await (await (), {
: ,
: 10_000_000n,
: ,
});
function (: ): string {
switch (.) {
case "relayed":
switch (..) {
case "landed":
return "Bob has been paid.";
case "failed":
return "The payment failed on Solana. Nothing moved.";
case "pending":
case "not-followed":
return "The payment is on its way.";
}
case "refused":
return `Nothing was sent: ${..}.`;
case "relay-refused":
case "unsent":
case "merge-failed":
return "The payment was not sent. Try again.";
}
}
.(());A relayer submits the payment, so Alice's wallet signs nothing and pays no network fee. The call then follows it until it settles on Solana. Private Send covers each result.
Bob withdraws
On his own device, Bob has opened his session and registered, as Alice did. bobSession is that
session. He withdraws 5 USDC to his wallet. If Alice's payment has only just landed, run
waitUntilAvailable(bobSession, usdc, 5_000_000n) first.
import { , } from "@umbra-privacy/nimbus-solana-web";
const = await (await (), {
: ,
: 5_000_000n,
: .,
});
if (. === "relayed" && .. === "landed") {
.("Bob withdrew 5 USDC.");
}The withdrawal syncs Bob's device first, so it sees Alice's payment. A relayer submits it too. Withdraw covers every result.
The objects you hold
You load one SDK per page. You open one session per user, when they connect. You create one context per payment, from the session. Every function takes one of these first. SDK, sessions and contexts explains the pattern.
The whole file
This is Alice's side, in one file. Replace the two values at the top before you run it. Bob's withdrawal runs in his own app, with his own session, as Bob withdraws shows.
import { } from "@solana/kit";
import type { } from "@solana/kit";
import {
,
,
,
,
,
,
,
,
,
,
,
,
,
,
} from "@umbra-privacy/nimbus-solana-web";
import type { NimbusSession } from "@umbra-privacy/nimbus-solana-web";
// Replace these two: your gateway token, and the wallet address Bob registered.
const = "YOUR_GATEWAY_TOKEN";
const = ("BOB_WALLET_ADDRESS");
const = ("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v");
async function (: NimbusSession): <void> {
for (let = 0; < 30; += 1) {
const = await (, {});
if (. === "registered") return;
await new (() => (, 2_000));
}
throw new ("Nimbus has not seen the registration yet. Try again shortly.");
}
async function (
: NimbusSession,
: ,
: bigint,
): <void> {
for (let = 0; < 30; += 1) {
await (, {});
const = await (, {});
const = .(
() => . === && . === "available",
);
if ((?. ?? 0n) >= ) return;
await new (() => (, 2_000));
}
throw new ("The deposit is not available yet. Try again shortly.");
}
// Load the SDK.
const = await (
{ : "mainnet" },
{ : "/nimbus/" },
);
// Connect a wallet.
const [] = ();
if ( === ) throw new ("Install a Solana wallet first.");
const = await (, { });
if (. !== "connected") throw new ("Not connected.");
const { , } = .;
// Open Alice's private account.
const = await (
,
{ : },
{ },
);
if (. === "declined") throw new ("Alice declined to sign.");
const { } = ;
// Register her, once, and wait until Nimbus sees it.
if (.. === "unregistered") {
const = await (
await (),
{},
{ },
);
const =
. === "complete" ||
(. === "submitted" &&
... === "landed");
if (!) throw new ("The registration did not land.");
}
await ();
// Deposit 25 USDC, and wait until Solana decides.
const = await ();
const = await (
,
{ : , : 25_000_000n, : . },
{ },
);
if (. !== "submitted") {
throw new (`The deposit was not sent: ${.}.`);
}
let : string = ...;
if ( === "unknown") {
const = await (, {
: .,
});
= .;
}
if ( === "unknown" || === "store-refused") {
throw new ("The deposit is still pending. Do not deposit again yet.");
}
if ( !== "landed") {
throw new (`The deposit did not land: ${}.`);
}
// Wait until enough of her balance is available, then pay Bob 10 USDC privately.
await (, , 10_000_000n);
const = await (await (), {
: ,
: 10_000_000n,
: ,
});
if (. === "relayed" && .. === "landed") {
.("Bob has been paid.");
}Next
Last updated