ClairveilJS is a JavaScript SDK for using Clairveil privacy features in browser and Node.js environments.
Korean documentation: README.ko.md
It uses CosmJS as the transport/signing foundation and provides Clairveil-specific privacy primitives and DApp-friendly APIs:
- Telescope-generated
MsgDeposit,MsgTransfer,MsgWithdraw, and privacy query protobuf bindings - Clairveil transaction type URLs
- Clairveil registry creation for CosmJS
Registry - root signing message helpers
- browser-friendly crypto primitives through
@noble/hashesand@noble/ciphers - Clairveil root seed, spend/view/disclosure key derivation
- Clairveil shielded address encode/decode
- Clairveil BN254 twisted Edwards point compression
- Clairveil disclosure ECIES decrypt
- Clairveil note creation, commitment/nullifier calculation, and note encryption
- Clairveil transfer disclosure MiMC digest verification
- signer address/pubkey checks
- privacy events, auditable transfer, and reserve accounting queries
- balance queries
- wallet adapters for Keplr or custom signers
- CosmJS OfflineSigner adapter when a separate privacy-root signer is supplied
- note store adapters for memory/localStorage-backed scans
- planner results for transfer and withdraw UX states
- stable
ClairveilErrorcodes for planner/prover/wallet failures - prepared transfer payload building
- prepared withdraw and relay withdraw prover/final payload building
- HTTP prover adapter for
/v1/prover/transferand/v1/prover/withdraw - async job prover adapter for remote queue/poll prover deployments
- direct sign-doc construction for Keplr
signDirect - signed tx assembly and broadcast
- Clairveil-compatible
IPrivacyEVM precompile calldata/transaction adapter for EIP-1193 wallets - runtime shape assertions and TypeScript declarations
Use the shared privacy core from clairveiljs/core, Cosmos transport from clairveiljs/cosmos or clairveiljs/cosmos-client, EVM transport from clairveiljs/evm, and wallet note reservation helpers from clairveiljs/reservation.
import { deriveShieldedAddress } from "clairveiljs/core";
import { createClairveilClient } from "clairveiljs/cosmos";
import { createClairveilEvmClient } from "clairveiljs/evm";
import { createNoteReservationManager } from "clairveiljs/reservation";npm install github:DELIGHT-LABS/clairveiljsThe root clairveiljs entrypoint remains backward-compatible with the current Cosmos-oriented client surface.
The EVM entrypoint supports Clairveil-compatible EVM chains: the chain must expose the Clairveil IPrivacy precompile ABI. The precompile address may come from chain configuration or use the SDK default 0x100000000000000000000000000000000000000b, but the calldata ABI is expected to match Clairveil's EVM privacy precompile contract.
For EVM chains, ClairveilJS calls the chain-provided privacy precompile. The DApp/SDK must use the privacy precompile ABI and fixed precompile address published by the target chain. The ABI may be generated by the chain's contracts package, for example through a Hardhat compile, wagmi generate, and tsdown build pipeline.
Source and TypeScript declarations live in src/. The root src/index.js is a thin public barrel; implementation code is grouped by architectural layer:
src/
core/ crypto, note, disclosure, schemas, errors
privacy/ scan, planner, payload, note-store, prover adapters
transport/ Cosmos/CosmJS client and EVM precompile client
browser/ browser public client and wallet/DApp client
wallet/ wallet adapter helpers
generated/ Telescope protobuf bindings
Public consumers should import through the package export map (clairveiljs, clairveiljs/core, clairveiljs/cosmos, clairveiljs/cosmos-client, clairveiljs/evm, clairveiljs/browser-dapp, clairveiljs/reservation, clairveiljs/generated/...) instead of reaching into files directly. The export map preserves semantic entrypoints even when an entrypoint shares an internal implementation; for example, clairveiljs/browser-dapp points at the browser wallet client surface.
Minimal SDK usage examples live in examples/. Start with examples/minimal-keplr-flow.js for Keplr/Cosmos and examples/minimal-metamask-flow.js for MetaMask/EVM. Both examples derive wallet privacy material, prepare a deposit, scan notes, prepare a transfer, and broadcast through the SDK surface. The Keplr/Cosmos example requires a depositProofProvider because Cosmos MsgDeposit includes a DepositCircuit proof.
Current boundary:
- This package does not modify the chain module or generated Go code.
- Cosmos message/query bindings are generated from
proto/with Telescope. Runnpm run codegen:protoafter updating Clairveil proto definitions; the script also reapplies the browser-safe pagination helper patch to bothcodegen/generated/helpers.tsandsrc/generated/helpers.js.npm run checkverifies that the patch is still applied. - ZK proof generation is not embedded in JS. Use the prover adapter against a browser, local, or remote prover.
- User disclosure decoding is handled in JS.
- Auditor disclosure decoding is handled in JS with a request-level disclosure private scalar supplied by a trusted admin/backend/local auditor runtime.
- Cosmos transport uses CosmJS/Keplr-style signing. EVM transport uses the Clairveil-compatible
IPrivacyprecompile calldata contract. - Relay withdraw is supported as a two-party handoff. The wallet/DApp prepares the final withdraw payload with one API. Cosmos relayers validate that payload before signing
MsgWithdrawas the relayer creator; EVM relayers rebuild or byte-for-byte validate the returnedIPrivacy.withdrawtransaction before submitting it from their own EVM account. - The SDK files are ESM and avoid direct
node:cryptoimports. Browser bundlers still need to provide normal web APIs such asfetch,TextEncoder,TextDecoder, andcrypto.getRandomValues. - The default prefixes are the Clairveil reference prefixes (
clairaccounts andclairsshielded addresses), but account and shielded prefixes are runtime configuration. UseaccountPrefix/shieldedPrefixwhen targeting a downstream Cosmos chain that embeds the Clairveil privacy module.
For audit disclosure key generation in JS, see the repository example at examples/audit-disclosure-keys. It derives deterministic, random, and privacy-root-signer-based audit disclosure keypairs and prints the genesis-compatible audit_master_pubkey encoding.
ClairveilJS includes handoff tests that replay the Go SDK conformance fixtures from the Clairveil repository. By default the tests look for a sibling Clairveil checkout:
../clairveil/x/privacy/client/sdk/conformance/testdataFor local development, run:
npm run test:conformanceIf the fixture directory is missing, this local command skips with a clear message so the package can still be installed or smoke-tested without a sibling Clairveil checkout.
If your Clairveil checkout lives elsewhere, point the SDK at that fixture directory:
CLAIRVEIL_CONFORMANCE_FIXTURE_DIR=/path/to/clairveil/x/privacy/client/sdk/conformance/testdata npm run test:conformanceFor release handoff or CI, use the strict command. It sets CLAIRVEIL_CONFORMANCE_REQUIRED=1, so missing fixtures fail the job instead of producing an all-skip success:
npm run test:conformance:requiredprepublishOnly runs the strict conformance command.
These tests verify root seed/key/address derivation, browser signer adapter behavior, note scan results, prepared transfer and withdraw payload hashes, prover HTTP contract behavior, disclosure decoding, and relay withdraw message handoff behavior against the Go-generated fixtures.
Downstream packages can reuse the same fixture loading policy:
import { runClairveilConformanceFixtures } from "clairveiljs/conformance";
const result = await runClairveilConformanceFixtures({
fixtureDir: "/path/to/clairveil/x/privacy/client/sdk/conformance/testdata",
fixtureNames: ["privacy_wallet_golden_vectors.json"]
});
if (!result.skipped) {
console.log(result.fixtures["privacy_wallet_golden_vectors.json"].schema_version);
}The handoff e2e item is intentionally not part of prepublishOnly. Running a Clairveil node and prover is the chain repository's responsibility; this package only proves that the published SDK surface can attach to those services and execute the full wallet flow.
Current local e2e scope is deposit, wallet note scan, shielded transfer, disclosure decode, and direct withdraw. Relay withdraw payload/signDoc construction is covered by SDK tests and Go conformance fixtures; full relayer service e2e depends on the product's relayer transport and deployment.
Run the optional smoke/e2e command with a local Clairveil node:
CLAIRVEIL_E2E_LOCAL=1 npm run test:e2e:localBy default this only checks that the local REST/RPC privacy endpoints respond. The command is skipped unless CLAIRVEIL_E2E_LOCAL=1 is set, so normal package tests and publish checks do not require a node.
To run the tx flow, explicitly opt in:
CLAIRVEIL_E2E_LOCAL=1 \
CLAIRVEIL_E2E_FULL_FLOW=1 \
CLAIRVEIL_E2E_WALLET_MODULE=/absolute/path/to/wallet-adapter.mjs \
CLAIRVEIL_E2E_DEPOSIT_PROOF_MODULE=/absolute/path/to/deposit-proof-provider.mjs \
npm run test:e2e:localThe full flow performs:
- deposit
- wallet note scan
- transfer to a shielded recipient
- public user disclosure decode
- direct withdraw
It uses these defaults, all overrideable through environment variables:
CLAIRVEIL_E2E_CHAIN_ID=clairveil-local-1
CLAIRVEIL_E2E_RPC=http://127.0.0.1:26657
CLAIRVEIL_E2E_REST=http://127.0.0.1:1317
CLAIRVEIL_E2E_PROVER_URL=http://127.0.0.1:8080
CLAIRVEIL_E2E_ACCOUNT_PREFIX=clair
CLAIRVEIL_E2E_SHIELDED_PREFIX=clairs
CLAIRVEIL_E2E_DENOM=uclair
CLAIRVEIL_E2E_DEPOSIT_AMOUNT=10uclair
CLAIRVEIL_E2E_TRANSFER_AMOUNT=1uclair
CLAIRVEIL_E2E_WITHDRAW_AMOUNT=<same as CLAIRVEIL_E2E_TRANSFER_AMOUNT>The recommended wallet input is a module that exports a ClairveilJS-compatible wallet adapter or async factory:
// /absolute/path/to/wallet-adapter.mjs
export default async function createWallet(config) {
return {
async getAddress() {
return "clair1...";
},
async getPubKeyHex() {
return "...";
},
async signPrivacyRootBase64(messageBytes, context) {
return "...";
},
async signDirect(signDoc, context) {
return { signed, signature };
}
};
}The deposit proof module must export default, createDepositProof, or depositProofProvider. It receives the browser/SDK-built deposit material and returns proof bytes or proof hex, such as { proof }, { depositProof }, { proofHex }, or { proof_hex }.
// /absolute/path/to/deposit-proof-provider.mjs
export async function createDepositProof({ material }) {
return { proofHex: await proveDepositCircuit(material) };
}For a simple local signer, the e2e test also accepts CLAIRVEIL_E2E_MNEMONIC plus either CLAIRVEIL_E2E_ROOT_SIGNATURE_BASE64 or CLAIRVEIL_E2E_ROOT_SIGNATURE_HEX. The root signature must match the transparent account and pubkey; CosmJS offline signers do not sign Clairveil's privacy-root message by themselves.
ClairveilJS is designed so production wallets and DApps can keep privacy material on the client side. Treat the privacy root signature as secret material: for the same transparent address and pubkey, it can derive the wallet root seed again.
A production user-facing DApp must not send these values to an application server:
- privacy root signature or root seed
- spend, view, or disclosure private material
- decrypted notes or note store contents
- user disclosure private scalar
- prepared payloads or proofs unless the user experience explicitly treats the selected prover or relayer as a trusted party
The server side of a normal DApp should only proxy chain REST/RPC, prover HTTP, and tx broadcast endpoints. Run deriveWalletPrivacyMaterial, note scanning, deposit note creation, transfer preparation, withdraw preparation, and user disclosure decoding in the browser, wallet extension, or another client-controlled runtime.
Audit disclosure private scalars are different from user wallet root material, but they are still secrets. It is valid for ClairveilJS to accept an audit scalar when decoding audit disclosure. The scalar should live either in a trusted admin/backend environment or in a local auditor tool; do not collect it from public user-facing UI.
If a local demo or back-office tool uses a relay for convenience, do not copy that relay boundary into a production wallet without moving privacy material back to the client side.
For a production browser DApp, depend on the SDK directly and use the browser-dapp entrypoint. A public-node DApp does not need a Clairveil application server just to prepare privacy transactions: provide chain RPC/REST endpoints and a prover URL, then keep wallet privacy material in the browser or wallet-controlled runtime.
import { createClairveilBrowserDappClient } from "clairveiljs/browser-dapp";
const clairveil = createClairveilBrowserDappClient({
rpc: "https://rpc.example-chain.invalid",
rest: "https://rest.example-chain.invalid",
chainId: "example-1",
accountPrefix: "example",
shieldedPrefix: "examples",
denom: "uexample",
proverUrl: "https://prover.example.invalid"
});
const deposit = await clairveil.prepareDeposit({
address: walletAddress,
pubKeyHex: walletPubKeyHex,
signatureBase64: privacyRootSignatureBase64,
amount: "1000000uexample",
async depositProofProvider({ material }) {
// Generate a DepositCircuit proof with your local/WASM/trusted deposit prover.
return createDepositProof({ material });
}
});
// This Cosmos-style client returns deposit.signDoc for the wallet signDirect flow.
// For EVM, create the client with profile: { transport: "evm", ... };
// then prepareDeposit returns deposit.transaction for EIP-1193 submission.For a local single-node demo, it is fine to run a helper server for faucet funding, local signer setup, auditor admin tools, or CORS/proxy convenience. Keep that helper out of the production privacy boundary: the DApp should still call ClairveilJS for root material derivation, note scanning, deposit preparation, transfer preparation, withdraw preparation, and user disclosure decoding.
import {
createClairveilClient,
createHttpProverAdapter,
createKeplrWalletAdapter,
LocalStorageNoteStore
} from "clairveiljs";
const clairveil = createClairveilClient({
rpc: "tcp://127.0.0.1:26657",
rest: "http://127.0.0.1:1317",
chainId: "clairveil-local-3",
accountPrefix: "clair",
shieldedPrefix: "clairs",
defaultDenom: "uclair"
});
const proverAdapter = createHttpProverAdapter({
baseURL: "http://127.0.0.1:8080"
});
const wallet = createKeplrWalletAdapter({
keplr: window.keplr,
chainId: "clairveil-local-3",
accountPrefix: "clair"
});
const noteStore = new LocalStorageNoteStore({
key: "clairveil:notes:keplr",
allowPlaintext: true
});LocalStorageNoteStore is intentionally opt-in because it stores decrypted wallet notes in plaintext browser storage. Use it only for local demos/tests; production wallets should store the same fields in an encrypted wallet database.
EVM Clairveil chains submit state-changing privacy actions through the EVM privacy precompile, not direct Cosmos SDK tx broadcast. ClairveilJS still prepares the privacy payload in the browser, then converts the prepared message into IPrivacy.deposit, IPrivacy.transfer, or IPrivacy.withdraw calldata.
Supported EVM scope: an EVM Clairveil chain is expected to use the Clairveil IPrivacy precompile ABI and payload semantics. Adding another EVM chain should only require chain/profile configuration changes when that chain keeps the same ABI contract. Different precompile function shapes are outside the current supported SDK scope.
In the supported EVM ABI, IPrivacy.deposit receives { amount, noteCommitment, encryptedNote }. The Cosmos MsgDeposit path requires a DepositCircuit proof, but the current EVM precompile deposit calldata does not include a proof field.
The supported EVM IPrivacy.transfer ABI carries encrypted output notes and the two 2-byte viewTags aligned with newCommitments and cipherTexts, plus user and audit disclosure fields. It does not carry the Cosmos selfViewDisclosure* fields. ClairveilJS disables self-view disclosure by default on the EVM transport and rejects EVM transfer messages that still contain self-view disclosure bytes instead of silently dropping them.
import {
createClairveilEvmClient,
createEip1193WalletAdapter,
defaultEvmPrivacyPrecompileAddress
} from "clairveiljs/evm";
const wallet = createEip1193WalletAdapter({
provider: window.ethereum
});
const evmClairveil = createClairveilEvmClient({
provider: window.ethereum,
chainId: "evm-privacy-local-1",
accountPrefix: "clair",
shieldedPrefix: "clairs",
defaultDenom: "uclair"
});
const deposit = evmClairveil.buildDepositTransaction({
rootSeed,
amount: "10uclair"
});
await evmClairveil.sendTransaction(wallet, deposit.transaction);
console.log(defaultEvmPrivacyPrecompileAddress);For EVM transfer/withdraw, use the same ClairveilJS note scan, planner, disclosure, and prover adapter flow as the Cosmos client. The final submit step is different: Cosmos sends a Msg* sign doc, while EVM sends calldata to the privacy precompile.
Some EVM IPrivacy.withdraw deployments may still include legacy newNoteCommitment and encryptedNote ABI fields. ClairveilJS fills those ABI-only fields with 32 zero bytes by default for compatibility. withdrawOutputMode: "none" only changes the placeholder values sent to that legacy-compatible ABI. If a downstream precompile removes those ABI fields entirely, provide a custom contract adapter/encoder that matches the new function shape.
For a CosmJS OfflineSigner, supply a separate root-signing function because standard OfflineSigners do not define Clairveil's arbitrary root message signature by themselves:
import { createOfflineSignerWalletAdapter } from "clairveiljs";
const wallet = createOfflineSignerWalletAdapter({
signer: offlineSigner,
signPrivacyRoot: async messageBytes => {
return kmsOrWalletSignBytes(messageBytes);
}
});createWalletAdapter is intentionally tiny. A wallet only needs to provide:
- transparent
clair1...address - transparent account pubkey bytes (
Keplrnormally provides a compressed secp256k1 pubkey) - a function that signs the Clairveil root message bytes
const material = await clairveil.deriveWalletPrivacyMaterial(wallet);
console.log(material.shieldedAddress);
console.log(material.disclosurePubKeyHex);The derived rootSeed stays client-side and is used to scan notes, create new notes, sign note hashes, and derive the disclosure keypair.
prepareDeposit builds a Telescope-generated MsgDeposit and returns a Keplr-compatible sign doc. Deposit requires a DepositCircuit proof; generate it with a local/WASM/trusted deposit prover and pass the proof with the same deposit material.
const material = await clairveil.deriveWalletPrivacyMaterial(wallet);
const depositMaterial = clairveil.buildDepositMaterial({
creator: material.address,
rootSeed: material.rootSeed,
amount: "10uclair"
});
const { proofHex } = await depositProofProvider({
material: depositMaterial
});
const deposit = await clairveil.prepareDeposit({
material,
depositMaterial,
amount: "10uclair",
proofHex
});
const broadcast = await clairveil.signDirectAndBroadcast({
wallet,
signDoc: deposit.signDoc
});
if (!broadcast.ok) throw new Error(broadcast.error || "deposit was not confirmed");const scan = await clairveil.scanWalletNotes({
wallet,
noteStore,
includeFoundNotes: true
});
console.log(scan.summary);includeFoundNotes: true returns internal BigInt note objects for builders. Keep it off when serializing JSON directly.
Use nextScanOptions for cursor/resume UX instead of manually juggling has_more, next_page, and after_height:
const nextScan = await clairveil.scanWalletNotes({
wallet,
noteStore,
...scan.nextScanOptions
});Transfer planning is explicit because notes may need a self-merge before the final transfer.
const transfer = await clairveil.prepareTransfer({
wallet,
amount: "10uclair",
recipient: "clairs1...",
proverAdapter,
allowPlanStep: false
});
if (transfer.status === "self_merge_required") {
// Ask the user to confirm the self transaction, then retry with allowPlanStep: true.
console.log(transfer.plan.message);
} else if (transfer.status === "ready") {
const broadcast = await clairveil.signDirectAndBroadcast({
wallet,
signDoc: transfer.signDoc
});
if (!broadcast.ok) throw new Error(broadcast.error || "transfer was not confirmed");
}Planner failures use stable error codes when raised through assertPlanCanBuildTx:
import { ClairveilErrorCode, assertPlanCanBuildTx } from "clairveiljs";
try {
assertPlanCanBuildTx(transfer.plan);
} catch (error) {
if (error.code === ClairveilErrorCode.SELF_MERGE_REQUIRED) {
// Show the self-merge confirmation UI.
}
}For selective disclosure:
await clairveil.prepareTransfer({
wallet,
amount: "10uclair",
recipient: "clairs1...",
proverAdapter,
userPrivacyPolicy: "amount-from-to",
userDisclosureMode: "recipient-encrypted",
userDisclosureTargetPubKeyHex: "ab".repeat(32)
});Disclosure decode reports use a stable top-level handoff shape: plane, policy, output_index, commitment_hex, digest_hex, verified, amount, asset_denom, from, and to.
const userReport = await dapp.decodeUserDisclosure({
txHash,
address,
pubKeyHex,
signatureBase64
});
const auditReport = await dapp.decodeAuditDisclosure({
txHash,
disclosurePrivKeyHex
});
console.log(userReport.verified, auditReport.plane);The relay examples below fetch authoritative time from the latest chain block. Keep the REST endpoint aligned with the client configuration, and fail closed if the latest block does not contain a valid timestamp.
const chainRestEndpoint = "https://rest.example-chain.invalid";
async function fetchLatestChainBlockTimeUnix() {
const response = await fetch(
`${chainRestEndpoint}/cosmos/base/tendermint/v1beta1/blocks/latest`
);
if (!response.ok) {
throw new Error(`latest block time query failed with HTTP ${response.status}`);
}
const data = await response.json();
const value = data?.block?.header?.time ?? data?.sdk_block?.header?.time;
const milliseconds = Date.parse(String(value || ""));
if (!Number.isFinite(milliseconds)) {
throw new Error("latest block response omitted a valid block time");
}
return Math.floor(milliseconds / 1000);
}Withdraw requires one exact-match note. If the planner returns exact_note_required, create the exact note with a shielded self-transfer first.
MsgWithdraw has no output note fields. Do not send legacy newNoteCommitment, encryptedNote, or dummy zero-byte output-note values.
const withdraw = await clairveil.prepareWithdraw({
wallet,
amount: "5uclair",
recipient: "clair1...",
proverAdapter
});
if (withdraw.status === "ready") {
const broadcast = await clairveil.signDirectAndBroadcast({
wallet,
signDoc: withdraw.signDoc,
relayPayload: withdraw.payload,
getChainNowUnix: fetchLatestChainBlockTimeUnix
});
if (!broadcast.ok) throw new Error(broadcast.error || "withdraw was not confirmed");
}For relay withdraw, the wallet/DApp prepares a final payload and sends it to a product-defined relayer endpoint. Use the same prepareRelayWithdraw(...) call for Cosmos and EVM profiles. Cosmos returns a payload for relayer-side MsgWithdraw signing. EVM returns the same payload plus an IPrivacy.withdraw transaction request, but the relayer must still rebuild or byte-for-byte validate that transaction from the payload before broadcasting it from its own EVM account. Do not trust a client-supplied transaction without checking its to, data, chainId, recipient, expiry, and payload hash. Both examples below assume reservationManager has already been created with the complete setup in Note reservation.
const latestChainBlockTimeUnix = await fetchLatestChainBlockTimeUnix();
const prepared = await clairveil.prepareRelayWithdraw({
wallet,
amount: "5uclair",
recipient: "clair1...",
proverAdapter,
reservationManager,
chainNowUnix: latestChainBlockTimeUnix
});
if (prepared.status !== "ready") {
throw new Error(`relay withdraw preparation failed: ${prepared.plan?.status || prepared.status}`);
}
await reservationManager.recordRelayHandoff(prepared.reservation.reservation_ids, {
leaseToken: prepared.reservation.lease_token,
payloadHash: prepared.payload.payload_hash
});
await fetch("/relayer/withdraw", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ payload: prepared.payload })
});For an EVM profile, forward prepared.transaction to the relayer as a candidate transaction instead of asking the user wallet to send it. The relayer should reject it unless it matches the transaction it rebuilds from prepared.payload:
const latestChainBlockTimeUnix = await fetchLatestChainBlockTimeUnix();
const prepared = await clairveil.prepareRelayWithdraw({
walletType: "evm",
address,
pubKeyHex,
signatureBase64,
amount: "5aokrw",
recipient: "0x...",
chainNowUnix: latestChainBlockTimeUnix,
reservationManager
});
// The browser EVM client throws when relay-withdraw preparation cannot produce a ready result.
await reservationManager.recordRelayHandoff(prepared.reservation.reservation_ids, {
leaseToken: prepared.reservation.lease_token,
payloadHash: prepared.payload.payload_hash
});
await fetch("/relayer/evm-withdraw", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
payload: prepared.payload,
transaction: prepared.transaction
})
});const latestChainBlockTimeUnix = await fetchLatestChainBlockTimeUnix();
const relay = await relayerClient.createRelayWithdrawSignDoc({
payload,
relayer: relayerAddress,
pubKeyHex: relayerPubKeyHex,
chainNowUnix: latestChainBlockTimeUnix,
expectedChainId: "clairveil-1",
expectedRecipient: payload.recipient
});
await relayerClient.signDirectAndBroadcast({
wallet: relayerWallet,
signDoc: relay.signDoc,
relayPayload: relay.payload,
// Fetch the latest chain block time again after any signing delay.
getChainNowUnix: fetchLatestChainBlockTimeUnix
});Wallets and DApps that can prepare multiple private transactions concurrently should pass a reservation manager into prepareTransfer(...), prepareWithdraw(...), and prepareRelayWithdraw(...). The manager filters already-reserved notes before planning, records the selected notes while proofs are being built, renews leases during SDK proof/payload construction, and returns reservation metadata on prepared results. After a prepared result is handed back to wallet UI or a relayer flow, callers can keep the lease fresh with heartbeatLease(...)/renewLease(...).
import {
createBrowserReservationStore,
createNoteReservationManager,
reservationStatuses
} from "clairveiljs/reservation";
const reservationStateText = new TextEncoder();
const reservationStateKeyMaterial = await crypto.subtle.importKey(
"raw", material.rootSeed, "HKDF", false, ["deriveKey"]
);
const reservationStateKey = await crypto.subtle.deriveKey({
name: "HKDF",
hash: "SHA-256",
salt: reservationStateText.encode(`${chainId}:${address}`),
info: reservationStateText.encode("clairveil/reservation-state/v1")
}, reservationStateKeyMaterial, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
const encryptReservationState = async state => {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv }, reservationStateKey,
reservationStateText.encode(JSON.stringify(state))
);
return { version: 1, iv: [...iv], ciphertext: [...new Uint8Array(ciphertext)] };
};
const decryptReservationState = async value => {
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(value.iv) }, reservationStateKey,
new Uint8Array(value.ciphertext)
);
return JSON.parse(new TextDecoder().decode(plaintext));
};
const reservationStore = createBrowserReservationStore({
namespace: `${chainId}:${address}`,
requireLocks: true,
encodeState: encryptReservationState,
decodeState: decryptReservationState
});
const reservationManager = createNoteReservationManager({
store: reservationStore,
ownerKeyId: `${chainId}:${address}`,
indexKey: material.rootSeed,
// Optional: the SDK generates a fresh worker id when this is omitted.
leaseOwner: `browser-tab:${crypto.randomUUID()}`
});
const latestChainBlockTimeUnix = await fetchLatestChainBlockTimeUnix();
const prepared = await clairveil.prepareRelayWithdraw({
wallet,
amount: "5uclair",
recipient: "clair1...",
proverAdapter,
reservationManager,
chainNowUnix: latestChainBlockTimeUnix
});
if (prepared.reservation?.reservation_ids?.length) {
console.log(prepared.reservation.reservations[0].status === reservationStatuses.ProofReady);
}Use a namespace that is stable for the chain and wallet identity. indexKey is required and should come from wallet-private material, such as the privacy root seed; do not use a public account id as the default reservation lookup key. unsafeAllowPublicIndexKey: true exists only for single-user demos that knowingly accept that weaker privacy boundary. A reservation manager always requires an explicit store; it never silently creates an in-memory store. createBrowserReservationStore(...) uses IndexedDB and requires the Web Locks API by default so two tabs cannot reserve the same note at the same time. It fails closed when IndexedDB is unavailable; unsafeAllowMemoryFallback: true is an explicit demo/test-only opt-in. Production callers must provide paired encodeState/decodeState callbacks that encrypt the complete reservation state at rest. unsafeAllowPlaintext: true is an explicit demo/test-only opt-in; reservation records include operational metadata such as amounts, transaction evidence, and timestamps. For single-flow tests, callers may explicitly pass new MemoryReservationStore(), but it does not protect another tab or process and does not survive restart.
The example derives a non-extractable, namespace-separated AES-GCM key from the wallet-private root seed. Applications may instead load an equivalent stable key from their secure key-management layer, but must never persist that key beside the ciphertext.
When deriving a reservation lookup key directly, use nullifierLookupKeyFromHex(indexKey, nullifierHex) for 32-byte nullifier hex strings. nullifierLookupKey(indexKey, nullifier) treats string nullifiers as raw UTF-8 labels and rejects hex-shaped nullifier strings to avoid accidental mismatches.
The SDK moves reservations through Reserved -> Proving -> ProofReady while it prepares the payload. Reserved is the durable note-inventory lock and has no worker lease; beginning Proving atomically claims the batch lease. Only Proving and ProofReady retain worker lease fields. The SDK generates a fresh leaseOwner for each manager by default; callers that supply one must keep it unique per browser tab or worker so recovery can leave another tab's unexpired work alone. The proof heartbeat interval is derived from the active lease window rather than fixed at 60 seconds, so short leases are renewed before expiry where timers allow. After that, the wallet or DApp owns the broadcast/reconcile step:
- Pass both
reservationManagerand the preparedreservationtosignDirectAndBroadcast(...),broadcastSignedTx(...), or EVMsendTransaction(...). These methods atomically setbroadcast_in_flightand incrementbroadcast_attempt_countbefore the external RPC call, then recordSubmitted,Unknown, orManualReview. A failed terminal write leaves the durable marker in place and blocks resubmission until reconciliation. Withdraw/relay submissions must also pass the matchingrelayPayloadplus a freshchainNowUnix, or preferablygetChainNowUnix. When an EVM request containschainId, also pass that network ID asexpectedEvmChainId. Unbound relay requests are rebuilt and caller-supplied sender, gas, and fee fields are stripped after validation. If a reservation already carries an authoritativetxBytesHash, supported sender, gas, and fee fields covered by that binding may be preserved; unsupported transaction keys are always stripped before submission. The SDK decodes the Cosmos body or rebuilds the EVM calldata and rejects a missing or mismatched payload before external submission. If custom EVM transaction encoding options were used, pass the same options asrelayTransactionOptions. - Custom wallet/provider integrations must call
markBroadcastAttempting(ids, { leaseToken, txHash?, txBytesHash?, signDocHash? })immediately before crossing the external broadcast boundary, persisting any transaction identity already available before using the outcome-specific methods below. - Call
markSubmitted(ids, { leaseToken, txHash | txBytesHash })only after a transaction was actually submitted. AsignDocHashalone is not enough forSubmitted. - Call
markUnknown(ids, { leaseToken, txHash | txBytesHash, signDocHash?, error })only when the transaction may have reached the network. AsignDocHashis supplemental evidence and cannot establish that boundary by itself. - Keep the
ProofReadylease alive while a wallet or relayer is waiting.markSubmitted(...)andmarkUnknown(...)require the current, unexpired lease token; if the lease expires, reconcile or replan instead of advancing stale ownership. - Immediately before copying or uploading a relay payload, call
recordRelayHandoff(ids, { leaseToken, payloadHash: prepared.payload.payload_hash })and wait for it to persist. If that call fails, do not expose the payload. Once it succeeds, do not use local proof-discard/release paths; reconcile the externally deliverable payload instead. - For wallet rejection or local proof discard before broadcast, use
markReplanRequired(...)with the current live lease instead of leavingProofReadyactive. An expiredProofReadylease must move toManualReview; a refreshed page cannot prove that an older proof artifact was destroyed and must not make the note spendable again. - If you directly discard a local batch,
Reservedcan be released directly;Provingrequires its current batch lease token viareleaseReservedOrProving(ids, { leaseToken }).rollbackPlanReservation(...)handles both cases for you. - If a rollback sees that the lease already expired, it does not release the note. It moves the reservation to
ManualReviewbest-effort so the original prepare/prover error is preserved and the note is not silently reused. - Resolve
ManualReviewonly after an operator has reviewed the chain and payload history.resolveManualReview(ids, { target: "Released" | "ReplanRequired" | "Failed", operatorId, approvalReference, reason })records the approval metadata and moves the linked note into the approved outcome. - After a relay payload is copied or handed to a relayer, do not release the reservation by TTL or a local cancel button. The relayer may still submit the proof until expiry, so reconcile by checking nullifier status, submitted tx evidence, or manual review.
- Relay payload validation and relay signing require
chainNowUnixfrom the latest chain block time. Do not substitute browser time; fetch it again immediately before relay broadcast and reject submission if it is unavailable. - For submitted EVM transactions with a failed receipt, check nullifier status before deciding between
ConfirmedSpent,ReplanRequired, orManualReview.SubmittedorUnknownreservations can only move toReplanRequiredwhenmarkReplanRequired(...)proves bothnullifierUnspentConfirmed: trueandtxAbsentOrFailedConfirmed: true; retaincheckedHeightandtxHashCheckedas the audit trail for that tx lookup.
Reservations can also carry operation success evidence. Nullifier spent proves that the input note was consumed, but payroll/payment success requires a matching persisted transaction identity plus the expected output commitment, audit disclosure digest, recipient hash, amount hash, denom, and, when relevant, item index. markProofReady(...) accepts expectedOutputCommitment, expectedDisclosureDigest, expectedRecipientHash, expectedAmount, expectedAmountHash, expectedDenom, batchItemIndex, batchItemIndexKnown, and operationSuccessEvidenceRequired. A payroll or batch transfer sets batchItemIndexKnown: true; a direct integration may leave it false when position is not part of its success predicate. High-level transfer prepares fill note-lock evidence automatically, but operation success checking is enabled only when the complete success predicate, including recipient and amount hashes, is available. Use hashRecipient(recipient, { shieldedPrefix }) and hashAmount(denom, amount) from clairveiljs/reservation; both reject empty identity fields, and the latter accepts only non-negative uint64 minimal-denom amounts before computing SHA-256 over canonical denom:amount.
Scan migrations must treat a note as spendable only when its latest nullifier check explicitly records nullifierStatus: "unspent". Older cached isSpent: false entries, missing responses, malformed responses, and query failures are unverified and must stay out of the planner until revalidated.
When later calling reconcileSpentNotes(...), include matching tx/event evidence under operationSuccessEvidence or successEvidence. operation_status: "Succeeded" requires an actual tx identity matching the stored submitted txHash or txBytesHash; signDocHash is only a supplemental mismatch guard and cannot establish chain execution by itself. A bare txResult: { code: 0 } also has no identity and cannot succeed. Nullifier spent alone is not enough. For a multi-input operation, include spent evidence for every linked input in the same reconcile call. Incomplete evidence records ManualReview across the linked operation, while an explicit identity or expected-output mismatch records ConflictSpent with operation_success_evidence_errors. Confirmed spent inputs remain quarantined in both cases, and a later complete evidence set atomically resolves every linked reservation to the same operation outcome. If you only use reservations as a note inventory lock, leave operationSuccessEvidenceRequired unset and keep operation success checks in your downstream operation database.
The default createHttpProverAdapter is a synchronous HTTP request. For a remote prover that returns job IDs, wrap submit/poll functions with createAsyncJobProverAdapter.
import { createAsyncJobProverAdapter } from "clairveiljs";
const proverAdapter = createAsyncJobProverAdapter({
submitTransferJob: request => fetchJSON("/proof-jobs/transfer", request),
submitWithdrawJob: request => fetchJSON("/proof-jobs/withdraw", request),
getJob: jobId => fetchJSON(`/proof-jobs/${jobId}`),
intervalMs: 1000,
timeoutMs: 300000
});From this folder:
npm run check
npm run typecheck
npm test
npm pack --dry-run