SDK: @pampalo/sdk
The programmatic core for agent accounts. Import the Account
class, point it at an RPC and a local store, and you can read balances, sync
notes from chain, and move money, public or private.
Install
pnpm add @pampalo/sdkRequires Node 20+ (22 recommended). @pampalo/shared is pulled in
automatically.
Quickstart
import { Account } from "@pampalo/sdk";
// 1. Create a fresh agent account (writes an encrypted keystore to
// ~/.pampalo/accounts/agent1.json)
const account = await Account.create({
name: "agent1",
passphrase: process.env.PAMPALO_PASSPHRASE!,
});
console.log(account.addresses.evm); // fund this address to get started
// 2. Wire up chain access + the local note store
account
.useRpc({ 84532: "https://base-sepolia.g.alchemy.com/v2/<KEY>" })
.useStore(); // ~/.pampalo/pampalo.db
// 3. Scan the chain for this account's notes + the merkle leaf set
await account.sync({ chainId: 84532 });
// 4. Read balances
const pub = await account.balance({ chainId: 84532 });
const priv = account.privateBalance({ chainId: 84532 });
console.log("public ETH wei:", pub.native.wei);
console.log("private notes:", priv.byAsset);The chain id 84532 is Base Sepolia, the deployment the SDK ships with
out of the box.
Accounts
An account is unlocked once per process and held in memory for the run, with no per-call passphrase prompt.
// fresh identity → new keystore
await Account.create({ name, passphrase });
// existing recovery phrase → new keystore (explicit opt-in)
await Account.import({ name, passphrase, mnemonic });
// unlock an existing keystore (throws "wrong passphrase" on a bad key)
await Account.load({ name, passphrase });
// ephemeral, never touches disk (CI / one-shot agents)
Account.fromMnemonic(process.env.PAMPALO_MNEMONIC!);Every account exposes its public identifiers:
account.addresses;
// {
// evm: "0x…", // public Ethereum address (m/44'/60'/0'/0/0)
// poseidon: "0x…", // unlinkable on-chain note identifier
// envelope: "0x04…",// SHARED envelope: path-0 ECIES key (= the EVM key)
// envelopeIsolated: "0x04…",// ISOLATED envelope: slot-420 ECIES key (m/44'/60'/0'/0/420)
// }Two envelope keys
The envelope key is the secp256k1 key others ECIES-encrypt your notes to. A mnemonic has two of them:
- Shared envelope (
envelope) — derived at path 0, the same key as your EVM address. Used on chains where the deployment'sseparateDerivationKeyisfalse(today: Base Sepolia). - Isolated envelope (
envelopeIsolated) — derived at the dedicated slot-420 path. Used on chains whereseparateDerivationKeyistrue(mainnets, and the default for an unset flag). Isolating it means a compromise of the always-warm shared/EVM key on testnet can't decrypt mainnet notes.
separateDerivationKey only decides which key a recipient publishes and
a sender encrypts to. It never gates decryption: account.sync()
trial-decrypts every on-chain payload against both envelope private
keys, so you recover your notes regardless of which envelope the sender
used. See ADR 0021.
Transport and store
account.useRpc({ 84532: "https://…", 1: "https://…" }); // chainId → JSON-RPC URL
account.useStore("/path/to/notes.db"); // optional; defaults to ~/.pampalo/pampalo.dbThe store is SQLite. It holds your notes, the public merkle leaf set (needed to
build proofs), and a per-chain sync cursor. sync is incremental; it resumes
from the last block it scanned.
const result = await account.sync({ chainId: 84532 });
// { fromBlock, toBlock, leavesIndexed, notesUpserted, spentMarked }Reading balances
// public, on-chain balances (native + the deployment's known tokens)
await account.balance({ chainId: 84532 });
// private balance: spendable notes summed per asset (from the local store)
account.privateBalance({ chainId: 84532 });
// shield lifecycle: queued → spendable, or cancelled / contested
account.shields({ chainId: 84532 });privateBalance and shields read the local store, so run sync first to
make sure they're current.
Moving money
All amounts are bigint base units (wei, or a token's smallest unit).
// public EVM transfer (native or ERC-20), no privacy
await account.send({ chainId: 84532, to: "0x…", amount: 1_000_000_000_000_000n });
await account.send({ chainId: 84532, to: "0x…", asset: "0x…USDC", amount: 5_000_000n });
// shield: public → private note (to yourself)
await account.shield({ chainId: 84532, amount: 1_000_000_000_000_000n }); // native
await account.shield({ chainId: 84532, asset: "0x…USDC", amount: 5_000_000n }); // ERC-20
// transfer: private note → private note
await account.transfer({
chainId: 84532,
to: { poseidon: "0x…", envelope: "0x04…" }, // recipient's identifiers
asset: "0x…USDC",
amount: 2_000_000n,
});
// unshield: private note → public payout (defaults to your own EVM address)
await account.unshield({ chainId: 84532, asset: "0x…USDC", amount: 3_000_000n, recipient: "0x…" });Each returns { txHash } (shield also returns leafCommitment). The store is
updated optimistically; run sync afterwards to pick up confirmed state and
the leaf indices new notes need before they can be spent.
v1 limitations
- Transfers/unshields self-broadcast (see above).
- Shield is to self only.
- Single-note coin selection: a spend uses one note that covers the amount; if no single note is large enough, you'll get a clear error rather than an automatic multi-note spend.
- One merkle epoch: fine for current volume; multi-tree rollover lands later.
TypeScript
The package ships full type declarations. Useful exports beyond Account:
DEPLOYMENTS / getDeployment (the chain catalog), Store, RpcTransport,
ETH_SENTINEL, and the lower-level intent builders (buildShield,
buildTransfer, buildUnshield, buildTree) if you want to construct a
transaction without broadcasting it.