Skip to Content
RecipesMulti-chain Payouts (Squid Mode)

Multi-chain Payouts with WaaP Squid Mode

What are we cooking?

A Node.js runner that pays people on whichever chain they asked to be paid on, Base, Sui or Solana, from one account, one budget and one approval channel.

Most teams paying contributors across ecosystems today either run a separate treasury per chain or make everyone accept one chain. Squid Mode gives one account signing addresses on all three, so the recipient list can carry the chain as just another column.

This is the least exotic thing Squid Mode does, and probably the most common.

ChainsBase (evm:8453), Sui (sui:mainnet), Solana (solana:mainnet)
Signingwaap-cli squid send-tx, keys are generated in secure hardware on WaaP’s infrastructure and never reach your machine
RuntimeNode.js 18+, run on a schedule or on demand
The hard partNot the payments. The ledger that stops you paying twice

Keep yourself safe

  • Start on testnet. Base Sepolia (evm:84532), Sui testnet and Solana devnet. Note that the USD spend limit does not engage on testnet, because testnet assets have no price. Risk, malicious-address and escalation checks do.
  • Run in Docker if this is going on a schedule.
  • Set a daily spend limit that reflects one payout run, not a month of them.
  • Enable escalation before funding. A payout run is exactly the case where you want the large ones to stop and ask.
  • Never commit the recipient file or your .env. Both belong in .gitignore.
  • Use a separate account for testing. Do not point this at an account holding real value.

What you’ll need

  • Node.js 18+
  • An approval channel configured on your WaaP account
  • A recipient list: address, chain, amount
  • Funds on each leg you plan to pay

Setup

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

The version is pinned rather than floating on latest so that the behaviour described on this page is the behaviour you get.

squid init provisions two dWallets: sqd1 (secp256k1, used for EVM) and sqd2 (ed25519, used for both Sui and Solana). squid addresses prints the three addresses they produce. Two dWallets, three addresses, one account. That is the design, not a rounding error.

⚠️ 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: it reconciles pending operations rather than starting a second key generation.

Fund the addresses squid addresses prints. Chain is a property of each call, passed as --chain. There is no persistent chain setting, so do not reach for chain set.

The recipient file

id is the idempotency key. It must be stable across runs, because it is the only thing standing between a retry and a double payment.

[ { "id": "leg-evm-001", "label": "EVM leg — Base", "recipient": "0xb53fcf2c4a1b315bb4508db16dc1769f761a2b02", "chain": "evm:8453", "amount": "0.000002" }, { "id": "leg-sui-001", "label": "Sui leg", "recipient": "0xe999e9b1faaab2660777549e408d378034adfc38c22ad3d6296800d8e17ac607", "chain": "sui:mainnet", "amount": "0.001" } ]

⚠️ amount is a string in whole native units: ETH, SUI, SOL. Never wei, never MIST, never lamports. send-tx --value takes whole units on all three chains, and passing an aggregator’s wei figure straight through is how a $1.30 payment becomes an attempt to send 529000000000000 ETH. Keep it a string so no JSON parser rounds it.

The runner

This imports the shared lib/squid.ts helper. See Squid Mode agent toolkit, which wraps waap-cli and is the same across all three Squid recipes. Take both files; this one does not run alone.

