voidcashdocs

Integrations

What wallets, exchanges, explorers and analytics tools need to know about the vault and VOID, the indexer API, and building on the voidcash SDK.

voidcash is one contract that holders opt into: the PrivateVault, at 0x5535fa0d95E4e03783C3A5d693E2f7225e6d23d4. HYPE and listed ERC-20 tokens shielded into it are held against encrypted notes; what is paid out of it is ordinary HYPE or an ordinary token transfer again. The vault runs behind an ERC-1967 proxy: use the proxy's address, which stays the same when the implementation is upgraded.

VOID is a separate, ordinary ERC-20 on Volta, and the one token the vault lists. Using voidcash does not need it.

Addresses are on Deployments; every function and event on Contracts.

Wallets and exchanges

A voidcash user has two balances:

BalanceHow to read itWho can read it
Public HYPE and tokenseth_getBalance(address), and balanceOf(address) on each tokenAnyone
Private (notes), per assetRebuild the note tree from the vault's events and decrypt the notes with the user's viewing keyOnly the owner

A standard integration shows the public balance correctly. Registering privacy keys changes nothing about it: HYPE becomes private only when the holder shields it (shield, with the HYPE as msg.value), a listed token when the holder approves the vault and calls shieldToken, and each becomes public again when an exit pays it to an address.

To show the private balance, a wallet derives the user's privacy keys from one signature and scans the notes. The SDK does both. See Keys and Your balance.

The keys that read a private balance come from a signature over a Sign-In with Ethereum message naming one domain (voidcash.io) and the vault's address. Never ask users to sign another site's key-derivation message: the result reveals all their notes. Spending also needs a per-transaction signature by the same wallet, so the keys alone cannot move funds.

Signing requirements for a wallet:

  • An EOA. Every spend is checked inside the proof against an ECDSA signature by the wallet's own key, so a smart-contract wallet cannot spend.
  • Deterministic signatures. The keys come back next time only if the wallet signs the same message the same way. On first use the SDK asks for the signature twice and refuses a wallet whose two signatures differ.

Receiving an exit. An unshield of HYPE to an exchange deposit address arrives as a plain HYPE transfer from the vault, as an internal call. An unshield of a token arrives as the token's Transfer from the vault. In both cases:

  • sent privately, through ERC-4337, the outer transaction is a bundler's call to the EntryPoint's handleOps, and the HYPE moves in the vault's execute4337;
  • submitted directly with transact, the outer transaction is the submitter's call to the vault;
  • an exit paying out more than the vault's largeExitThreshold (1,000 HYPE at deployment), or for a token more than its own threshold or beyond its cap per 24-hour window, is paid at least 24 hours later, by whoever calls releaseExit.

Deposit detection must therefore follow internal transfers and token Transfer logs, not only top-level transactions.

Explorers and analytics

Events

All on the vault unless noted.

