Skip to Content
RecipesScoped Trading Agent (Squid Mode)

Scoped Trading Agent

What are we cooking?

A trading agent that runs on a Privilege rather than on your account. The Privilege is a signed token carrying one origin, one chain, a list of addresses it may vouch for, a dollar ceiling, and a clock that is hard-capped at two hours. Inside that scope the agent transacts without stopping to ask you. Outside it, ordinary account policy decides, and past your daily limit that means you get asked.

What this closes: the failure that drained the Grok/Bankrbot wallet in May 2026. There, an inbound NFT granted “Executive” permissions that bypassed transfer limits, and an encoded instruction posted publicly was decoded by one agent and executed by another. A Privilege here is a signed token with fixed fields. Nothing that arrives in the wallet can mint one, and nothing an agent reads can widen one.

Read this before you build on it

A Privilege is a delegate, not a fence. It is your approval, given in advance, for payments that would otherwise stop and ask you. It is not a restriction on what your account can do.

That distinction decides whether the thing you build works:

What people assumeWhat actually happens
An address outside allowed_addresses is refusedIt is not refused. The Privilege stops applying and ordinary account policy decides: below your daily limit, with no risk finding, it goes through
A send above amount_usd is refusedIt is not refused, for the same reason: the Privilege is dropped and ordinary policy decides
A tampered token is rejected as forgedIt is dropped exactly like a non-matching one, so a caller cannot tell a forged token from an irrelevant one
The Privilege is what stops the agentThe daily spend limit decides when you are asked. The Privilege’s own USD budget is the one hard spending cap: when it is exhausted, requests start asking you again

The engine refuses outright in two families of case. A terminal finding: critical risk, such as a recipient or domain flagged malicious, which nothing overrides. And no usable 2FA factor on the account when a request needs a human: a mandatory step-up (an authority-changing action, an inconsistent simulation), the emergency forced step-up, minting a Privilege, or crossing the daily limit. On an account with 2FA configured none of those is a refusal, it is an ask. That is why this page shows you the ask, not a refusal.

Why this recipe is EVM-scoped

A dollar-scoped Privilege prices against two transaction shapes on EVM: a plain native transfer, and a direct ERC-20 transfer of a token the engine can price, which on Base Sepolia means USDC. Everything else keeps ordinary escalation. That is why the Privilege here covers the settlement leg rather than the market order.

This is a scoping choice for this recipe, not a limit of the Privilege contract. The engine also accepts Sui and Solana, and treats address-only and USD-only Privileges as independently valid.

Keep yourself safe

  • Start on Base Sepolia (evm:84532) with USDC. Not plain Sepolia. A dollar-scoped Privilege never applies to an unpriced outflow, so on plain Sepolia it is never exercised and you learn nothing about it.
  • Run your agent in Docker. This recipe puts an LLM in the path of a spending decision. Containerise it.
  • Set a low daily spend limit on the account itself, underneath the Privilege. $10/day.
  • Enable escalation before funding, and leave still_require_2fa_for_high_risk_tx on for your first runs.
  • Don’t store API keys in code. .env, git-ignored.
  • Use a separate account for testing.

What you’ll need

  • Node.js 18+, Docker
  • An approval channel on your WaaP account
  • Base Sepolia ETH for gas, and testnet USDC
  • The market addresses your agent may interact with. Collect these first

Setup

npm install -g @human.tech/waap-cli@2.2.0 waap-cli signup waap-cli policy set --daily-spend-limit 10 waap-cli squid init

⚠️ squid init is asynchronous. It returns before the dWallets exist, and squid addresses and squid status --json show no addresses until provisioning completes. Poll either rather than assuming one call is enough. The toolkit’s squidInit() does the polling for you, and its squidAddresses() reads squid status --json and fails closed if nothing is provisioned yet. Re-running init is safe.

Chain is a property of each call, passed as --chain. There is no persistent chain setting here.

The Privilege

Each field maps to a specific way an agent gets away from you:

FieldWhat it is for
originStops a token issued for your app being replayed from somewhere else
chainStops a Privilege for one network being spent on another
allowed_addressesSelects the recipients this Privilege may vouch for when a send would otherwise need your approval. On its own it restricts nothing
amount_usdThe one hard spending cap: the lifetime USD budget of this Privilege. An unbounded position cannot hide behind it
expiry, capped at two hoursStops a Privilege outliving its task. This is the revocation model: Privileges are not revoked, they lapse
wallet_modeStops a Privilege minted for one signing mode being redeemed in the other
still_require_2fa_for_high_risk_txStops a high-risk transaction sliding through under a valid Privilege

Mint one:

waap-cli squid privilege create \ --chain evm:8453 \ --allow 0xYOUR_SETTLEMENT_ADDRESS \ --allow 0xANOTHER_VENUE \ --amount-usd 5 \ --expiry-seconds 3600 \ --require-2fa-for-high-risk-tx \ --json

