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:
| Balance | How to read it | Who can read it |
|---|---|---|
| Public HYPE and tokens | eth_getBalance(address), and balanceOf(address) on each token | Anyone |
| Private (notes), per asset | Rebuild the note tree from the vault's events and decrypt the notes with the user's viewing key | Only 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'sexecute4337; - 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 callsreleaseExit.
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.
| Event | Use 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, TokenFeeRecipientSet | The 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, Upgraded | Changes by the admin and the upgrader |
Collected, Burned, TeamPaid, CallerTipped on the fee collector | The 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
senderis the vault, submitted by a bundler. The EntryPoint'sUserOperationEventnames the vault for every one of them. - An exit and its payment can be apart.
ExitCreatedorTokenExitCreatedrecords the exit;ExitPaidorTokenExitPaidrecords the payment. A held exit is paid 24 hours or more later, and a stopped one only afterExitResumed. - 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 throughshieldand 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 throughshieldToken; 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 unlessentriesPaused()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| Field | Meaning |
|---|---|
events | Up 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 |
head | The 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 blockWithout 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 parametersappDomain 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. ASpendResult'sheldfollows the same rule.- History and exits carry
asset. A token entry'samountis in the token; its gas is ingasFee, in HYPE. shieldrefuses 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.
Payment links and private addresses
Payment links
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% feeA 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 to0x000000000000000000000000000000000000dEaDinstead. VOID burned in total is1,000,000,000 − totalSupply()plusbalanceOf(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.
Contracts
Every external function, event, struct and error of PrivateVault and FeeCollector, with the Volta trading code and the interfaces they use.
MCP server for agents
Give an AI agent its own private HYPE account. The voidcash MCP server lets agents hold, pay and get paid in private HYPE on Elysium, signing with their own key.