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 20.6+, run on a schedule or on demand
The hard partNot the payments. Knowing, after something goes wrong, whether a payment already went out

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 20.6 or later (the pinned CLI needs 20; --env-file needs 20.6)
  • 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 # The password is 12 to 64 characters from A-Z a-z 0-9 - _ . , : / + = @ # Read it from a prompt so it stays out of shell history. signup waits until you approve # the email it sends; if it is interrupted, re-run it with --resume. read -rs WAAP_PASSWORD printf '%s\n' "$WAAP_PASSWORD" | waap-cli signup --email you+agent@example.com --password-stdin 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 names the payout and must be stable across runs and unique in the file: it is the only thing standing between a retry and a double payment. A duplicate id stops the whole run before anything is sent.

A payout with no intervalMs is paid once. A recurring payout needs both intervalMs and startAt, and is paid once per period: period k runs from startAt + k × intervalMs. A period that was never sent is skipped when the next one starts, never paid late.

[ { "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.

Rehearse first with DRY_RUN=1: it prints what would be sent and writes nothing. The first real run needs PAYOUT_LEDGER_INIT=1 to create the ledger; every later run refuses to start without it.

DRY_RUN=1 npx tsx --env-file=.env multichain-payouts/agent.ts PAYOUT_LEDGER_INIT=1 npx tsx --env-file=.env multichain-payouts/agent.ts # first run only npx tsx --env-file=.env multichain-payouts/agent.ts

--env-file=.env loads your .env, including WAAP_FENCED_ADDRESSES (see the toolkit). Without it the toolkit’s pre-flight finds the variable unset and refuses to sign.

// multichain-payouts/agent.ts import * as fs from 'fs' import * as os from 'os' import * as path from 'path' import { pathToFileURL } from 'url' import { classifySendFailure, squidAddresses, squidSendTx, txOutcome, type Chain } from '../lib/squid' const TAG = '[multichain-payouts]' const LEDGER = path.resolve(process.env.PAYOUT_LEDGER ?? './data/payout-ledger.json') const LOCK = `${LEDGER}.lock` const CONFIG = path.resolve(process.env.PAYOUT_CONFIG ?? './payouts.json') const DRY_RUN = process.env.DRY_RUN === '1' const INIT = process.env.PAYOUT_LEDGER_INIT === '1' const log = (level: string, event: string, data?: Record<string, unknown>) => console.log(JSON.stringify({ ts: new Date().toISOString(), level, agent: TAG, event, ...data })) export interface Payout { id: string // stable, caller-supplied, unique in the file label: string recipient: string chain: Chain // the chain THEY asked to be paid on amount: string // whole units intervalMs?: number // omit for one-shot startAt?: string // required with intervalMs: period k runs from startAt + k * intervalMs enabled?: boolean } /** * One row per payment occurrence. The key is `<id>#once` for a one-shot payout and `<id>@<k>` * for period k of a recurring one, so "pay Alice every week" is many occurrences, each paid once. * * intent written and flushed BEFORE the CLI starts; still here on startup = crashed mid-send * not_sent our own pre-flight stopped it before the CLI started. The only state that retries * broadcast the CLI returned a hash but not a confirmation. Rechecked on every startup * ambiguous the CLI failed. The money may have moved. Waits for a person * failed_onchain the transaction landed and failed. Waits for a person * settled confirmed on-chain * resolved_paid a person supplied a hash the chain confirms * skipped a not_sent occurrence whose period ended. Periods are never paid late */ export type Phase = | 'intent' | 'not_sent' | 'broadcast' | 'ambiguous' | 'failed_onchain' | 'settled' | 'resolved_paid' | 'skipped' export const BLOCKING: Phase[] = ['intent', 'broadcast', 'ambiguous', 'failed_onchain'] const PHASES: Phase[] = [...BLOCKING, 'not_sent', 'settled', 'resolved_paid', 'skipped'] export interface Entry { key: string; id: string; phase: Phase; attempt: number recipient: string; chain: Chain; amount: string; intentAt: string txHash?: string; settledAt?: string; error?: string resolvedBy?: string; resolvedAt?: string; override?: boolean } export interface Ledger { version: 2; createdAt: string; occurrences: Record<string, Entry>; history: Entry[] } // ── occurrences ─────────────────────────────────────────────────────────────── /** Which occurrence is current at `now`, or null before a recurring payout's first period. */ export function occurrenceKey(p: Payout, now: Date): string | null { if (!p.intervalMs) return `${p.id}#once` const k = Math.floor((now.getTime() - Date.parse(p.startAt!)) / p.intervalMs) return k < 0 ? null : `${p.id}@${k}` } export type Decision = | { kind: 'send'; key: string; previous?: Entry } | { kind: 'blocked'; key: string; phase: Phase } | { kind: 'done' | 'disabled' | 'not_started' } /** Pure: what to do about one payout. Also returns earlier not_sent occurrences to mark skipped. */ export function decide(p: Payout, l: Ledger, now: Date): { decision: Decision; skip: string[] } { const mine = Object.values(l.occurrences).filter((e) => e.id === p.id) if (p.enabled === false) return { decision: { kind: 'disabled' }, skip: [] } const blocked = mine.find((e) => BLOCKING.includes(e.phase)) if (blocked) return { decision: { kind: 'blocked', key: blocked.key, phase: blocked.phase }, skip: [] } const key = occurrenceKey(p, now) if (!key) return { decision: { kind: 'not_started' }, skip: [] } const skip = mine.filter((e) => e.phase === 'not_sent' && e.key !== key).map((e) => e.key) const cur = l.occurrences[key] if (!cur) return { decision: { kind: 'send', key }, skip } if (cur.phase === 'not_sent') return { decision: { kind: 'send', key, previous: cur }, skip } return { decision: { kind: 'done' }, skip } } // ── validation ──────────────────────────────────────────────────────────────── const isTime = (s: unknown) => typeof s === 'string' && !Number.isNaN(Date.parse(s)) export function validateConfig(raw: unknown): Payout[] { if (!Array.isArray(raw)) throw new Error('payout config must be a JSON array') const seen = new Set<string>() raw.forEach((p: any, i) => { const where = `payout config entry ${i}` if (typeof p?.id !== 'string' || !/^[\w.-]+$/.test(p.id)) throw new Error(`${where}: id must be letters, digits, _ . -`) if (seen.has(p.id)) throw new Error(`${where}: duplicate id "${p.id}". Each id is paid at most once per occurrence`) seen.add(p.id) if (typeof p.recipient !== 'string' || !p.recipient) throw new Error(`${where}: recipient missing`) if (!/^(evm|sui|solana):/.test(String(p.chain))) throw new Error(`${where}: unknown chain "${p.chain}"`) if (typeof p.amount !== 'string' || !/^\d+(\.\d+)?$/.test(p.amount) || Number(p.amount) <= 0) throw new Error(`${where}: amount must be a positive decimal string in whole units`) if (p.intervalMs !== undefined) { if (!Number.isInteger(p.intervalMs) || p.intervalMs <= 0) throw new Error(`${where}: intervalMs must be a positive integer`) if (!isTime(p.startAt)) throw new Error(`${where}: a recurring payout needs startAt (ISO time)`) } else if (p.startAt !== undefined) throw new Error(`${where}: startAt without intervalMs`) }) return raw as Payout[] } /** Every rule a ledger must satisfy. Anything else is treated as corrupt: nothing is sent. */ export function validateLedger(l: any): string[] { const errs: string[] = [] if (l?.version !== 2) return ['not a version 2 ledger'] if (!isTime(l.createdAt)) errs.push('createdAt missing') if (!l.occurrences || typeof l.occurrences !== 'object' || Array.isArray(l.occurrences)) return [...errs, 'occurrences missing'] if (!Array.isArray(l.history)) errs.push('history missing') for (const [k, e] of Object.entries<any>(l.occurrences)) { const bad = (why: string) => errs.push(`${k}: ${why}`) if (e?.key !== k) { bad('key does not match its slot'); continue } if (!new RegExp(`^${String(e.id).replace(/[.]/g, '\\.')}(#once|@\\d+)$`).test(k)) bad('key does not belong to its id') if (!PHASES.includes(e.phase)) bad(`unknown phase "${e.phase}"`) if (!Number.isInteger(e.attempt) || e.attempt < 1) bad('attempt missing') if (!e.recipient || !e.chain || !e.amount) bad('recipient, chain or amount missing') if (!isTime(e.intentAt)) bad('intentAt missing or invalid') if (['broadcast', 'failed_onchain', 'settled', 'resolved_paid'].includes(e.phase) && !e.txHash) bad(`${e.phase} without txHash`) if (e.phase === 'settled' && !isTime(e.settledAt)) bad('settled without settledAt') if (e.phase === 'ambiguous' && !e.error) bad('ambiguous without error') } return errs } /** * A payout whose recipient, chain or amount changed while one of its payments may be in flight * is refused: resolve that payment first. A `not_sent` row is exempt, because nothing was sent, * so the corrected values are simply what gets paid. */ function assertFingerprints(payouts: Payout[], l: Ledger) { for (const p of payouts) { for (const e of Object.values(l.occurrences)) { if (e.id !== p.id || !BLOCKING.includes(e.phase)) continue if (e.recipient !== p.recipient || e.chain !== p.chain || e.amount !== p.amount) throw new Error(`${e.key} is still open (${e.phase}) but its recipient, chain or amount changed. Resolve it first`) } } } // ── the ledger file ─────────────────────────────────────────────────────────── const emptyLedger = (): Ledger => ({ version: 2, createdAt: new Date().toISOString(), occurrences: {}, history: [] }) function readLedger(): { ledger: Ledger | null; v1?: any } { let raw: string try { raw = fs.readFileSync(LEDGER, 'utf8') } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return { ledger: null } throw new Error(`cannot read the ledger at ${LEDGER}: ${(e as Error).message}. Nothing was sent`) } let parsed: any try { parsed = JSON.parse(raw) } catch { throw new Error(`the ledger at ${LEDGER} is not valid JSON. Nothing was sent. Restore it from a backup; do not delete it`) } if (parsed && parsed.version === undefined && parsed.entries) return { ledger: null, v1: parsed } const errs = validateLedger(parsed) if (errs.length) throw new Error(`the ledger at ${LEDGER} is inconsistent: ${errs.join('; ')}. Nothing was sent`) return { ledger: parsed as Ledger } } /** * A missing ledger is only acceptable on a deliberate first run. The path is relative to the * working directory, so a scheduler started in the wrong folder would otherwise begin with an * empty ledger and pay everyone again. */ function loadLedger(): Ledger { const { ledger, v1 } = readLedger() if (v1) throw new Error('this ledger was written by the earlier version of this runner. Run once with --migrate-v1') if (ledger) return ledger if (!INIT) { throw new Error(`no ledger at ${LEDGER}. On a genuine first run set PAYOUT_LEDGER_INIT=1. ` + 'If you expected a ledger here, stop: without it this runner cannot know whom it has paid') } const l = emptyLedger() saveLedger(l) log('info', 'ledger_created', { ledger: LEDGER }) return l } /** 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}.${process.pid}.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 const dir = fs.openSync(path.dirname(LEDGER), 'r') fs.fsyncSync(dir) // and the rename itself survives a power cut fs.closeSync(dir) } // ── one run at a time ───────────────────────────────────────────────────────── interface LockInfo { host: string; pid: number; childPid?: number; startedAt: string } function writeLock(info: LockInfo, flag: 'wx' | 'w') { const fd = fs.openSync(LOCK, flag) fs.writeSync(fd, JSON.stringify(info)) fs.fsyncSync(fd) fs.closeSync(fd) } /** * Two overlapping runs would both read "unpaid" and both send. The lock is never cleared * automatically: a crash leaves it behind on purpose, next to the `intent` row that says * which payment was in flight. See `--clear-lock`. */ async function withLock<T>(fn: (setChild: (pid?: number) => void) => Promise<T>): Promise<T> { fs.mkdirSync(path.dirname(LEDGER), { recursive: true }) const info: LockInfo = { host: os.hostname(), pid: process.pid, startedAt: new Date().toISOString() } try { writeLock(info, 'wx') } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e const held = fs.readFileSync(LOCK, 'utf8') throw new Error(`another run holds ${LOCK} (${held}). If no run is active, use --clear-lock`) } try { return await fn((pid) => writeLock({ ...info, childPid: pid }, 'w')) } finally { fs.rmSync(LOCK, { force: true }) } } const alive = (pid?: number) => { if (!pid) return false try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' } } /** Removes a lock left by a crash, and nothing else: the ledger rows stay exactly as they are. */ function clearLock() { if (!fs.existsSync(LOCK)) return log('info', 'no_lock') const held = JSON.parse(fs.readFileSync(LOCK, 'utf8')) as LockInfo if (held.host !== os.hostname()) throw new Error(`lock was taken on ${held.host}; clear it there`) if (alive(held.pid) || alive(held.childPid)) { throw new Error(`the run (pid ${held.pid}) or its waap-cli child (pid ${held.childPid}) is still alive. ` + 'A child can still send after its parent dies. Wait for it to exit') } fs.rmSync(LOCK) log('info', 'lock_cleared', { held }) } // ── sending ─────────────────────────────────────────────────────────────────── /** A `broadcast` row has a hash, so the chain can answer for it. Unknown stays as it is. */ async function recheckBroadcasts(l: Ledger) { for (const e of Object.values(l.occurrences)) { if (e.phase !== 'broadcast') continue const o = await txOutcome(e.chain, e.txHash!) if (o === 'unknown') { log('warn', 'still_unconfirmed', { key: e.key, txHash: e.txHash }); continue } l.occurrences[e.key] = o === 'confirmed' ? { ...e, phase: 'settled', settledAt: new Date().toISOString() } : { ...e, phase: 'failed_onchain' } saveLedger(l) log('info', 'rechecked', { key: e.key, outcome: o }) } } async function pay(p: Payout, key: string, previous: Entry | undefined, l: Ledger, setChild: (pid?: number) => void) { if (previous) l.history.push(previous) // 1 — INTENT, durable before anything is broadcast. l.occurrences[key] = { key, id: p.id, phase: 'intent', attempt: (previous?.attempt ?? 0) + 1, recipient: p.recipient, chain: p.chain, amount: p.amount, intentAt: new Date().toISOString(), } saveLedger(l) const e = l.occurrences[key] 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, onSpawn: setChild }) // 3 — only `confirmed` is paid. A hash without confirmation is rechecked next run. if (!r.txHash) l.occurrences[key] = { ...e, phase: 'ambiguous', error: 'result without txHash' } else if (r.status === 'confirmed') l.occurrences[key] = { ...e, phase: 'settled', txHash: r.txHash, settledAt: new Date().toISOString() } else if (r.status === 'failed') l.occurrences[key] = { ...e, phase: 'failed_onchain', txHash: r.txHash } else l.occurrences[key] = { ...e, phase: 'broadcast', txHash: r.txHash, error: r.confirmationError } } catch (err) { const side = classifySendFailure(err) const message = String((err as Error)?.message ?? err).slice(0, 2000) l.occurrences[key] = { ...e, phase: side === 'not_sent' ? 'not_sent' : 'ambiguous', error: message } } setChild(undefined) saveLedger(l) const done = l.occurrences[key] log(done.phase === 'settled' ? 'info' : 'error', done.phase === 'settled' ? 'paid' : 'payout_not_settled', { key, chain: p.chain, phase: done.phase, txHash: done.txHash, error: done.error }) } async function run(payouts: Payout[], l: Ledger, now: Date, setChild: (pid?: number) => void) { await recheckBroadcasts(l) for (const p of payouts) { const { decision: d, skip } = decide(p, l, now) for (const k of skip) { l.occurrences[k] = { ...l.occurrences[k], phase: 'skipped' }; saveLedger(l); log('warn', 'period_skipped', { key: k }) } if (d.kind === 'blocked') { log('warn', 'blocked_needs_person', { id: p.id, key: d.key, phase: d.phase }); continue } if (d.kind !== 'send') continue await pay(p, d.key, d.previous, l, setChild) } } // ── a person resolving a parked payment ─────────────────────────────────────── async function resolve(l: Ledger, args: string[]) { const key = args[args.indexOf('--resolve') + 1] const e = l.occurrences[key] if (!e) throw new Error(`no occurrence ${key}`) const who = { resolvedBy: os.userInfo().username, resolvedAt: new Date().toISOString() } const hashArg = args.includes('--paid') ? args[args.indexOf('--paid') + 1] : undefined if (hashArg) { // Proof of payment: a hash the chain confirms, not already used for another payment. The // runner cannot decode every chain's transfer, so match the ledger row's recipient and // amount against the transaction yourself before you run this. if (!['intent', 'ambiguous', 'broadcast'].includes(e.phase)) throw new Error(`${key} is ${e.phase}; --paid does not apply`) const reused = [...Object.values(l.occurrences), ...l.history].find((x) => x.key !== key && x.txHash === hashArg) if (reused) throw new Error(`${hashArg} already records the payment ${reused.key}`) if ((await txOutcome(e.chain, hashArg)) !== 'confirmed') throw new Error(`${hashArg} is not confirmed on ${e.chain}`) l.history.push(e) l.occurrences[key] = { ...e, ...who, phase: 'resolved_paid', txHash: hashArg } } else if (args.includes('--retry-failed')) { // A transaction that landed and failed moved no value, so this occurrence may be sent again. if (e.phase !== 'failed_onchain') throw new Error(`${key} is ${e.phase}; --retry-failed needs failed_onchain`) if ((await txOutcome(e.chain, e.txHash!)) !== 'failed') throw new Error(`the chain no longer reports ${e.txHash} as failed`) l.history.push(e) l.occurrences[key] = { ...e, ...who, phase: 'not_sent' } } else if (args.includes('--override-resend')) { // No proof either way. This is a decision to risk paying twice, and it is recorded as one. if (!args.includes('--accept-duplicate-risk')) throw new Error('--override-resend also needs --accept-duplicate-risk') if (!BLOCKING.includes(e.phase)) throw new Error(`${key} is ${e.phase}; nothing to override`) l.history.push(e) l.occurrences[key] = { ...e, ...who, phase: 'not_sent', override: true } } else { throw new Error('--resolve <key> needs --paid <txHash>, --retry-failed, or --override-resend --accept-duplicate-risk') } saveLedger(l) log('info', 'resolved', { key, phase: l.occurrences[key].phase, recipient: e.recipient, amount: e.amount, chain: e.chain }) } // ── moving a ledger from the earlier version of this page ───────────────────── /** * The earlier runner recorded unconfirmed and failed sends as `settled`, and recorded some * sends that may have landed as `failed`. So nothing it wrote is trusted as paid: a row with a * hash is rechecked on the next run, and every other row waits for a person. */ export function migrateV1(v1: any, payouts: Payout[]): Ledger { const l = emptyLedger() const byId = new Map(payouts.map((p) => [p.id, p])) const keyAt = (id: string, when: string) => { const p = byId.get(id) if (!p?.intervalMs) return `${id}#once` return occurrenceKey(p, new Date(isTime(when) ? when : Date.now())) ?? `${id}@0` } const lastPaidAt: Record<string, string> = v1.lastPaidAt ?? {} for (const [id, e] of Object.entries<any>(v1.entries ?? {})) { if (byId.get(id)?.intervalMs && !byId.get(id)?.startAt) throw new Error(`${id} is recurring: add startAt before migrating`) const when = e.settledAt ?? e.intentAt const key = keyAt(id, when) const base: Entry = { key, id, phase: 'ambiguous', attempt: 1, recipient: e.recipient, chain: e.chain, amount: e.amount, intentAt: isTime(e.intentAt) ? e.intentAt : new Date().toISOString(), txHash: e.txHash, } const consistent = e.phase !== 'settled' || (e.txHash && isTime(e.settledAt) && isTime(lastPaidAt[id])) if (e.phase === 'settled' && consistent) l.occurrences[key] = { ...base, phase: 'broadcast', error: 'migrated from v1: rechecking' } else l.occurrences[key] = { ...base, error: consistent ? `migrated from v1 phase "${e.phase}"` : 'migrated from v1: inconsistent row' } } for (const [id, when] of Object.entries(lastPaidAt)) { if (v1.entries?.[id]) continue const p = byId.get(id) const key = keyAt(id, when) l.occurrences[key] = { key, id, phase: 'ambiguous', attempt: 1, recipient: p?.recipient ?? '?', chain: p?.chain ?? ('evm:0' as Chain), amount: p?.amount ?? '0', intentAt: isTime(when) ? when : new Date().toISOString(), error: 'migrated from v1: paid time without a ledger entry', } } return l } function migrate(payouts: Payout[]) { const { v1 } = readLedger() if (!v1) throw new Error('--migrate-v1: the ledger is not a version 1 ledger') fs.copyFileSync(LEDGER, `${LEDGER}.v1.bak`) const l = migrateV1(v1, payouts) saveLedger(l) log('info', 'migrated', { backup: `${LEDGER}.v1.bak`, rows: Object.keys(l.occurrences).length }) } // ── entry point ─────────────────────────────────────────────────────────────── async function main() { const args = process.argv.slice(2) if (args.includes('--clear-lock')) return clearLock() const now = new Date() const payouts = validateConfig(JSON.parse(fs.readFileSync(CONFIG, 'utf8'))) if (DRY_RUN) { // A rehearsal takes no lock and writes nothing, including the ledger it would create. const { ledger, v1 } = readLedger() if (!ledger && !v1 && !INIT) log('warn', 'dry_run_no_ledger', { note: 'a real run needs PAYOUT_LEDGER_INIT=1' }) const l = ledger ?? (v1 ? migrateV1(v1, payouts) : emptyLedger()) for (const p of payouts) { const { decision } = decide(p, l, now) log('info', 'dry_run', { id: p.id, decision }) } return } const addrs = await squidAddresses() log('info', 'agent_start', { evm: addrs.evm, sui: addrs.sui, sol: addrs.sol, now: now.toISOString() }) await withLock(async (setChild) => { if (args.includes('--migrate-v1')) return migrate(payouts) const ledger = loadLedger() if (args.includes('--resolve')) return resolve(ledger, args) assertFingerprints(payouts, ledger) await run(payouts, ledger, now, setChild) }) log('info', 'agent_stop', { processed: payouts.length }) } // Runs when invoked directly; importing the file (for tests) runs nothing. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { 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 record rather than an unknown. On the next run, a row still at intent blocks that payout until a person has checked the chain.

Why a failed send is not an unpaid one

The hard case is not the crash. It is the error that arrives after the money has already moved.

waap-cli squid send-tx hands the signed transaction to WaaP’s server, which submits it to the chain. If the connection to the chain drops after submission, the server can still answer with an error such as /tx/broadcast-sui failed: HTTP 502. The payment landed; the message says it failed. A timeout or a killed process tells you even less.

So this runner never reads an error message as “nothing was sent”:

What happenedRecorded asWhat the next run does
Confirmed on-chainsettledNothing, it is paid
Sent, confirmation not read yetbroadcast with its hashAsks the chain. Confirmed becomes settled, failed becomes failed_onchain, no answer leaves it waiting
Landed and failed on-chainfailed_onchainWaits for a person
Any error from the CLIambiguousWaits for a person
Stopped by this runner’s own checks before the CLI startednot_sentSends it

A payout that is waiting for a person blocks every later payment with the same id, including later periods of a recurring payout. The check is a single rule that runs before every send, so a second copy of the same payout in the same run cannot slip past it either.

When a payment is waiting for you

The run logs blocked_needs_person with the occurrence key, for example alice#once or weekly-alice@3. Look the payment up on the chain from your Squid address, then pick one:

# You found it on-chain: record the hash. It must be confirmed on that chain. npx tsx --env-file=.env multichain-payouts/agent.ts --resolve alice#once --paid <txHash> # It landed and failed on-chain (failed_onchain only): allow one more attempt. npx tsx --env-file=.env multichain-payouts/agent.ts --resolve alice#once --retry-failed # You cannot prove either way and decide to send again anyway. npx tsx --env-file=.env multichain-payouts/agent.ts --resolve alice#once --override-resend --accept-duplicate-risk

Before --paid, check the transaction’s recipient and amount yourself: the command confirms the hash landed, not that it is this payment.

A payment that needed your approval can also end up here. waap-cli waits up to 30 minutes for an escalated payment to be approved, and the toolkit gives send-tx 31 minutes before it gives up. If the run is killed while it waits, check whether you approved the payment before choosing.

Not finding a transaction is not proof it was never sent, which is why there is no “mark as unpaid” command. The override is the honest name for that decision: it can pay twice, and the ledger keeps the earlier attempt in its history.

If you change a payout’s recipient, chain or amount while one of its payments is waiting for you, the run stops until that payment is resolved. Changing startAt or intervalMs of a recurring payout renumbers its periods, so an already-paid period can come due again: give the new schedule a new id instead.

Never edit or delete ledger rows by hand, and never point a second copy of the runner at a different ledger file for the same recipients. One ledger, one machine. The lock stops two runs on one machine; nothing stops two machines with two ledgers.

If a run crashed, it leaves payout-ledger.json.lock behind. --clear-lock removes it only after the crashed run and its waap-cli child have both exited, because the child can still send after its parent dies. It never touches a ledger row.

A ledger written by the earlier version of this page is refused until you run --migrate-v1 once. That version recorded unconfirmed and failed sends as paid, so the migration trusts none of it: a row with a hash is rechecked on the chain, and every other row waits for you. The old file is kept as payout-ledger.json.v1.bak.

Two things that will catch you

⚠️ request is EVM-only. The CLI cannot resolve a Sui or Solana transaction by hash, so the runner checks all three chains over RPC with the toolkit’s txOutcome(). Point it at your own endpoints with WAAP_RPC_URLS.

⚠️ 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

Those transfers predate the current runner. The runner’s ledger rules are covered by a regression suite that never spends: it runs the code on this page against a stand-in for waap-cli and a stand-in chain. It covers corrupt and missing ledgers, duplicate ids, a broadcast error after submission, pending and failed results, overlapping runs, a crash mid-send, recurring periods and migration from the earlier version.

Last updated on