Operations
What shield, send, unshield, balance, and sync actually do — coin selection, the 1×2 join-split, and fees.
The four verbs are the whole agent-facing surface. This page is what each one does under the hood, so you can reason about gas, privacy, and failure modes.
sync
await hestia.sync();Pulls new pool + registry events through the indexer into the local store, rebuilding the
commitment tree, nullifier set, and approved ASP roots. Call it before reading a balance or
spending. send and unshield sync internally, but calling it after a shield keeps your
view current.
balance
const value: bigint = await hestia.balance(token); // base unitsComputed entirely locally: the SDK trial-decrypts every note ciphertext with your viewing key,
keeps the notes that are yours and unspent, filters to token, and sums their values. ETH is
the token field 0; an ERC-20 maps to addressToField(token).
shield
await hestia.shield({ token, amount });Steps:
- Approve (ERC-20 only). If the token isn't ETH, the SDK sends an
approve(pool, amount)and waits for it. For ETH, the amount rides asmsg.value. - Read position. Reads
nextLeafIndex()and derives the deposit's label from it. - Build & seal the note. Picks fresh
randomness, encrypts the note plaintext to your ownVK, and callsshield(token, amount, SK, randomness, encryptedNote). - Wait. Resolves with the transaction hash after the receipt confirms.
The deposit amount is public (it left a public account); ownership of the resulting note is
not.
send
await hestia.send({ token, amount, to, fee? });A private transfer is a 1×2 join-split: one input note, two output notes.
- Select one of your notes that covers
amount + fee(see coin selection below). - Split it into two outputs that carry the input's
label:out0→ recipient:value = amount,owner = recipient.SK(fromdecodeMetaAddress(to));out1→ you:value = input − amount − fee(your change).
- Seal
out0to the recipient'sVKandout1to your ownVK. - Prove & submit with
withdrawAmount = 0and a zero recipient address — nothing is paid out publicly. (See submission.)
Both outputs inherit the input's label, which is what enforces the single-lineage rule.
unshield
await hestia.unshield({ token, amount, to, fee? });Also a 1×2 join-split, but it pays out publicly:
- Select a note covering
amount + fee. - Split into your change note (
input − amount − fee) and an empty note (value0) — both owned by you. - Prove & submit with
withdrawAmount = amountandrecipient = to. The pool transfersamountoftokentoto.
The withdrawal amount and destination are public; the link back to your deposit is not.
Coin selection
The SDK uses one input note per transaction and picks the smallest single note that
covers amount + fee:
// conceptually:
notes
.filter((n) => !n.spent && n.token === tokenField && n.value >= required)
.sort((a, b) => (a.value < b.value ? -1 : 1))[0];If no single note is large enough, it throws:
import { InsufficientPrivateBalance } from "@hestia/sdk";
try {
await hestia.send({ token, amount, to });
} catch (e) {
if (e instanceof InsufficientPrivateBalance) {
// you have the total, but it's split across notes too small individually —
// consolidate first (a larger shield, or receive into one note)
}
}This is a deliberate consequence of single-lineage: the high-level operations never merge two notes of different origin to fund a payment. If your balance is fragmented across small notes, consolidate before a large send.
Submission, fees, and the relayer
The SDK builds the proof and submits it through relayTransact1x2 using its own wallet —
so by default the agent's wallet pays gas and is recorded as the relayer. The fee argument
(default 0) is the in-token amount paid to the relayer, bound into the proof.
To fully decouple a withdrawal address from any gas history, route submission through a
separate relayer — for example the Labs backend's /api/v1/relay
endpoint, which submits with a server-funded key. Because recipient, withdrawAmount, and
feeAmount are part of the verified public signals, that relayer cannot alter your
transaction.
