Skip to content

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/kit

You 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:

package.json
{
  "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/nimbus

Check 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.

next.config.ts
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.

Information

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.

Next

Last updated

On this page