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 may still have paidThe error text does not tell you whether the transaction reached the network: a broadcast error can arrive after the server has submitted it. Treat every CLI failure as “may have landed”. And with --wait, a sent transaction exits 0 even when its confirmation could not be read, so check status, not the exit code

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; onSpawn?: (pid?: number) => void; timeoutMs?: number } = {}) { const sub = execa(SQUID_BIN, args, { env: SQUID_ENV, input: opts.input, timeout: opts.timeoutMs ?? 180_000, }) opts.onSpawn?.(sub.pid) const { stdout } = await sub 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 a = parseNdjson<{ addresses?: SquidAddresses }>(out).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 `transactionDigest` while the network activates, with every address still null, * 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; needsOperatorReview?: boolean }>(out) // Check this first: the CLI fills `addresses` from its local cache even in this state. It // refuses to start another key generation here, so retrying cannot fix it. if (r.needsOperatorReview) throw new Error('squid init needs operator review — inspect `waap-cli squid status`') // A cached address next to `pending` is not a finished provisioning. if (r.addresses?.evm && !r.pending) 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<{ permissionToken?: string }>(out) // Only the field name is shown on failure: the token is a bearer secret. if (!r.permissionToken) throw new Error(`No permissionToken in output (fields: ${Object.keys(r).join(', ')})`) return r.permissionToken } // ── signing ─────────────────────────────────────────────────────────────────── /** * With `--wait`, a send that reached the network still exits 0 even when confirmation could * not be read: `status` is `pending` and `confirmationError` says why. Only `confirmed` means * the transfer landed; `failed` means it landed and reverted. */ export interface SendResult { from: string txHash: string operationId: string status?: 'confirmed' | 'failed' | 'pending' pending?: boolean confirmationError?: string } /** * Thrown only by checks in this file that run BEFORE `send-tx` is started. It is the one * failure that proves nothing was signed or sent, so it is the only one a caller may retry. */ export class LocalPreflightError extends Error { readonly localPreflight = true } /** * 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 /** Called with the CLI's process id as soon as it starts, e.g. to record it in a lock file. */ onSpawn?: (pid?: number) => void }): Promise<SendResult> { try { await assertNotFencedWallet() } catch (e) { throw new LocalPreflightError((e as Error).message) } 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') // A payment that crosses the daily limit waits for approval, for up to 30 minutes. Give it // longer than that: killing the CLI mid-approval leaves a payment nobody can prove unsent. const out = await cli(args, { input: p.grant ? `${p.grant}\n` : undefined, onSpawn: p.onSpawn, timeoutMs: 31 * 60_000 }) return parseNdjson<SendResult>(out) } /** * Did a failed `send-tx` move money? From the caller's side there is only one safe answer. * * `not_sent` → a LocalPreflightError: this file stopped before `send-tx` was started. * `ambiguous` → everything else. The money may already have moved. Never auto-retry. * * An error message is not evidence of where the failure happened. `/tx/broadcast-sui failed: * HTTP 502` can arrive after the server has already handed the transaction to the network, and * the CLI copies the server's error body into its own message, so any word you might match on * can come from anywhere. A timeout or a killed process says nothing at all. Treat all of it as * "may have landed" and let a person check the chain. */ export type SendFailureSide = 'not_sent' | 'ambiguous' export function classifySendFailure(err: unknown): SendFailureSide { return err instanceof LocalPreflightError ? 'not_sent' : 'ambiguous' } /** 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 ────────────────────────────────────────────────────────────────── /** Override or add endpoints with WAAP_RPC_URLS, a JSON object keyed by chain. */ 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', ...(JSON.parse(process.env.WAAP_RPC_URLS ?? '{}') as Record<string, string>), } /** * 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) } } /** * What the chain says about one transaction hash: `confirmed`, `failed`, or `unknown`. * * `unknown` covers "not found", "not yet", and "the RPC did not answer". None of them proves * the transaction will never land, so none of them may be read as "unpaid". This deliberately * does not go through `rpc()`, which turns a null result into `'0x0'`. */ export type TxOutcome = 'confirmed' | 'failed' | 'unknown' export async function txOutcome(chain: Chain, hash: string): Promise<TxOutcome> { const url = RPC[chain] if (!url) return 'unknown' const call = async (method: string, params: unknown[]) => { const res = await fetch(url, { signal: AbortSignal.timeout(15_000), method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), }) return (await res.json()) as { result?: any; error?: unknown } } try { if (chain.startsWith('evm:')) { const r = (await call('eth_getTransactionReceipt', [hash])).result if (!r) return 'unknown' return r.status === '0x1' ? 'confirmed' : r.status === '0x0' ? 'failed' : 'unknown' } if (chain.startsWith('sui:')) { const r = (await call('sui_getTransactionBlock', [hash, { showEffects: true }])).result const s = r?.effects?.status?.status return s === 'success' ? 'confirmed' : s === 'failure' ? 'failed' : 'unknown' } const r = (await call('getSignatureStatuses', [[hash], { searchTransactionHistory: true }])).result const s = r?.value?.[0] if (!s) return 'unknown' if (s.err) return 'failed' return s.confirmationStatus === 'confirmed' || s.confirmationStatus === 'finalized' ? 'confirmed' : 'unknown' } catch { return 'unknown' } } /** * 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 init -y # skip if the directory already has a package.json npm pkg set type=module # execa is ESM-only; without this the import fails npm install execa@9.6.1 npm install -D tsx@4.23.15 typescript@5.6.3 @types/node@22

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