HESTIAdocs

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

ts
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

ts
const value: bigint = await hestia.balance(token); // base units

Computed 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

ts
await hestia.shield({ token, amount });

Steps:

  1. 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 as msg.value.
  2. Read position. Reads nextLeafIndex() and derives the deposit's label from it.
  3. Build & seal the note. Picks fresh randomness, encrypts the note plaintext to your own VK, and calls shield(token, amount, SK, randomness, encryptedNote).
  4. 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

ts
await hestia.send({ token, amount, to, fee? });

A private transfer is a 1×2 join-split: one input note, two output notes.

  1. Select one of your notes that covers amount + fee (see coin selection below).
  2. Split it into two outputs that carry the input's label:
    • out0 → recipient: value = amount, owner = recipient.SK (from decodeMetaAddress(to));
    • out1 → you: value = input − amount − fee (your change).
  3. Seal out0 to the recipient's VK and out1 to your own VK.
  4. Prove & submit with withdrawAmount = 0 and 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

ts
await hestia.unshield({ token, amount, to, fee? });

Also a 1×2 join-split, but it pays out publicly:

  1. Select a note covering amount + fee.
  2. Split into your change note (input − amount − fee) and an empty note (value 0) — both owned by you.
  3. Prove & submit with withdrawAmount = amount and recipient = to. The pool transfers amount of token to to.

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:

ts
// 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:

ts
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.