duly / scripts/demo-agent.mjs ·
raw #!/usr/bin/env node// Watch the collections agent act on NEAR testnet, inside limits set on-chain. It// needs no keys. It takes about three minutes because the agent runs on a timer.// Run: node scripts/demo-agent.mjs [site]import { NETWORKS } from '../web/lib/config.js';import { accessKeyNonce, b64encode, delegateHash, encodeSignedDelegate, TGAS, toYocto, viewCall,} from '../web/lib/near.js';import { deriveSigner, hkdf, newRoot } from '../web/lib/session.js';import { sealDocument } from '../web/lib/doc.js';import { formatAmount } from '../web/lib/money.js';import { fetchRetry } from './net.mjs';const SITE = process.argv[2] || 'https://duly.swiftbotlab.workers.dev';const net = NETWORKS.testnet;const AGENT = 'agent-duly.testnet';const view = (m, a = {}) => viewCall(net.rpc, net.contract, m, a);let failures = 0;function check(what, ok, detail = '') { console.log(` ${ok ? 'PASS' : 'FAIL'} ${what}${detail ? ` (${detail})` : ''}`); if (!ok) failures++;}const post = async (path, body) => { const res = await fetchRetry(`${SITE}${path}?net=testnet`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); return { status: res.status, body: await res.json().catch(() => ({})) };};async function gasless(s, method, args, deposit = 0n, tgas = 30n) { const ak = await accessKeyNonce(net.rpc, s.account, s.publicKeyString); const d = { senderId: s.account, receiverId: net.contract, actions: [{ type: 'FunctionCall', method, args: new TextEncoder().encode(JSON.stringify(args)), gas: tgas * TGAS, deposit }], nonce: ak.nonce + 1n, maxBlockHeight: ak.blockHeight + 120n, publicKey: s.publicKey }; const r = await post('/api/relay', { signedDelegate: b64encode(encodeSignedDelegate(d, await s.sign(await delegateHash(d)))) }); if (!r.body.ok) throw new Error(`${method}: ${r.body.error}`); return r.body;}async function until(what, test, seconds = 240) { const end = Date.now() + seconds * 1000; while (Date.now() < end) { const v = await test().catch(() => null); if (v) return v; await new Promise((r) => setTimeout(r, 5000)); } throw new Error(`Timed out waiting for ${what}`);}const root = newRoot();const m = await deriveSigner(root);const docKey = await hkdf(root, 'documents');await post('/api/onboard', { publicKey: m.publicKeyString });await gasless(m, 'register', {}, toYocto('0.01'));console.log(`Merchant ${m.account.slice(0, 12)}…`);console.log('\n1. The human sets the limits on-chain: 3% at most, sweep only to one account, keep 2 USDC');await gasless(m, 'set_agent', { agent: AGENT, max_discount_bps: 300, sweep_to: net.testPayer, sweep_keep: '2000000' }, 1n);const at = Date.now();const message = new TextEncoder().encode(`duly:agent:${m.account}:${at}`);const rules = await post('/api/agent/rules', { account: m.account, publicKey: m.publicKeyString, signature: b64encode(await m.sign(message)), at, enabled: true, days: 0, bps: 1000, hours: 48,});check('off-chain rule asks for 10%, three times the on-chain cap', rules.status === 200, rules.body.error);console.log('\n2. An invoice goes overdue');const sealed = await sealDocument({ v: 1, items: [] }, docKey);const made = await gasless(m, 'create_invoice', { amount: '10000000', due_at_ms: String(Date.now() + 20000), doc_hash: sealed.hash }, toYocto('0.006'));const id = made.events.find((e) => e.event === 'invoice_created').data.id;console.log(` invoice ${id} for 10 USDC, due in 20 seconds`);console.log('\n3. The agent notices and acts on its own (runs every minute in test mode)');const offered = await until('the discount offer', async () => { const inv = await view('get_invoice', { id }); return inv.discount_bps > 0 ? inv : null;});check('agent made an offer on-chain with no human action', true, `${offered.discount_bps / 100}%`);check('the on-chain cap won over the 10% rule', offered.discount_bps === 300, `${offered.discount_bps} bps`);check('client now owes the discounted amount', offered.due_now === '9700000', formatAmount(offered.due_now));console.log('\n4. The client pays, and the agent sweeps idle money to the only account it may use');const paid = await post('/api/test/pay', { id, amount: offered.due_now });check('payment closed the invoice at the discounted total', paid.status === 200 && (await view('get_invoice', { id })).status === 'paid', paid.body.error);const swept = await until('the sweep', async () => { const b = BigInt(await view('balance_of', { account: m.account })); return b === 2_000_000n ? b : null;});check('balance swept down to the 2 USDC floor', swept === 2_000_000n, formatAmount(swept));console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed.');process.exit(failures ? 1 : 0);