Build with the SDK (testnet)
This walkthrough deploys the v0.8.0 confidential token suite on testnet and runs the full holder flow from Node: register, deposit, merge, transfer, withdraw, a payroll to several recipients, and auditing. It uses the Confidential Token SDK, a TypeScript implementation of the SDK specification.
The on-chain contracts and circuits are audited and released in stellar-contracts v0.8.0. The SDK is not audited,
is in active development, is not on npm, and is not production-ready. Use it on testnet.
Every holder operation goes through a client: deposit needs a registered account, and register needs a proof. The
Stellar CLI covers deployment and administration only. The SDK covers the holder flow, balance recovery, auditor
decryption, the spender flow (set_spender, confidential_transfer_from) and the D-recipient and D-sender
selective disclosures. It doesn't yet cover
clawback proving, or the D-auditor, D-balance and aggregate disclosures.
1. Deploy the contracts
Follow the testnet deployment guide,
steps 1–8, from the repository root at tag v0.8.0, with stellar-cli 28.1.0 or later. It deploys the verifier (with
all six verification keys), the auditor registry, the factory, then the allowlist and the compliant token, and
onboards each holder. The verifier and the auditor registry are shared across tokens, so deploy them once per
network and point each new token at them.
Use your own auditor key rather than the guide's example key. Auditor public keys are K_aud = k·H
(Compliance). Generate a key pair with the SDK and register the
public half under auditor_id 0:
import { randomScalar, auditorPublicKey, pointToBytes } from "stellar-confidential-token-sdk";
const k = randomScalar(); // the auditor's secret: keep it with the auditor, not in your app
console.log(Buffer.from(pointToBytes(auditorPublicKey(k))).toString("hex"));stellar contract invoke --id auditor --source admin -- register_key \
--auditor_id 0 --point <point hex> --operator admin2. Install the SDK
The SDK compiles itself on install. pnpm 10 only runs build scripts of git dependencies that are allowlisted by their exact tarball URL, so pin a commit:
onlyBuiltDependencies:
- "stellar-confidential-token-sdk@https://codeload.github.com/brozorec/stellar-confidential-token-sdk/tar.gz/aad17069714713bcb36fa376d0f2a87743e8e2a7"pnpm add "github:brozorec/stellar-confidential-token-sdk#aad17069714713bcb36fa376d0f2a87743e8e2a7" "@stellar/stellar-sdk@^14.2.0"Pin @stellar/stellar-sdk to the SDK's range (^14) so that only one copy is installed.
3. Connect, keys and state
import { Keypair } from "@stellar/stellar-sdk";
import {
ChainClient, keypairSigner, CircuitProver, StateEngine, deriveSk, deriveKeys,
buildRegisterWitness, buildTransferWitness, buildWithdrawWitness,
submitRegister, submitDeposit, submitMerge, submitTransfer, submitWithdraw,
} from "stellar-confidential-token-sdk";
import { loadCircuit } from "stellar-confidential-token-sdk/proving/artifacts";
import { JsonFileStore } from "stellar-confidential-token-sdk/state/json-store";
const PASSPHRASE = "Test SDF Network ; September 2015";
const client = new ChainClient({
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: PASSPHRASE,
contracts: { token: TOKEN, verifier: VERIFIER, auditor: AUDITOR },
});
const fromLedger = await client.latestLedger(); // record once, before the first register
function holder(name, secret, root /* 32 bytes you persist */) {
const kp = Keypair.fromSecret(secret);
const d = deriveSk(root, TOKEN, kp.publicKey()); // bound to this token and this account
const keys = deriveKeys(d.sk, d.addrF);
const engine = new StateEngine({
client, store: new JsonFileStore(`state/${name}.json`), keys, address: kp.publicKey(), fromLedger,
});
return { name, address: kp.publicKey(), signer: keypairSigner(kp.secret(), PASSPHRASE), keys, engine };
}
const provers = {
register: new CircuitProver(loadCircuit("register")),
transfer: new CircuitProver(loadCircuit("transfer")),
withdraw: new CircuitProver(loadCircuit("withdraw")),
};- Losing the root loses the funds. Treat it like a private key.
fromLedgeris where a holder's first sync starts. It must be at or before their first event. Later syncs resume from the cursor saved in the store.- Balance openings live only in events and your store. RPC keeps about 7 days of events, so sync at least once per window or configure an indexer (Indexing and Recovery).
4. Register, deposit, merge
const rw = buildRegisterWitness(alice.keys, alice.address);
const { proof } = await provers.register.prove(rw.inputs);
await submitRegister(client, alice.signer, alice.address, 0, rw, proof); // binds alice to auditor_id 0
await submitDeposit(client, alice.signer, alice.address, alice.address, 50n * 10_000_000n); // public → receiving
await submitMerge(client, alice.signer, alice.address); // receiving → spendable5. Transfer
Take the sender's opening from the StateEngine, and the recipient's keys and auditor from the chain:
async function transfer(from, toAddress, amount) {
await from.engine.sync();
const { v, r } = await from.engine.spendableForProof();
const [sender, recipient] = await Promise.all([
client.confidentialBalance(from.address),
client.confidentialBalance(toAddress),
]);
const tw = buildTransferWitness({
keys: from.keys, v, r, amount,
pvkB: recipient.viewingPublicKey,
kAudR: await client.auditorKey(recipient.auditorId),
kAudS: await client.auditorKey(sender.auditorId),
});
const { proof } = await provers.transfer.prove(tw.inputs);
await submitTransfer(client, from.signer, from.address, toAddress, tw, proof);
}
await transfer(alice, bob.address, 12_5000000n);For a payroll, call transfer once per recipient. It re-syncs before each proof, so each payment starts from the
balance the previous one left. Recipients see payments in their receiving balance until they merge.
6. Withdraw
await bob.engine.sync();
await submitMerge(client, bob.signer, bob.address);
const s = await bob.engine.sync();
const acct = await client.confidentialBalance(bob.address);
const ww = buildWithdrawWitness({
keys: bob.keys, v: s.spendable.v, r: s.spendable.r, amount: 5n * 10_000_000n,
kAudS: await client.auditorKey(acct.auditorId),
});
const { proof } = await provers.withdraw.prove(ww.inputs);
await submitWithdraw(client, bob.signer, bob.address, bob.address, 5n * 10_000_000n, ww, proof);7. Audit
The auditor decrypts amounts from events with k, without the holders' cooperation
(Auditing):
import { fetchEvents, auditTransfer } from "stellar-confidential-token-sdk";
const { events } = await fetchEvents(client, { startLedger: fromLedger });
for (const ev of events.filter((e) => e.type === "transfer")) {
const { amount, channelsAgree } = auditTransfer(k, ev);
}Notes
- Proofs take about 1 s (register) to 2 s (transfer) in Node on a laptop. Create each
CircuitProveronce and reuse it. - The SDK's circuits and VKs are pinned to v0.8.0. Register exactly those VKs on your verifier.
- Proofs must use the keccak transcript and the non-ZK UltraHonk flavour, with
@aztec/bb.jsexactly0.87.0and circuits compiled by nargo1.0.0-beta.11. The SDK pins all three. A proof built any other way does not verify on-chain. The recipe is incircuits/vks/README.md. - Back-to-back transactions from one account on testnet can fail with
txBadSeq. Wait for each transaction and retry. - Browser apps: see the SDK README's "Browser usage" (bb.js served unbundled, cross-origin isolation).
- For a UI reference, the demo app shows the full UX. It runs on a pre-v0.8.0 deployment.