--require-2fa-for-high-risk-tx keeps ordinary high-risk findings on 2FA even when the Privilege matches; the CLI default is off, so pass it explicitly for your first runs. --allow repeats, once per recipient or app scope: a bare address, target:*, target:selector on EVM, target:module::function on Sui, target:disc:0x… on Solana. --expiry-seconds is capped at 7200 and defaults to 900. --amount-usd defaults to 1 and must be positive.

⚠️ Minting always asks a human. It emits {"event":"awaiting_2fa"} and blocks until someone approves. So do not mint inside the agent loop. Mint once, hand the token to whatever runs (GRANT_TOKEN_FILE), and let it lapse. That is the production shape, and the only one that runs unattended.

Redemption is squid send-tx … --privilege-stdin. The older --privilege <encoded> and --permission-token forms are deprecated and warn, because argv leaks a bearer secret.

An agent that runs continuously renews often, so the blast radius of any single Privilege stays two hours wide.

See it work: watch the ask, not a refusal

The thing to demonstrate is when the wallet stops to ask you, and how a Privilege answers that in advance. Run it in this order:

  1. Set a low daily spend limit: waap-cli policy set --daily-spend-limit 1.
  2. Send enough to take the day’s total past $1, with no Privilege. It stops and asks you on your approval channel. Approve it, and it goes.
  3. Send the same thing again with a matching Privilege on stdin. It goes through with no prompt.

Step 2 versus step 3 is the whole security model in two commands: one variable, one visible difference.

⚠️ Do not try to demonstrate this by breaking a control and watching a refusal. It cannot work. Paying an unlisted address, exceeding the ceiling, presenting an expired or even a forged Privilege: none of these produce a refusal. The Privilege stops applying and ordinary policy decides, which below the daily limit means the payment goes through.

The loop

  1. Mint a Privilege scoped to the markets the agent may touch.
  2. The agent reads market state and forms a view.
  3. It submits an order. On EVM this is EIP-712 typed data via waap-cli squid sign-typed-data.
  4. The policy gate simulates, scores the destination, and checks the Privilege on the settlement leg.
  5. Renew before expiry, or stop.

⚠️ squid sign-typed-data takes no privilege option. The order signature in step 3 is not covered by the Privilege. It governs the settlement transaction in step 4. Route value movement through send-tx, or the scope binds to nothing.

⚠️ Attaching a Privilege to small sends drains it. A matching USD Privilege is debited its full gross amount on every send it rides along with, including ones far below the daily limit that never needed it. A $5 Privilege dies after five $1 trades that ordinary policy would have signed for free. Attach it conditionally if your sizing varies.

⚠️ When something does stop, you cannot see why from the client. squid send-tx returns {from, txHash, operationId}; the stable reason codes stay server-side.

The runner

This imports the shared lib/squid.ts helper. See Squid Mode agent toolkit.