EventUse it for
VaultUpdated(totalBacking, pendingExitTotal)HYPE held privately, and HYPE of exits decided and not yet paid. Emitted after every change
TokenBookUpdated(token, backing, pendingExits)The same for one listed token, in its base units
NoteAdded(index, commitment, ciphertext)Tree size: the set every private balance hides among. Notes of every asset share one tree
Shielded(from, index, amount)HYPE entering the vault, and who shielded it
TokenShielded(from, token, index, amount)A listed token entering the vault, and who shielded it
NullifierSpent(nullifier)Private transactions: three per transaction
ExitCreated(exitId, recipient, amount, exitFee, gasFee, payableAt)Every spend that took HYPE and no token out of the notes. amount > 0 is a HYPE exit to recipient; amount == 0 is an ERC-4337 operation that paid only its gas, such as a private send of any asset
ExitPaid(exitId, recipient, amount)HYPE reaching an exit's recipient
TokenExitCreated(exitId, token, recipient, amount, exitFee, gasFee, payableAt)A token exit: amount for recipient after exitFee in the token; gasFee HYPE paid from the fee note
TokenExitPaid(exitId, token, recipient, amount)A token reaching an exit's recipient
ExitStopped(exitId), ExitResumed(exitId)The guardian stopping or resuming an exit of any asset
FeesSwept(collector, amount)HYPE exit fees and unused gas sent to the fee collector
TokenFeesSwept(token, recipient, amount)Token exit fees sent to the token fee recipient
TokenListed, TokenLimitsSet, TokenEntriesPaused, TokenFeeRecipientSetThe listed tokens, their limits and deposits, and who receives their fees
KeysRegistered(owner, opk, viewKey)Addresses that can receive private sends
TreeCompleted(treeNumber, finalRoot)A full note tree; a new one starts at index 0
Paused, Unpaused, EntriesPaused(paused)The guardian pausing spends and payouts, or shields
LargeExitThresholdSet, FeeCollectorSet, RoleGranted, RoleRevoked, UpgradedChanges by the admin and the upgrader
Collected, Burned, TeamPaid, CallerTipped on the fee collectorThe split of the fees, VOID bought and burned, payments to the team

A private transaction's public inputs are the root, three nullifiers, four new commitments, exitAmount, exitAsset, feeAmount, extDataHash(ext) and the vault's EIP-712 domain separator in two 128-bit halves; publicInputs(t) on the vault returns all 14 in circuit order. See Contracts.

What not to assume

  • The ERC-4337 sender is not the user. Private transactions are UserOperations whose sender is the vault, submitted by a bundler. The EntryPoint's UserOperationEvent names the vault for every one of them.
  • An exit and its payment can be apart. ExitCreated or TokenExitCreated records the exit; ExitPaid or TokenExitPaid records the payment. A held exit is paid 24 hours or more later, and a stopped one only after ExitResumed.
  • HYPE leaves the vault in three ways only, in the current implementation. Exit payments (ExitPaid), sweeps to the fee collector (FeesSwept), and gas fees deposited into the EntryPoint during validation. The vault accepts HYPE only through shield and from the EntryPoint.
  • A token leaves the vault in two ways only: exit payments (TokenExitPaid) and sweeps of its exit fees to the token fee recipient (TokenFeesSwept). It enters through shieldToken; a token sent to the vault any other way backs no note.
  • Holder counts miss private holders. A note shows neither its owner nor its amount: everything in the vault counts as one address. VOID's holder list shows the vault as one holder of all the VOID shielded.
  • The vault can be paused. While paused() is true, no note can be spent and no exit paid. Shields still work unless entriesPaused() is also true, or, for a token, tokenConfig(token).entriesPaused.
  • The vault's code can change. Its address stays the same, but the upgrader can point it at a new implementation (Upgraded). Read the ABI from the current implementation.

The indexer API

The indexer at https://indexer.voidcash.io serves every vault event a wallet needs, the same for everyone, so the server never learns which notes anyone owns. There are no per-user queries, and wallets check what they get against the on-chain root. It answers CORS for any origin.

GET /events?from=<block>[&after=<block>:<logIndex>]  →  { events, next, head }
GET /status                                          →  { chainId, syncedThrough, lastConfirmedAt }
GET /ready                                           →  200 once the first confirmation is done, 503 before
GET /health                                          →  200 while the service runs
FieldMeaning
eventsUp to 2,000 events from block from on, in chain order: { block, logIndex, txHash, kind, args }. kind is the event name; numbers in args are decimal strings and addresses are lower case
next"<block>:<logIndex>" to pass as after for the next page, or null on the last page
headThe first block not yet confirmed: use the events before it, and read from head on from the chain

It serves NoteAdded, Shielded, NullifierSpent, TreeCompleted, ExitCreated, ExitPaid, ExitStopped, ExitResumed, FeesSwept, VaultUpdated and KeysRegistered, and the token events TokenListed, TokenLimitsSet, TokenEntriesPaused, TokenFeeRecipientSet, TokenShielded, TokenExitCreated, TokenExitPaid, TokenFeesSwept and TokenBookUpdated. Read the other events from the chain.

