Since 3.1.0 the package has a browser build. Bundlers that honour the browser export condition
(Vite, webpack 5, esbuild with platform: "browser", and the frameworks built on them) pick it up
by themselves, with no alias. This guide covers what is different in a browser: loading the
cryptography, getting keys from a wallet, and what the user sees.
A complete app to copy from is in
examples/vite.
npm install @softseco/confidential-transfers @solana/kit @solana/wallet-account-signer
Proofs and encryption run in @solana/zk-sdk, which is WebAssembly (about 2.7 MB, and it does not
compress much). The SDK loads it on the first call that needs it. To start the download as soon
as the page opens, call this once at startup:
import { initConfidentialTransfers } from "@softseco/confidential-transfers";
void initConfidentialTransfers();
The browser fetches index_bg.wasm from next to the zk-sdk module. Vite and webpack 5 see that
reference and copy the file into your build:
webpack 5: nothing to configure.
Vite: nothing to configure for vite build. For the dev server, add this to vite.config:
export default defineConfig({
optimizeDeps: { exclude: ["@solana/zk-sdk"] },
});
Without it the dev server pre-bundles zk-sdk into another folder and the .wasm request fails.
In a monorepo, or with the SDK linked from a local folder, the dev server may also refuse the
file (HTTP 403) because it is outside your project: add that node_modules folder to
server.fs.allow.
Anything else (esbuild, for example, does not copy it): serve
node_modules/@solana/zk-sdk/dist/web/index_bg.wasm yourself and say where it is:
await initConfidentialTransfers({ wasmUrl: "/zk/index_bg.wasm" });
If the file does not load, the SDK throws an error that says so and names these fixes.
Tested in Chromium: Vite 8 (production build and dev server, in CI on every push) and webpack 5 (production build). The keys a browser derives are byte-for-byte the ones Node derives.
The confidential keys (ElGamal and AES) come from the wallet's signature over one fixed message,
solana-conf-bal/v1. A wallet signs it through Wallet Standard's solana:signMessage. Ask for
that signature once, keep the keys in memory, and pass them to every call as keys:
import { createSignerFromWalletAccount } from "@solana/wallet-account-signer";
import { deriveConfidentialKeys, decryptBalance, transfer } from "@softseco/confidential-transfers";
import { isMessageSigner } from "@solana/kit";
const signer = createSignerFromWalletAccount(uiWalletAccount, "solana:devnet");
if (!isMessageSigner(signer)) throw new Error("This wallet cannot sign messages");
// One wallet prompt: "sign this message".
const keys = await deriveConfidentialKeys({ signer });
// No more message prompts: the keys are passed in.
const balance = await decryptBalance({ rpc, owner: signer, mint, keys });
await transfer({ rpc, rpcSubscriptions, payer: signer, owner: signer, mint, destinationOwner, amount, keys });
Without keys, every call asks the wallet to sign the message again. That works, but the user
gets one extra prompt per call.
With React, @solana/react gives you the two signers as hooks:
const messageSigner = useWalletAccountMessageSigner(account);
const signer = useWalletAccountTransactionSigner(account, "solana:devnet");
const keys = await deriveConfidentialKeys({ signer: messageSigner });
await configureAccount({ rpc, rpcSubscriptions, payer: signer, owner: signer, mint, keys });
What to know about the keys:
localStorage; after a reload, ask for the signature again.solana:signMessage, signing the message as given. The SDK verifies the signature and refuses
a wallet that changes the message, because the keys would then differ from the standard ones.solana:signTransaction. A wallet that only offers signAndSendTransaction cannot be used yet,
because the SDK simulates each transaction before sending it.Phantom, Solflare and Backpack support both. When a wallet cannot sign messages, the SDK's error
says so; you can check up front with isMessageSigner(signer) or "solana:signMessage" in wallet.features.
Since 3.2.0 the wallet is asked once per operation. A transfer is five transactions, but the SDK plans and signs them together, so the wallet shows one request for all five:
| Step | Wallet prompts | Transactions |
|---|---|---|
Unlock (deriveConfidentialKeys) |
1 (sign a message) | none |
configureAccount |
1 | 1 |
deposit |
1 | 1 |
applyPendingBalance |
1 | 1 |
transfer |
1 | 5 (proof accounts, then the transfer together with closing them) |
withdraw |
1 | 4 |
The transactions are then sent one after another, each simulated first. If one fails before the
transfer itself lands, the SDK closes the proof accounts it created (their rent goes back to the
payer) and throws a ConfidentialTransfersError. Closing them is one more transaction, so the
wallet asks once more in that case.
Everything the SDK throws on purpose is a ConfidentialTransfersError with a code, so the app
can say something useful instead of showing a stack trace:
import { isConfidentialTransfersError } from "@softseco/confidential-transfers";
try {
await transfer({ rpc, rpcSubscriptions, payer: signer, owner: signer, mint, destinationOwner, amount, keys });
} catch (e) {
if (isConfidentialTransfersError(e, "PENDING_BALANCE_LIMIT")) showMessage("The recipient has to accept their incoming payments first.");
else if (isConfidentialTransfersError(e, "INSUFFICIENT_FUNDS")) showMessage("Not enough confidential balance.");
else if (isConfidentialTransfersError(e) && e.retryable) showMessage("The network was busy. Try again.");
else throw e;
}
The checks that can be made before anything is signed (the recipient's account exists, is configured and can take another payment; the amount fits; the balance covers it) are made first, so a doomed operation fails before the wallet opens.
On a busy network, pass computeUnitPrice (a priority fee in micro-lamports per compute unit),
and retries to run the operation again when a transaction expires. A retry signs new
transactions, so the wallet asks again.
test/browser/e2e.ts runs examples/vite in Chromium with a Wallet Standard
test wallet against a local validator: connect, unlock, set up, deposit, apply, transfer,
withdraw, then checks the balances, that the wallet signed the key message exactly once, and that
it was asked to approve transactions once per operation. CI runs it on every push. To run it yourself, see
CONTRIBUTING.md.