// scoped-trading-agent/agent.ts import * as fs from 'fs' import { CHAINS, createGrant, squidAddresses, squidSendTx, nativeBalance, tokenBalance, USDC, type Chain, } from '../lib/squid' const TAG = '[scoped-trading-agent]' const CHAIN: Chain = (process.env.CHAIN as Chain) ?? CHAINS.baseSepolia /** The scope. Every field is a way the agent could get away from you. */ const GRANT = { amountUsd: Number(process.env.GRANT_AMOUNT_USD ?? '5'), expirySeconds: Number(process.env.GRANT_EXPIRY_SECONDS ?? '3600'), /** * Venues this Privilege may vouch for. NOT a fence: an address absent here is not refused — * the Privilege stops applying and ordinary account policy decides, which below the daily * limit means the payment goes through. */ allow: (process.env.GRANT_ALLOW ?? '').split(',').filter(Boolean), /** Keep ordinary high-risk findings on 2FA inside the Privilege. Default on; set GRANT_REQUIRE_2FA_HIGH_RISK=0 to opt out. */ require2faForHighRisk: process.env.GRANT_REQUIRE_2FA_HIGH_RISK !== '0', } const POLL_MS = Number(process.env.POLL_INTERVAL_MS ?? '30000') const MAX_TICKS = Number(process.env.MAX_TICKS ?? '0') // 0 = run until the Privilege expires const log = (level: string, event: string, data?: Record<string, unknown>) => console.log(JSON.stringify({ ts: new Date().toISOString(), level, agent: TAG, event, ...data })) /** Stand-in for the agent's real view. Replace with your market read. */ async function formView(): Promise<{ act: boolean; to: string; valueEth: string; why: string }> { const addrs = await squidAddresses() return { act: true, to: addrs.evm!, valueEth: '0.0001', why: 'placeholder — replace with a market read' } } async function main() { const addrs = await squidAddresses() const me = addrs.evm! log('info', 'agent_start', { squidEvm: me, chain: CHAIN }) // Balances are read over RPC: there is no Squid-aware balance command, and `wallet-balance` // reports the 2PC wallet, which is a different address entirely. const [eth, usdc] = await Promise.all([ nativeBalance(me, CHAIN), tokenBalance(USDC[CHAIN as keyof typeof USDC], me, CHAIN), ]) log('info', 'balances', { eth: eth.toString(), usdc: usdc.toString() }) if (usdc === 0n) { // A dollar-scoped Privilege needs trusted outgoing USD evidence to match against. With no // USDC it never applies — ordinary policy decides every send, so the run teaches nothing // about scope. log('warn', 'no_usdc', { note: 'dollar-scoped Privilege will never apply without priced outflow' }) } const allow = GRANT.allow.length ? GRANT.allow : [me] // A Privilege can be HANDED to the agent rather than minted by it, and in production that is // the normal shape: a human approves a scope once, then hands the token to whatever runs. // Minting in-process is the demo path, and it drags a blocking approval into every run. let grant: string let expiresAt: number const handed = process.env.GRANT_TOKEN_FILE ? fs.readFileSync(process.env.GRANT_TOKEN_FILE, 'utf8').trim() : process.env.GRANT_TOKEN?.trim() if (handed) { grant = handed // Trust the token's own expiry, not our clock arithmetic — the token is the authority. const claims = JSON.parse(Buffer.from(handed, 'base64').toString()).token as { expiry: string } expiresAt = new Date(claims.expiry).getTime() log('info', 'grant_supplied', { expiresAt: claims.expiry }) } else { // Blocks on 2FA. This is the human act: approving the scope. Afterwards the agent needs no // approval for sends the Privilege MATCHES — not for everything. Mandatory review, forced // step-up and policy changes still ask. log('info', 'grant_minting', { ...GRANT, allow, note: 'expect an approval prompt' }) grant = await createGrant({ chain: CHAIN, allow, amountUsd: GRANT.amountUsd, expirySeconds: GRANT.expirySeconds, require2faForHighRisk: GRANT.require2faForHighRisk, }) expiresAt = Date.now() + GRANT.expirySeconds * 1000 log('info', 'grant_minted', { expiresAt: new Date(expiresAt).toISOString() }) } let tick = 0 while (Date.now() < expiresAt) { if (MAX_TICKS && tick >= MAX_TICKS) break tick++ try { const view = await formView() if (!view.act) { log('info', 'tick_hold', { tick, why: view.why }) } else { // The Privilege rides on stdin. This is the only path where scope binds to a signature. const r = await squidSendTx({ to: view.to, chain: CHAIN, value: view.valueEth, grant }) log('info', 'trade_sent', { tick, txHash: r.txHash, operationId: r.operationId }) } } catch (err) { const e = err as Error & { code?: string } // Do NOT read an error here as "the scope refused it". Scope mismatches do not refuse — // they fall back to ordinary policy. The engine denies only a terminal finding (critical // risk) or a request that needs a human factor on an account with no usable one. Anything // else here is a transport failure. log('warn', 'send_failed', { tick, code: e.code, message: e.message }) } await new Promise((r) => setTimeout(r, POLL_MS)) } log('info', 'agent_stop', { reason: MAX_TICKS && tick >= MAX_TICKS ? 'max_ticks' : 'grant_expired', ticks: tick }) } main().catch((e) => { log('error', 'fatal', { message: (e as Error).message }); process.exit(1) })

What just happened

The agent traded without ever holding a key, and without holding your account. It held a signed token saying which payments it could make without stopping to ask you: for two hours, on one chain, to a list of addresses you chose.

What bounds it is the daily spend limit, which decides when you are asked, and the Privilege’s own USD budget, which is the one hard spending cap: when that is exhausted, the agent’s requests start asking you again. The address list selects what the Privilege vouches for; on its own it refuses nothing.

Spend is aggregated by authenticated account over the UTC day, across every chain address on the account. It is one budget, not a counter per address. And the daily limit is a human-authorization threshold rather than a ceiling: crossing it converts the request into a one-shot approval challenge, and every later priced request keeps asking while the day’s aggregate stays above the threshold. Unattended work belongs under a Privilege, not under the daily limit.

Verified on mainnet

Two trades executed under a handed Privilege on Base mainnet, on @human.tech/waap-cli@2.2.0:

Trade 10xcee43aaf…de6c61e1, block 50647504, status 0x1
Trade 20x7294b01b…82d61c96, block 50647522, status 0x1

The scope behaviour in the table at the top of this page was measured in the same session, against a Privilege of amount_usd: 1 scoped to one address. A send outside the address list, a send $0.47 over the ceiling, and a token whose payload had been edited without re-signing all three went through. That is the documented model working correctly: a Privilege that does not match is dropped, and ordinary policy decides.

Last updated on