Skip to Content
RecipesSquid Mode Agent Toolkit

Squid Mode Agent Toolkit

The three Squid Mode agent recipes all sit on one shared file: multi-chain payouts, the cross-chain rebalancer and the scoped trading agent. It wraps waap-cli, parses its output, and papers over the handful of places where the obvious command is the wrong one.

Save it as lib/squid.ts next to whichever agent you are building.

What it exists to get right

Five things about the CLI surface are not guessable, and each one is a silent wrong answer rather than an error:

whoami is not your Squid addressIt returns the standard 2PC wallet. The Squid signing addresses come from squid status / squid addresses, and they are different addresses entirely
There is no Squid-aware balance commandwallet-balance reads the 2PC wallet too. Balances are read over RPC here
squid init is two-phaseThe first call can return before the dWallets exist. It has to be polled
Decimals are per-chainSOL and SUI carry 9, not 18. Dividing them by 1e18 values a funded leg at zero and manufactures a 100% drift signal
A failed send-tx has two meaningsBroadcast failure means nothing moved and a retry is safe. A confirmation failure means it may already be on-chain. The CLI reports both as one non-zero exit

The file

// lib/squid.ts — the shared foundation the Squid Mode agent recipes sit on. // Written against @human.tech/waap-cli@2.2.0. import { execa } from 'execa' // ── environment ─────────────────────────────────────────────────────────────── /** * Left empty so the CLI uses its own defaults. Set WAAP_CLI_ENV / WAAP_CLI_SESSION_DIR only if * you deliberately keep more than one environment side by side; a session dir per environment * is what stops a testnet rehearsal and a mainnet run sharing one login. */ export const SQUID_ENV: Record<string, string> = Object.fromEntries( (['WAAP_CLI_ENV', 'WAAP_CLI_SESSION_DIR'] as const) .map((k) => [k, process.env[k]]) .filter(([, v]) => Boolean(v)) as [string, string][], ) export const SQUID_BIN = process.env.WAAP_CLI_BIN ?? 'waap-cli' /** Chain identifiers the CLI accepts on `--chain`. */ export type Chain = `evm:${number}` | `sui:${string}` | `solana:${string}` export const CHAINS = { // testnet ethSepolia: 'evm:11155111' as Chain, baseSepolia: 'evm:84532' as Chain, suiTestnet: 'sui:testnet' as Chain, solanaDevnet: 'solana:devnet' as Chain, // mainnet — real money base: 'evm:8453' as Chain, ethereum: 'evm:1' as Chain, arbitrum: 'evm:42161' as Chain, suiMainnet: 'sui:mainnet' as Chain, solanaMainnet: 'solana:mainnet' as Chain, } /** The chains that spend real funds. Use it for your own guard rails. */ export const MAINNET_CHAINS = new Set<string>([ CHAINS.base, CHAINS.ethereum, CHAINS.arbitrum, CHAINS.suiMainnet, CHAINS.solanaMainnet, ]) // ── NDJSON ──────────────────────────────────────────────────────────────────── export interface WaapError { event: 'error'; code?: string; message?: string } /** * Under `--json` the CLI emits newline-delimited JSON on stdout: progress lines, then a * terminal `{"event":"result"}` or `{"event":"error"}`. * * ⚠️ `awaiting_2fa` is emitted mid-stream and is NOT terminal. If approval never arrives the * stream simply stops, so impose your own timeout rather than waiting on process exit. */ export function parseNdjson<T>(stdout: string): T { const lines = stdout.split(/\r?\n/).filter((l) => l.trim().startsWith('{')) let lastErr: WaapError | undefined for (const line of lines) { try { const obj = JSON.parse(line) as { event?: string } if (obj.event === 'result') return obj as T if (obj.event === 'error') lastErr = obj as WaapError } catch { /* progress noise */ } } if (lastErr) { const e = new Error(lastErr.message ?? 'waap-cli error') as Error & { code?: string } e.code = lastErr.code throw e } throw new Error(`No terminal event in waap-cli output: ${stdout.slice(0, 200)}`) } async function cli(args: string[], opts: { input?: string } = {}) { const { stdout } = await execa(SQUID_BIN, args, { env: SQUID_ENV, input: opts.input, timeout: 180_000, }) return stdout } // ── the wrong-account pre-flight ────────────────────────────────────────────── /** * Refuse to sign if the active login is an account this code must never touch. * * Worth having because an agent shells out from inside Node, so any guard you have wired at * the shell level — a git hook, a wrapper script, a command-string filter — never sees these * calls at all. On testnet that is harmless. On mainnet the session directory is the only * thing standing between a wrong login and a real signature. * * Addresses come from the environment, never from source, so this file stays publishable. * `WAAP_FENCED_ADDRESSES` is comma-separated and case-insensitive. Unset is NOT "no fence" — * it throws, because an empty fence is usually a fence removed by accident. To run with no * fenced accounts, set it to `none` deliberately. */ function fencedWallets(): string[] { const raw = process.env.WAAP_FENCED_ADDRESSES if (raw === undefined || raw.trim() === '') { throw new Error( 'PRE-FLIGHT FAILED CLOSED: WAAP_FENCED_ADDRESSES is unset. Nothing will sign. Set it to ' + 'the comma-separated addresses this code must never sign with, or to "none" if you ' + 'deliberately have no fenced accounts.', ) } if (raw.trim().toLowerCase() === 'none') return [] return raw.split(',').map((a) => a.trim().toLowerCase()).filter(Boolean) } let preflightPassed = false export async function assertNotFencedWallet(): Promise<void> { if (preflightPassed) return let who: Record<string, string> try { who = parseNdjson<Record<string, string>>(await cli(['whoami', '--json'])) } catch (e) { throw new Error( `PRE-FLIGHT FAILED CLOSED: could not resolve the active wallet (${(e as Error).message}). ` + `Nothing will sign. Check WAAP_CLI_ENV / WAAP_CLI_SESSION_DIR.`, ) } const fenced = fencedWallets() const addrs = Object.entries(who) .filter(([k]) => /address/i.test(k)) .map(([, v]) => String(v ?? '').toLowerCase()) .filter(Boolean) if (addrs.length === 0) { throw new Error('PRE-FLIGHT FAILED CLOSED: whoami returned no address. Nothing will sign.') } for (const a of addrs) { if (fenced.includes(a)) { throw new Error(`FENCED WALLET DETECTED (${a}). Refusing to run. Log in as the agent account.`) } } preflightPassed = true } // ── account ─────────────────────────────────────────────────────────────────── export interface SquidAddresses { evm: string | null; sui: string | null; sol: string | null } /** The addresses Squid signs from. NOT what `whoami` returns — that is the 2PC wallet. */ export async function squidAddresses(): Promise<SquidAddresses> { const out = await cli(['squid', 'status', '--json']) const r = parseNdjson<{ Addresses?: SquidAddresses; addresses?: SquidAddresses }>(out) const a = r.Addresses ?? r.addresses if (!a?.evm) throw new Error('Squid not provisioned — run `waap-cli squid init` and poll') return a } /** * Provision the dWallets. Two-phase: the first call can return `{Created:false, Pending:true}` * plus a Sui digest while the network activates, and must be called again to get addresses. * Re-running is safe — it reconciles pending operations rather than starting a second key * generation. */ export async function squidInit(maxChecks = 10): Promise<SquidAddresses> { for (let i = 0; i < maxChecks; i++) { const out = await cli(['squid', 'init', '--json']) const r = parseNdjson<{ Addresses?: SquidAddresses; Pending?: boolean }>(out) if (r.Addresses?.evm) return r.Addresses await new Promise((res) => setTimeout(res, 3000)) } throw new Error('squid init still pending after retries — activation did not complete') } // ── Privileges ──────────────────────────────────────────────────────────────── export interface GrantScope { chain: Chain /** * Addresses or targets this Privilege may supply delegated approval FOR. Not a restriction * on what the account can pay: an address absent from this list is not refused, the * Privilege simply stops applying and ordinary policy decides. */ allow: string[] /** * Lifetime USD allowance, and the one hard spending cap. Debited its full gross amount * on EVERY send it rides along with, including sub-threshold ones that never needed it — so * a $5 Privilege dies after five $1 sends that ordinary policy would have signed for free. */ amountUsd: number /** Token lifetime. The CLI caps this at 7200s — two hours. */ expirySeconds: number /** Keep 2FA for high-risk findings inside the Privilege. */ require2faForHighRisk?: boolean } export const MAX_GRANT_SECONDS = 7200 /** * Mint a short-lived Privilege bound to Squid. * * ⚠️ Minting triggers 2FA: it emits `{"event":"awaiting_2fa"}` and blocks until a human * approves. That is the human act — spending inside the Privilege is the agent act. So do not * mint inside an agent loop: mint once, hand the token to whatever runs, and let it lapse. */ export async function createGrant(scope: GrantScope): Promise<string> { if (scope.expirySeconds > MAX_GRANT_SECONDS) { throw new Error(`expirySeconds ${scope.expirySeconds} exceeds the CLI cap of ${MAX_GRANT_SECONDS}`) } if (!(scope.amountUsd > 0)) throw new Error('amountUsd must be positive') await assertNotFencedWallet() const args = [ 'squid', 'privilege', 'create', '--chain', scope.chain, '--amount-usd', String(scope.amountUsd), '--expiry-seconds', String(scope.expirySeconds), '--json', ] for (const a of scope.allow) args.push('--allow', a) if (scope.require2faForHighRisk) args.push('--require-2fa-for-high-risk-tx') const out = await cli(args) const r = parseNdjson<Record<string, string>>(out) const token = r.privilege ?? r.token ?? r.encoded ?? r.privilegeToken if (!token) throw new Error(`No privilege in output: ${out.slice(0, 200)}`) return token } // ── signing ─────────────────────────────────────────────────────────────────── export interface SendResult { from: string; txHash: string; operationId: string } /** * Build, sign and broadcast from the Squid signer. * * `grant` is the only way a Privilege binds to a signature. It is passed on stdin, never in * argv, because argv leaks a bearer secret to anything that can read the process table. */ export async function squidSendTx(p: { to: string chain: Chain /** Whole units: ETH / SUI / SOL. Never wei, MIST or lamports. */ value?: string /** EVM only. 0x-prefixed calldata. */ data?: string grant?: string wait?: boolean }): Promise<SendResult> { await assertNotFencedWallet() const args = ['squid', 'send-tx', '--to', p.to, '--chain', p.chain] if (p.value) args.push('--value', p.value) if (p.data) args.push('--data', p.data) if (p.grant) args.push('--privilege-stdin') if (p.wait) args.push('--wait') args.push('--json') return parseNdjson<SendResult>(await cli(args, { input: p.grant ? `${p.grant}\n` : undefined })) } /** * Which side of the broadcast did a failed `send-tx` die on? The CLI reports both as one * non-zero exit, but they are opposite facts: * * `/tx/broadcast-*` failed → nothing was signed onto the chain. Safe to retry. * `confirm failed` / `/tx/status-*` → it was broadcast and only the STATUS read failed. * The money may already have moved. Never auto-retry. * * This is not hypothetical: an EVM leg once returned `confirm failed … /tx/status-eth failed: * <!DOCTYPE html>` — an HTML error page from the edge, not JSON — while the transaction landed * perfectly well. A caller that reads that as "failed" double-pays. */ export type SendFailureSide = 'pre-broadcast' | 'post-broadcast' | 'unknown' export function classifySendFailure(err: unknown): SendFailureSide { const m = String((err as Error)?.message ?? err) if (/\/tx\/broadcast-/.test(m)) return 'pre-broadcast' if (/confirm failed|\/tx\/status-/.test(m)) return 'post-broadcast' return 'unknown' } /** EIP-712. ⚠️ Carries NO privilege option — a Privilege cannot bind on this path. */ export async function squidSignTypedData(p: { chain: Chain; typedData: unknown }): Promise<string> { await assertNotFencedWallet() const out = await cli([ 'squid', 'sign-typed-data', '--chain', p.chain, '--data', JSON.stringify(p.typedData), '--json', ]) const r = parseNdjson<{ signature?: string }>(out) if (!r.signature) throw new Error(`No signature in output: ${out.slice(0, 200)}`) return r.signature } // ── balances ────────────────────────────────────────────────────────────────── const RPC: Record<string, string> = { 'evm:11155111': 'https://ethereum-sepolia-rpc.publicnode.com', 'evm:84532': 'https://sepolia.base.org', 'sui:testnet': 'https://sui-testnet-rpc.publicnode.com', 'solana:devnet': 'https://api.devnet.solana.com', // Pick a Base endpoint that answers receipts: several public ones serve eth_getBalance and // eth_call happily but reject eth_getTransactionReceipt as an archive request. Verifying a // send is the whole point, so the endpoint has to answer receipts. 'evm:8453': 'https://mainnet.base.org', 'evm:1': 'https://ethereum-rpc.publicnode.com', 'evm:42161': 'https://arbitrum-one-rpc.publicnode.com', 'sui:mainnet': 'https://sui-rpc.publicnode.com', 'solana:mainnet': 'https://api.mainnet-beta.solana.com', } /** * Native decimals per chain. Not cosmetic: a rebalancer that divides SOL or SUI by 1e18 values * a funded leg at zero and reports 100% drift against a leg that is perfectly fine. */ export const DECIMALS: Record<string, number> = { evm: 18, sui: 9, // MIST solana: 9, // lamports } export function decimalsFor(chain: Chain): number { return DECIMALS[chain.split(':')[0]] ?? 18 } /** * One retry, deliberately. A public RPC dropping a single read should not take a whole tick * down with it. Retrying is safe here only because every call on this path is a READ — * nothing that signs or spends ever retries itself. */ async function rpc(chain: Chain, method: string, params: unknown[], attempt = 0): Promise<string> { const url = RPC[chain] if (!url) throw new Error(`No RPC configured for ${chain}`) try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), }) const j = (await res.json()) as { result?: unknown; error?: { message: string } } if (j.error) throw new Error(j.error.message) return (j.result ?? '0x0') as string } catch (e) { if (attempt >= 1) throw e await new Promise((r) => setTimeout(r, 750)) return rpc(chain, method, params, attempt + 1) } } /** * Native balance in base units. Read over RPC because there is no Squid-aware balance command: * `wallet-balance` reads the 2PC wallet, which is a different address. */ export async function nativeBalance(address: string, chain: Chain): Promise<bigint> { if (chain.startsWith('evm:')) return BigInt(await rpc(chain, 'eth_getBalance', [address, 'latest'])) // Sui and Solana are the same ed25519 dWallet (sqd2) on two different networks, and neither // speaks eth_getBalance. Each gets its own read; both return whole native base units. if (chain.startsWith('sui:')) { const r = (await rpc(chain, 'suix_getBalance', [address, '0x2::sui::SUI'])) as unknown as { totalBalance?: string } return BigInt(r?.totalBalance ?? 0) } if (chain.startsWith('solana:')) { const r = (await rpc(chain, 'getBalance', [address])) as unknown as { value?: number } return BigInt(r?.value ?? 0) } throw new Error(`No balance read for ${chain}`) } /** ERC-20 balance via `balanceOf(address)`. For your own sizing only. */ export async function tokenBalance(token: string, holder: string, chain: Chain): Promise<bigint> { const data = `0x70a08231${holder.replace(/^0x/, '').padStart(64, '0')}` return BigInt(await rpc(chain, 'eth_call', [{ to: token, data }, 'latest'])) } /** * ERC-20 `allowance(owner, spender)`. Read before every approve: an allowance that is already * sufficient makes the approve a wasted signature, and on Squid a signature costs 0.25 SUI * from your gas tank whether or not the call changes anything. */ export async function tokenAllowance( token: string, owner: string, spender: string, chain: Chain, ): Promise<bigint> { const data = `0xdd62ed3e${owner.replace(/^0x/, '').padStart(64, '0')}${spender.replace(/^0x/, '').padStart(64, '0')}` return BigInt(await rpc(chain, 'eth_call', [{ to: token, data }, 'latest'])) } export const USDC = { 'evm:11155111': '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', 'evm:84532': '0x036CbD53842c5426634e7929541eC2318f3dCF7e', // mainnet — native (Circle-issued) USDC, not a bridged variant 'evm:8453': '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', 'evm:1': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', 'evm:42161': '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', } as const

Installing it

npm install execa npm install -D tsx typescript @types/node

Then set the pre-flight variable before anything signs:

# .env — never committed WAAP_FENCED_ADDRESSES=none # or a comma-separated list of accounts to refuse
Last updated on