@remi-fzc/confidential
v0.1.5
Published
Confidential USDC on Stellar — client library with in-browser zero-knowledge proving
Maintainers
Readme
@remi-fzc/confidential
Confidential USDC on Stellar — a TypeScript client library that generates zero-knowledge proofs in the browser and settles against Soroban contracts.
Early release. The surface below is the real one — address-based, signer-pluggable, with the cryptographic internals deliberately unexported. Expect breaking changes before
1.0.How far it has been exercised, precisely: the read path (registration and freeze status, the event feed, reconstructed balances) runs against Stellar testnet in a live dashboard. The write path was last exercised end to end on 2026-08-17 against
0.1.3, on testnet, by two headless harnesses: one drove operator-free onboarding, a self-paidregister, then a fee-bumpeddeposit,mergeand confidentialtransfer, asserting the sender's XLM was unchanged to the stroop; the other confirmed the same transfer reverts withError(Contract, #3601)while the sender is frozen and succeeds again after unfreeze.withdrawis built and proven by the circuits but is not covered by either harness. Nothing here has touched mainnet.
Confidentiality, not anonymity. Addresses and the fact of a transfer are public on-chain. The amount is not.
Install
npm install @remi-fzc/confidential@stellar/stellar-sdk (^14.6.1) is a required peer. npm installs it for
you; pnpm with strict peers, and Yarn, do not, so add it explicitly there:
npm install @remi-fzc/confidential @stellar/stellar-sdkReading is dependency-light. The root entry point imports neither prover dependency, so a plain install pulls no WebAssembly at all and the nine root exports work immediately.
Proving needs two more packages, at these exact versions:
npm install @aztec/[email protected] @noir-lang/[email protected]They are exact rather than cautious: proof compatibility is tied to the
verification keys registered in the deployed verifier, so a proof built against a
different backend version does not verify on-chain. They are declared optional
peers so a read-only consumer does not download bb.js (11 MB on disk) — but
register, transfer and withdraw do not work without them. Skip them and
import "@remi-fzc/confidential/proving-bb" fails at import time, not at the
first proof:
ERR_MODULE_NOT_FOUND: Cannot find package '@noir-lang/noir_js' imported from
.../node_modules/@remi-fzc/confidential/dist/core/proving/prover.jsThat message points inside this package, so it reads like a broken package. It is not: it is the optional peer, absent by design.
Requirements
Node 22+. dist/circuits/index.js loads the compiled circuits with JSON import
attributes (import ... with { type: "json" }), which older Node, webpack and
Jest do not accept. Node 22 still prints an ExperimentalWarning for it.
Browser proving needs a little more, below.
Content-Security-Policy (required for browser proving)
If you serve a CSP, it needs all of these or bbProver() fails:
script-src 'wasm-unsafe-eval'— bb.js instantiates WebAssembly.worker-src 'self' blob:— a violation throws from theWorkerconstructor.connect-src https://crs.aztec.network— bb.js fetches its structured reference string at proving time.connect-src data:— bb.js loads its WASM from an inlinedata:URI.connect-srcentries for your Stellar RPC and Horizon endpoints.
These fail loudly, with a rejected promise, so they are easy to diagnose.
The failure that is NOT loud
The one thing that hangs instead of erroring is a bundler that rewrites bb.js's
worker URL. bb.js does new Worker(new URL("./main.worker.js", import.meta.url))
and then awaits a "message" event with no error listener and no timeout — so
if that URL 404s, the promise never settles and proving appears to stall forever.
Vite serves bb.js's browser build as native ESM from node_modules, so the
worker resolves and this does not arise. Webpack and Next mangle the URL; pass
bbProver({ backendLoader }) to load bb.js yourself from a path where its
sibling worker and WASM files stay intact.
Cross-origin isolation is optional
You may have read that bb.js requires COOP/COEP and SharedArrayBuffer. It does
not, and this package does not benefit from it today: UltraHonkBackend defaults
to { threads: 1 } and bbProver() passes no thread count, so bb.js loads its
single-threaded WASM either way. Without cross-origin isolation it simply reports
one available thread and proceeds — no throw, no hang.
Setting the headers is harmless and would matter if this package later asks for more threads:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentiallessIf you do set them, use credentialless rather than require-corp —
require-corp blocks cross-origin responses that lack CORP headers, which
includes Stellar RPC and Horizon.
The deployment you point it at
The library ships no addresses: they are configuration, so redeploying never needs a release. There is exactly one deployment today, on Stellar testnet:
const TESTNET = {
rpcUrl: "https://soroban-testnet.stellar.org",
token: "CBU5YXEDZQE6ARDGEK4HQE6JVCV3O35B2NC6WZPZ47N4CMMOQESPOKEP",
auditor: "CBZX3AZTETO64HNBQV2J5AINKLVK6NUZIOHVU6H6L3KU242LJXZOF7AJ",
auditorId: 0,
// The wrapped asset: classic USDC, 7 decimals.
usdc: { code: "USDC", issuer: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" },
};Only token and auditor are passed to createClient. The proof verifier is
never passed in: the token calls it cross-contract on every proof.
auditorId is the id your accounts register under, and it must exist in that
registry. The library reads get_key(auditor_id) and does not translate a
failure there, so a missing id surfaces as a raw simulation error rather than a
typed ConfidentialError. To deploy your own wrapper, use
github.com/Remi-FZC-LLC/stellar-confidential-token
and its deployments/ output.
Unaudited, testnet only. Do not point anything holding real value at it.
Before the first call: what each side needs
Both sides of a confidential transfer run this library. There is no way to send to an address that has not onboarded, and no way to merge or withdraw on someone else's behalf — those operations are signed by the account itself.
| Needed by | What | Who provides it |
| --- | --- | --- |
| every write | the Stellar account exists and holds XLM, or you pass feeBump | you |
| register | a prover (/proving-bb plus both peers) | you, once per account |
| deposit | a USDC trustline and a classic USDC balance on that account | you, outside this library |
| withdraw | a USDC trustline on the destination (which is the same account) | you, outside this library |
| transfer | the recipient is already registered under an auditor id | the recipient |
| spending received value | the recipient calls merge() with its own signer | the recipient |
None of this is added implicitly: the library will not create an account, add a
trustline or fund anything. A missing trustline surfaces as a contract-level
rejected, not as a readable precondition error, so check for it yourself
(Horizon /accounts/{id} balances) before calling deposit or withdraw.
deposit(amount) and withdraw(amount) are self-only: both submit with
from = to = the account. You cannot deposit on a user's behalf, or pay a
withdrawal out to a third address.
Usage
import { createClient, keypairSigner, memoryStore } from "@remi-fzc/confidential";
import { bbProver } from "@remi-fzc/confidential/proving-bb";
import { deriveConfidentialKeys } from "@remi-fzc/confidential";
import { Keypair, Networks } from "@stellar/stellar-sdk";
const client = createClient({
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: Networks.TESTNET,
contracts: { token: TOKEN_ID, auditor: AUDITOR_ID },
auditorId: 0,
store: memoryStore(),
prover: bbProver(), // omit for a read-only client — see below
});
const kp = Keypair.fromSecret(SECRET);
const account = client.account({
signer: keypairSigner(SECRET, Networks.TESTNET),
keys: await deriveConfidentialKeys(kp, TOKEN_ID, Networks.TESTNET),
// Capture BEFORE the account's first write, and persist it beside the seed.
fromLedger: await client.latestLedger(),
});
await account.register();
await account.deposit(25_000_000n); // 2.5 USDC — public at this boundary
await account.merge(); // received → spendable (on-chain)
await account.sync(); // ← replay events into local state
console.log(await account.balance()); // { spendable: 25000000n, receiving: 0n }
await account.transfer(BOB, 10_000_000n); // amount hidden on-chainReads are local; syncing is explicit. balance() never touches the network —
it returns the last synced state, and zeros for an address that has never synced.
sync() is what replays chain events into that state, and it is the expensive
call. transfer and withdraw sync internally before building a witness;
register, deposit and merge do not, so a UI that renders straight after
a deposit must sync() first or it will show a stale, usually zero, balance.
Omitting prover gives a read-only client. Balances, events, deposit,
merge and setFrozen all work without one, and the root entry point imports
neither prover package, so a read-only consumer never installs them and no bundler
pulls them. register, transfer and withdraw then throw
prover-not-configured, naming the import to add. That is why the prover peers are
declared optional. Note that a read-only client can only operate an account that
some prover-enabled client already registered with the same keys.
Amounts
Always bigint base units. This deployment's USDC has 7 decimals, so
1 USDC === 10_000_000n. Never write the zeros by hand.
The operations
| Operation | ZK proof? | Effect |
| --- | --- | --- |
| register | yes | one-time account setup inside the wrapper |
| deposit | no | public USDC → confidential |
| merge | no | received balance → spendable |
| transfer | yes | confidential → confidential, amount hidden |
| withdraw | yes | confidential → public USDC |
deposit and merge need no proof — the contract verifies them arithmetically.
That is why a deposit is fast and a transfer is not.
Writes are slow, and there are three outcomes, not two
Every write is build, simulate, sign, submit, then poll Soroban RPC for confirmation: up to 60 polls at 2 s, so a write can occupy the call for two minutes on top of proving time, and none of that is configurable. Do not put these calls in a request handler or any function with a short execution limit; run them on a worker you control and hand the caller the hash.
- resolved — an
OpResult { hash, maxFee }. ConfirmedSUCCESSon-chain. ConfidentialError— it did not happen.codeis one ofnot-registered,insufficient-balance(carriesbalance),key-mismatch,prover-not-configured,rejected(carriescontractCode; this deployment uses3601for a frozen account).state-divergedis also declared, for a divergence check that is not wired up — nothing throws it, so do not build a handler on it.ConfirmationTimeout— the network accepted the submission and the poll window closed. This is not a failure. The transaction may still settle, so re-running the call can spend the same value twice, and treating it as failure can lose a settled payment. Look uperr.hash, thensync().
That last case is handled deliberately: on a timeout the cached spendable opening
is not advanced, because the throw happens before setSpendable. sync()
will reconcile it against the chain.
import { ConfidentialError, ConfirmationTimeout } from "@remi-fzc/confidential";
try {
const { hash } = await account.transfer(BOB, 10_000_000n);
} catch (err) {
if (err instanceof ConfirmationTimeout) await resolveByHash(err.hash); // never retry
else if (err instanceof ConfidentialError && err.code === "insufficient-balance")
showShortfall(err.balance);
else throw err;
}State is fund-safety-critical
The store is not a cache. Soroban RPC serves a limited window of events, and an
account's receiving balance is a running sum of them. Once a crediting event
ages out of that window, the persisted blob is the only thing that can still open
that value. Call account.sync() at least once per retention window, and treat
the store as durable, encrypted-at-rest storage.
If you miss the window there is no error. The event source clamps a cold start up
to the RPC's retention floor, so the first sync after the window closes
succeeds, silently omits the aged-out credits and persists a cursor above them.
That value becomes unspendable and nothing reports it. The engine has a
divergence check, but it is not reachable from this surface, so compare
account.balance() against your own record of settled operations and alarm on a
gap.
memoryStore() keeps nothing across a reload or a process — tests and single-run
scripts only. In a browser use localStorageStore(prefix); the prefix is
required and has no default, because your reset flow is what has to enumerate it,
and it throws rather than silently no-opping when storage is unavailable. In Node
there is no shipped persistent store: implement StateStore yourself before
moving any real value. It is three methods:
interface StateStore {
load(key: string): Promise<string | null>;
save(key: string, blob: string): Promise<void>;
remove(key: string): Promise<void>;
}The blob holds the balance openings (v, r): a full opening of the on-chain
commitment, which is the confidentiality property. Treat it as a spending
secret.
Three traps worth knowing before you ship
isRegistered cannot distinguish "not registered" from "cannot ask." It is a
simulation whose errors are swallowed, so a wrong rpcUrl, a wrong token
contract, a rate-limited RPC or an outage all return false. Its neighbour
isFrozen throws in those cases. Verify your endpoint independently before
trusting a false — and read a not-registered error from transfer
("G… must register before it can receive") as possibly an RPC failure rather
than a statement about the recipient.
key-mismatch only proves the keys were derived for this token contract. The
check compares the derived addrF against the token's; it does not compare the
keys against the account's on-chain registration. Keys from the wrong seed, or
derived under a different message, are accepted and then decrypt every event into
a plausible-looking but meaningless balance. If you support key recovery or more
than one derivation path, compare your derived Y and PVK against the
registered ones yourself before spending.
incoming() is a history, not a queue, and it has a hard age limit. It
returns every transfer received since the account's fromLedger — merged ones
included — re-reading and decrypting the contract's event history on every call.
It passes fromLedger to RPC unclamped, unlike sync(), so once that ledger
falls out of the retention window the call fails with a raw RPC error, and keeps
failing. Use balance().receiving, which comes from clamped cursor-based
sync(), as the authoritative "waiting to be merged" figure, and keep your own
record of what you have credited.
The surface
Client — createClient(options), then account(options), isRegistered,
isFrozen, setFrozen (token owner only), events({ fromLedger }),
latestLedger, close.
Account — register, deposit, merge, transfer, withdraw, balance
(local), sync (network), incoming, forget, address, spendingKeyHex.
Keys — deriveConfidentialKeys, keyDerivationMessage.
Stores — memoryStore, localStorageStore(prefix).
Errors — ConfidentialError, ConfirmationTimeout.
Auditor — auditTransfer(k, transferEvent), where k is the auditor's
Grumpkin secret scalar as a bigint. It opens the amount and the sender's
post-transfer balance, for transfers only: withdraw-channel decryption and
auditor-key derivation exist internally but are not exported, so a compliance
integrator can audit transfers and not withdrawals. channelsAgree === false
means k is not the auditor key for both parties, which happens when sender and
recipient registered under different auditor ids.
Progress, and paying someone else's fees
Every operation takes { onProgress } and emits four stages: syncing,
proving, proved, submitting. proved carries the measured ms and
proofBytes; submitting carries sponsored. Proving takes seconds and is
device-dependent, so drive a real indicator from these events rather than guessing
a duration, and keep the tab alive.
await account.transfer(BOB, 10_000_000n, {
onProgress: (e) => { if (e.stage === "proving") setBanner("Generating proof…"); },
});createClient({ feeBump }) is consulted immediately before each write: return a
FeeBump (a fee-source address plus signFeeBump) and that account pays the
whole fee, so your users can hold zero XLM. Return undefined to have the account
pay. Because it is called per operation, a runtime sponsorship toggle needs no
client rebuild.
One client and one prover per process, reused
bbProver() retains a prover per circuit, each with its own WASM and CRS, so do
not construct a client per request: a Node service that does will pay backend
init every time and eventually exhaust memory. client.close() releases them.
Proving has one outbound dependency
On the first proof, bb.js downloads the structured reference string (g1.dat,
g2.dat, grumpkin_g1.dat) from https://crs.aztec.network and caches it,
in IndexedDB in a browser. Nothing about the transaction is sent — these are
byte-range requests for public curve data — but if that host is unreachable, no
proof can be generated. Allow it in your CSP and your egress rules, and mirror
the CRS if you need independence from it.
Security model
Every secret stays on the client: key derivation, note decryption, balance reconstruction, witness construction, proof generation, and signing all run locally. Nothing server-side can reconstruct a key.
There is no hosted proving service, and one could not preserve
confidentiality. The spending key sk is a private input to all three
circuits, and generating a proof requires the witness. Any remote prover would
therefore receive full, permanent spend authority over the account it proves for
— including the viewing key, which derives from the same scalar. That is a
property of the circuits, and no library interface can change it. Delegated proving that preserved
confidentiality would need different circuits, MPC, or an attested TEE.
Key classes
| Key | Opens | Blast radius |
| --- | --- | --- |
| Spending key (sk) | authorises transfers; derives the viewing key | one account, permanently |
| Viewing key (vk) | that account's own notes and balance | one account |
| Auditor view key | every transfer in the wrapper | whole wrapper |
Because vk derives from sk, there is no spend-only disclosure.
Keys today: the app holds the seed
Transaction signing is pluggable: keypairSigner(secret, passphrase), or any
object with { publicKey, sign(xdr) }, including a browser wallet's
signTransaction. Key derivation is not.
deriveConfidentialKeys(keypair, token, passphrase) needs the raw Stellar seed,
because it derives sk from a deterministic ed25519 signature over a fixed
message. So the only supported model today is app-managed: your application
holds the seed.
If your product is non-custodial — wallet- or passkey-signed — this release cannot
derive its keys for you. keyDerivationMessage() is exported, but the
signature-to-key reduction and the Grumpkin point type it produces are internal,
so you cannot complete that path yourself. Wallet-derived keys are not on this
surface yet; do not design around them.
The message string is fixed by this package and is part of the derivation. Keys derived under any other string will not match a registration made with this one, so it cannot be changed per consumer.
Compliance states you must handle
The deployed wrapper is a compliant token. Its owner can freeze an account, and a
frozen sender's transfer or withdraw reverts as
ConfidentialError("rejected") with contractCode 3601. Surface that as a
policy outcome, never as a retry. Call client.isFrozen(address) before a large
operation if you want to fail early — noting it throws on a failed read.
client.setFrozen({ account, frozen, owner }) is on the surface for whoever holds
the token owner's key. If you are an integrator rather than the issuer, this
is not yours to call and the chain will reject it.
Roadmap
Shipped: the address-based public surface, the cryptographic core withdrawn from it (internal, not deleted), and a test suite.
Still open:
- [ ] Mainnet exercise — testnet only so far
- [ ] A Node file-backed
StateStore; today Node consumers getmemoryStore()or their own implementation - [ ] Recipient-side helpers beyond
incoming()
Three subpaths, and only three: ., ./proving-bb, ./circuits. Earlier
drafts planned /presets, /auditor, /compliance and /stellar-classic;
each was cut for having no real caller. Deployment addresses in particular stay
out, so redeploying a contract never requires an SDK release.
License
MIT, © 2026 Remi FZC LLC. See LICENSE.
This package redistributes third-party MIT code — the compiled circuits from OpenZeppelin/stellar-contracts and the cryptographic client from brozorec/stellar-confidential-token-demo. Required notices are in THIRD-PARTY-NOTICES.md; what was taken and what was changed is recorded in ATTRIBUTION.md.
This library's own source repository is private. What ships on npm is compiled
JavaScript with type declarations, plus the compiled circuit artifacts. The
contracts and the Noir circuits are public at
github.com/Remi-FZC-LLC/stellar-confidential-token,
and the artifacts in this package are byte-identical to the ones there — two
shasum -a 256 runs close that link without trusting us.
Not affiliated with, endorsed by, or supported by OpenZeppelin, Aztec, Circle, or the Noir project.
