#!/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);