type IndexedEvent = { block: string; logIndex: number; txHash: string; kind: string; args: Record<string, unknown> };

async function vaultEvents(from: bigint) {
  const events: IndexedEvent[] = [];
  let after: string | null = null;
  for (;;) {
    const url = new URL("https://indexer.voidcash.io/events");
    url.searchParams.set("from", from.toString());
    if (after) url.searchParams.set("after", after);
    const page = (await (await fetch(url)).json()) as { events: IndexedEvent[]; next: string | null; head: string };
    events.push(...page.events);
    if (!page.next) return { events, head: BigInt(page.head) };
    after = page.next;
  }
}

const { events, head } = await vaultEvents(454409n); // the vault's deployment block

Without the indexer, read the same events from the chain with eth_getLogs, in ranges of at most 10,000 blocks. The SDK's fetchVaultLogs uses the indexer when given one, the chain after its head, and the chain alone if the indexer does not answer.

The SDK

@voidcash/sdk is the TypeScript library the app and the MCP server are built on: key derivation, note scanning, proving, shielding, private sends, unshields of HYPE and listed tokens, payment links, private addresses, payment receipts and the pool's figures. It runs in browsers (the app) and in Node.js (the MCP server, on Node.js 22 or later); proofs are made locally with Barretenberg (@aztec/bb.js 5.2.1) and Noir (@noir-lang/noir_js 1.0.0).

The SDK is not published on npm. It lives in the voidcash repository as packages/sdk and ships its TypeScript source; inside the repository's pnpm workspace, depend on it with:

{ "dependencies": { "@voidcash/sdk": "workspace:*" } }

Set up a client

import { createPublicClient, createWalletClient, http, type PublicClient } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { bundlerSubmitter, elysium, ENTRY_POINT_V08, PrivacyClient, transactCircuit } from "@voidcash/sdk";

