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 address | It 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 command | wallet-balance reads the 2PC wallet too. Balances are read over RPC here |
squid init is two-phase | The first call can return before the dWallets exist. It has to be polled |
| Decimals are per-chain | SOL 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 meanings | Broadcast 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 constInstalling it
npm install execa
npm install -D tsx typescript @types/nodeThen set the pre-flight variable before anything signs:
# .env — never committed
WAAP_FENCED_ADDRESSES=none # or a comma-separated list of accounts to refuseRelated
- Multi-chain payouts: one budget, three settlement networks
- Cross-chain portfolio rebalancer: one account, two curves
- Scoped trading agent: Privileges, in practice
Last updated on