// multichain-payouts/agent.ts import * as fs from 'fs' import * as path from 'path' import { classifySendFailure, squidAddresses, squidSendTx, type Chain } from '../lib/squid' const TAG = '[multichain-payouts]' const LEDGER = path.resolve(process.env.PAYOUT_LEDGER ?? './data/payout-ledger.json') const CONFIG = path.resolve(process.env.PAYOUT_CONFIG ?? './payouts.json') const DRY_RUN = process.env.DRY_RUN === '1' const log = (level: string, event: string, data?: Record<string, unknown>) => console.log(JSON.stringify({ ts: new Date().toISOString(), level, agent: TAG, event, ...data })) interface Payout { id: string // stable, caller-supplied. The idempotency key. label: string recipient: string chain: Chain // the chain THEY asked to be paid on amount: string // whole units intervalMs?: number // omit for one-shot enabled?: boolean } type Phase = 'intent' | 'settled' | 'failed' interface Entry { id: string; phase: Phase; chain: Chain; recipient: string; amount: string txHash?: string; error?: string; intentAt: string; settledAt?: string } interface Ledger { entries: Record<string, Entry>; lastPaidAt: Record<string, string> } function loadLedger(): Ledger { try { return JSON.parse(fs.readFileSync(LEDGER, 'utf8')) as Ledger } catch { return { entries: {}, lastPaidAt: {} } } } /** Synchronous and flushed. An async write here reintroduces the exact race this exists to close. */ function saveLedger(l: Ledger): void { fs.mkdirSync(path.dirname(LEDGER), { recursive: true }) const tmp = `${LEDGER}.tmp` const fd = fs.openSync(tmp, 'w') fs.writeSync(fd, JSON.stringify(l, null, 2)) fs.fsyncSync(fd) // durable before we broadcast, or the ledger is theatre fs.closeSync(fd) fs.renameSync(tmp, LEDGER) // atomic swap } /** * Startup reconciliation. An entry still at `intent` means we crashed mid-flight and cannot * know whether the transaction landed. It is left for a human rather than silently re-sent: * double-paying is worse than pausing. */ function reconcile(l: Ledger): string[] { const stuck = Object.values(l.entries).filter((e) => e.phase === 'intent').map((e) => e.id) if (stuck.length) log('warn', 'reconcile_needed', { stuck, note: 'verify on-chain before re-running these' }) return stuck } function isDue(p: Payout, l: Ledger): boolean { if (p.enabled === false) return false const last = l.lastPaidAt[p.id] if (!last) return true if (!p.intervalMs) return false // one-shot, already paid return Date.now() - new Date(last).getTime() >= p.intervalMs } async function main() { const addrs = await squidAddresses() log('info', 'agent_start', { evm: addrs.evm, sui: addrs.sui, sol: addrs.sol, dryRun: DRY_RUN }) const ledger = loadLedger() const stuck = new Set(reconcile(ledger)) const payouts: Payout[] = JSON.parse(fs.readFileSync(CONFIG, 'utf8')) for (const p of payouts) { if (stuck.has(p.id)) { log('warn', 'skipped_unreconciled', { id: p.id }); continue } if (!isDue(p, ledger)) continue // A dry run must never touch the ledger. Writing intent first and returning here leaves // permanent `intent` rows, which reconciliation then reads as a mid-flight crash and // refuses to pay — a rehearsal that locks out the real run. Bail before the write. if (DRY_RUN) { log('info', 'dry_run', { id: p.id, chain: p.chain }); continue } // 1 — INTENT, durable before anything is broadcast. ledger.entries[p.id] = { id: p.id, phase: 'intent', chain: p.chain, recipient: p.recipient, amount: p.amount, intentAt: new Date().toISOString(), } saveLedger(ledger) try { // 2 — broadcast. One account, whichever chain they asked for. const r = await squidSendTx({ to: p.recipient, chain: p.chain, value: p.amount, wait: true }) // 3 — SETTLED. ledger.entries[p.id] = { ...ledger.entries[p.id], phase: 'settled', txHash: r.txHash, settledAt: new Date().toISOString() } ledger.lastPaidAt[p.id] = new Date().toISOString() saveLedger(ledger) log('info', 'paid', { id: p.id, chain: p.chain, txHash: r.txHash }) } catch (err) { const e = err as Error & { code?: string } const side = classifySendFailure(e) // `failed` means "nothing moved, retry is safe", so only a failure BEFORE broadcast earns // it. A confirm or status failure means the send may already be on-chain — that is the // ambiguous case, and it stays at `intent` so reconciliation refuses to pay again until a // human checks. Recording it as `failed` is how a payout system pays twice. const phase: Phase = side === 'pre-broadcast' ? 'failed' : 'intent' ledger.entries[p.id] = { ...ledger.entries[p.id], phase, error: e.message } saveLedger(ledger) log('error', 'payout_failed', { id: p.id, chain: p.chain, code: e.code, side, phase, message: e.message }) } } log('info', 'agent_stop', { processed: payouts.length }) } main().catch((e) => { log('error', 'fatal', { message: (e as Error).message }); process.exit(1) })

Why the ledger is written before the broadcast

A payout runner that can be run twice needs a ledger, not a log. The difference is the ordering.

A log writes history after the send:

send → (crash here) → saveHistory()

On restart that payment looks unpaid, and it goes out again. Real money, twice.

This runner writes the intent and fsyncs it before the broadcast, so a crash between the two leaves a recoverable record rather than an unknown. On the next run, reconcile() finds anything stuck at intent and refuses to touch it until a human has checked the chain.

Two things that will catch you

⚠️ request is EVM-only. The CLI cannot resolve a Sui or Solana transaction by hash, so an automated receipt step works on EVM and needs the runner’s own RPC clients for the other two legs.

⚠️ Crossing the daily limit is not a one-time interruption. The limit is a human-authorization threshold, not a cap. Once the day’s aggregate is above it, every later priced payment keeps asking, so a large payout run will not resume unattended after a single approval. If a run must complete with nobody present, put it under a Privilege instead.

What just happened

You paid across three settlement networks from one account, under one limit and one approval channel, and the private key for the account never existed in one place, and never on your machine or the agent’s. The right to sign lives in a DWalletCap: an object your own Sui address owns, transferable and enforced by the chain. Nothing signs without it.

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, so a payout run draws down a single figure no matter how many chains it touches.

Note what that means for when you get asked: the engine compares the account’s aggregate spend for the day, not the size of any one payment. A $2 payment asks when the day is already at the limit; a $200 one does not when the day is empty.

Verified on mainnet

Every leg below ran on production mainnet on @human.tech/waap-cli@2.2.0, and each hash was checked against that chain’s own RPC rather than the CLI’s exit code.

ChainResult
Base evm:84530x4faa8c97bdf79108b34e45818f1ea4305829fbe14d859f495fc0f6b5828db5cc, block 50647746, status 0x1
Sui sui:mainnet4pEWGq9AtLyMKbyaiRid7bLLyonkSbsdymjaW6QxmHUo, checkpoint 316682975, success
Solana solana:mainnet3E9cJEvsvADau8nb2Wd35L6X6oA7XxeEPJofKoKUQGxxReSL7ae6MqyeUq6X1a8bc6wCercyfP6YEqz5Wha8zk7b, slot 442853893, err: null

The idempotency ledger was exercised in the same run: an entry reverted to phase: intent, the state a crash between the flush and the broadcast leaves, was refused, not re-sent.

Last updated on