const chain = elysium(); // chain 99901 on https://rpc.volta.family
const publicClient = createPublicClient({ chain, transport: http() }) as PublicClient;
const wallet = createWalletClient({
  account: privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`),
  chain,
  transport: http(),
});

const client = new PrivacyClient(
  publicClient,
  {
    vault: "0x…", // PrivateVault (proxy), see Deployments
    entryPoint: ENTRY_POINT_V08,
    deployBlock: 454409n, // first block to scan for notes
    appDomain: "voidcash.io", // named in the key-derivation message: the same keys as the app
  },
  transactCircuit,
  bundlerSubmitter("https://bundler.voidcash.io", { entryPoint: ENTRY_POINT_V08 }),
);
client.indexerUrl = "https://indexer.voidcash.io";
client.crsBaseUrl = "https://voidcash.io/crs"; // where the prover loads its parameters

appDomain is part of the keys: with voidcash.io, the same wallet gets the same keys, balance and history as in the app. Without crsBaseUrl the prover loads its parameters from Aztec's CDN, which then sees the user's IP address.

Balance, shield and send

import { formatEther, parseEther } from "viem";

await client.activate(wallet); // one signature, or two the first time
const b = await client.refresh(wallet.account.address);
console.log(formatEther(b.privateBalance), formatEther(b.publicBalance));

await client.shield(wallet, parseEther("1")); // a public transaction from the wallet; no fee
if (!(await client.isRegistered(wallet.account.address))) await client.register(wallet);

await client.refresh(wallet.account.address); // every private action needs a fresh view of the notes
const sent = await client.send("0x…", parseEther("0.25")); // to an address that registered its keys
console.log(sent.transactionHash, formatEther(sent.gasFee));

activate derives the keys; refresh scans the notes and returns the balances, the history and the exits not yet paid. privateBalance is the HYPE balance; privateBalances holds every asset (see Tokens). shield and register are ordinary transactions from the wallet, paid in HYPE. Private actions (send, unshield, links, private-address payments and collections) go through the bundler as ERC-4337 operations: the wallet signs the spend, the SDK proves it, and the gas is paid from the notes. A spend uses the two largest notes of one tree.

Unshield

const { fee, net } = client.exitFeeOf(parseEther("2")); // 1% exit fee, and what the recipient receives
const terms = await client.exitTerms(); // { exitFeeBps, largeExitThreshold, delaySeconds }

await client.refresh(wallet.account.address);
const exit = await client.unshield(parseEther("2"), "0x…"); // or "all"
if (exit.held) {
  // paid out more than largeExitThreshold: payable 24 hours later, by anyone
  await client.releaseExit(wallet, exit.exitId);
}

The exit fee comes out of the amount; the gas fee comes on top of it. See Unshield.

Tokens

An asset is a number in the SDK: 0n (HYPE_ASSET) for HYPE, otherwise the token's address as a bigint. Every call that takes an asset also accepts the token's address; leaving it out means HYPE. shield, send, unshield, exitTerms and exitQuote take one as their last argument.

import { formatUnits, parseUnits } from "viem";

const [void_] = await client.listedTokens(); // from the vault's TokenListed events, checked with tokenConfig
// { address, asset, symbol, name, decimals, entriesPaused, largeExitThreshold, windowCap }

// Shield: approves the vault for exactly the amount if the allowance is lower, then calls shieldToken
await client.shield(wallet, parseUnits("1000000", void_.decimals), undefined, void_.address);

const b = await client.refresh(wallet.account.address);
console.log(formatUnits(b.privateBalances.get(void_.asset) ?? 0n, void_.decimals));

// Private send and unshield: amounts in the token, gas in HYPE from a private HYPE note
await client.send("0x…", parseUnits("250000", void_.decimals), void_.address);
const quote = await client.exitQuote(parseUnits("100000", void_.decimals), void_.address);
// { asset, fee, net, held, reason?: "threshold" | "window" }
const exit = await client.unshield(parseUnits("100000", void_.decimals), "0x…", void_.address);
  • Gas comes from HYPE. A token operation spends up to two notes of the token and, in the same proof, the smallest private HYPE note of the same tree that covers the gas fee; the HYPE change goes back to the sender. Without such a note the SDK throws before asking for a signature.
  • unshield(…, "all", …) for a token takes everything in the two notes it spends; the gas comes from the HYPE note.
  • exitTerms(asset) returns the token's threshold and its window: { cap, used, resetsAt, seconds }, computed as the vault does at the latest block's time. A SpendResult's held follows the same rule.
  • History and exits carry asset. A token entry's amount is in the token; its gas is in gasFee, in HYPE.
  • shield refuses an unlisted token, a token closed to deposits or closed by the global entries pause, and a token shield to a private address.
  • Payment links and private addresses carry HYPE only.
  • Ciphertexts. Every new note ciphertext is 306 bytes, HYPE included, so the length does not show the asset. A 286-byte ciphertext written before tokens still decrypts, as HYPE.

A payment link is a bearer note: a fresh secp256k1 key in the URL fragment (/claim#…), which browsers never send to a server. Whoever holds the link can claim it, privately or to any address, with no HYPE of their own.

import { linkUrl, parseLinkFragment } from "@voidcash/sdk";

await client.refresh(wallet.account.address);
const link = await client.createLink(parseEther("0.5")); // asks the wallet to sign the link-key message once per session
const url = linkUrl("https://voidcash.io", link.secret); // https://voidcash.io/claim#…

const secret = parseLinkFragment(new URL(url).hash)!;
const status = await client.linkStatus(secret); // { amount, funded, claimed }

await client.claimLink(secret, { private: true }); // into this client's notes, after activate
// or: await client.claimLink(secret, { address: "0x…" }); // to an address, an exit that pays the 1% fee

A link must hold more than twice the gas its claim pays. The link's key is derived from a second wallet signature and a random nonce kept in the sender's change note, so the sender can find and take back unclaimed links on any device. See Payment links.

Anyone who sees a payment link can claim it. Send it only to the payee, and never log or post it.

Private addresses

A private address is a link anyone can pay (/pay#…) that does not say whom it pays: it carries a second set of keys derived from the same activation, unrelated on-chain to the wallet's address.

import { parsePrivateAddress, privateAddressFingerprint, privateAddressUrl } from "@voidcash/sdk";

const mine = client.privateAddress(); // after activate
privateAddressUrl("https://voidcash.io", mine); // https://voidcash.io/pay#…
privateAddressFingerprint(mine); // "VC·XXXX·XXXX", to show and compare

// Pay someone's private address
const to = parsePrivateAddress(new URL("https://voidcash.io/pay#…").hash)!; // undefined if cut or mistyped
await client.refresh(wallet.account.address);
await client.payPrivateAddress(to, parseEther("0.1")); // from the notes
// or: await client.shield(wallet, parseEther("0.1"), to); // public HYPE straight into their note

// Move what waits at your own private address into your balance (up to two payments per call)
const b = await client.refresh(wallet.account.address);
if (b.inbox.amount > 0n) await client.collectPrivateAddress();

The link ends with a checksum, so a cut or mistyped link fails to parse instead of paying keys nobody holds. See Private address.

Proof of payment

A private send to a registered address leaves the sender a receipt: the opening of the note it created. It proves that one payment, its recipient and its amount, to whoever the sender shows it to, and nothing else.

import { decodePaymentReceipt, encodePaymentReceipt, verifyPayment } from "@voidcash/sdk";

const entry = (await client.refresh(wallet.account.address)).history.find((h) => h.receipt);
const proof = `https://voidcash.io/verify#${encodePaymentReceipt(entry!.receipt!, 99901, client.deployment.vault)}`;

const { receipt, chainId, vault } = decodePaymentReceipt(proof);
if (chainId !== 99901 || vault.toLowerCase() !== client.deployment.vault.toLowerCase()) throw new Error("another deployment");
const result = await verifyPayment(publicClient, vault, receipt); // { valid, reason?, amount?, block?, at? }

verifyPayment needs no keys: it checks that the transaction created the note and that the note opens to the recipient's registered key with that amount.

Pool figures

poolStats returns what the app's analytics page shows, from public state and events: HYPE shielded, notes, private transactions, shields, exits and their fees, gas paid, fees swept, and, with the fee collector and VOID given, VOID burned by the collector and by Volta's buyback.

import { poolStats } from "@voidcash/sdk";

const stats = await poolStats(
  publicClient,
  { vault: "0x…", deployBlock: 454409n, collector: "0x…", token: "0x…", hook: "0x…" },
  "https://indexer.voidcash.io",
);

VOID

VOID is an ordinary ERC-20 with 18 decimals, name voidcash and symbol VOID. It is a Volta launch, a 44-byte clone of Volta's token implementation, and voidcash adds nothing to it. Holding, sending, trading and listing it need no special handling.

  • Where it trades. On its Uniswap v4 pool on Elysium's PoolManager, against native HYPE, with Volta's PositionManager as the hook (pool key on Deployments). Every swap pays a 1% fee: 30% to Volta, 35% to the holder of VOID's creator NFT, 35% to Volta's buyback.
  • Supply. VOID launched with 1,000,000,000. Volta's buyback destroys what it buys, so totalSupply() falls. The fee collector sends what it buys to 0x000000000000000000000000000000000000dEaD instead. VOID burned in total is 1,000,000,000 − totalSupply() plus balanceOf(0x…dEaD).
  • Listed in the vault. VOID can be shielded, sent privately and unshielded like HYPE; the VOID the vault holds backs VOID notes only. VOID gives no claim on the vault's HYPE, and no voidcash action needs it. See VOID.

On this page