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 admin

2. 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:

pnpm-workspace.yaml
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.
  • fromLedger is 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 → spendable

5. 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 CircuitProver once 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.js exactly 0.87.0 and circuits compiled by nargo 1.0.0-beta.11. The SDK pins all three. A proof built any other way does not verify on-chain. The recipe is in circuits/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.