Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

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
pnpm add @pampalo/sdk

Requires 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's separateDerivationKey is false (today: Base Sepolia).
  • Isolated envelope (envelopeIsolated) — derived at the dedicated slot-420 path. Used on chains where separateDerivationKey is true (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.db

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