#!/usr/bin/env node // One full paid-invoice loop on NEAR testnet with worthless test USDC, in about a // minute. It needs no keys, no wallet and no account: it makes a throwaway merchant // and prints every transaction. Run: node scripts/demo-testnet.mjs [site] import { NETWORKS } from '../web/lib/config.js'; import { accessKeyNonce, b64encode, delegateHash, encodeSignedDelegate, hex, sha256, TGAS, toYocto, viewCall, } from '../web/lib/near.js'; import { deriveSigner, hkdf, newRoot } from '../web/lib/session.js'; import { fromUrlKey, openDocument, sealDocument, toUrlKey } from '../web/lib/doc.js'; import { invoiceRow, toCsv } from '../web/lib/csv.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 view = (m, a = {}) => viewCall(net.rpc, net.contract, m, a); const tx = (h) => `${net.explorer}/txns/${h}`; const usd = (u) => formatAmount(u); let failures = 0; function check(what, ok, detail = '') { console.log(` ${ok ? 'PASS' : 'FAIL'} ${what}${detail ? ` (${detail})` : ''}`); if (!ok) failures++; } const step = (s) => console.log(`\n${s}`); async function post(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 signedDelegate(signer, method, args, deposit, tgas) { const ak = await accessKeyNonce(net.rpc, signer.account, signer.publicKeyString); const d = { senderId: signer.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: signer.publicKey, }; return b64encode(encodeSignedDelegate(d, await signer.sign(await delegateHash(d)))); } async function gasless(signer, method, args, { deposit = 0n, tgas = 30n } = {}) { const r = await post('/api/relay', { signedDelegate: await signedDelegate(signer, method, args, deposit, tgas) }); if (r.status !== 200 || !r.body.ok) throw new Error(`${method}: ${r.body.error || r.status}`); console.log(` tx ${method}: ${tx(r.body.txHash)}`); return r.body; } const root = newRoot(); const merchant = await deriveSigner(root); const taxJar = await deriveSigner(root, 'jar/tax'); const docKey = await hkdf(root, 'documents'); step(`1. A new merchant with no wallet and no NEAR: ${merchant.account.slice(0, 12)}…`); const made = await post('/api/onboard', { publicKey: merchant.publicKeyString }); check('relayer created the account', made.status === 200 && made.body.created === true, made.body.error); await gasless(merchant, 'register', {}, { deposit: toYocto('0.01') }); check('merchant is registered on-chain', !!(await view('get_merchant', { account: merchant.account }))); step('2. Set-asides: 20% tax jar, 10% to a subcontractor'); const designer = net.testPayer; await gasless(merchant, 'set_shares', { shares: [ { to: taxJar.account, bps: 2000, label: 'Tax' }, { to: designer, bps: 1000, label: 'Designer' }, ], }, { deposit: toYocto('0.004') }); step('3. Issue an invoice for 100 USDC. Client details stay encrypted.'); const doc = { v: 1, from: { name: 'Demo Studio' }, to: { name: 'Acme Ltd' }, items: [{ d: 'Logo design', q: '1', p: '100' }], taxBps: 0, ccy: 'EUR', fx: 0.92 }; const sealed = await sealDocument(doc, docKey); const created = await gasless(merchant, 'create_invoice', { amount: '100000000', due_at_ms: String(Date.now() + 7 * 86400000), doc_hash: sealed.hash, }, { deposit: toYocto('0.006') }); const id = created.events.find((e) => e.event === 'invoice_created').data.id; const up = await post('/api/doc', { id, ct: sealed.ct, wrap: sealed.wrap }); check('server accepted the document because its hash matches the chain', up.status === 200, up.body.error); const forged = await sealDocument({ ...doc, to: { name: 'Mallory' } }, docKey); const bad = await post('/api/doc', { id: `${id.split('-')[0]}-999999`, ct: forged.ct, wrap: forged.wrap }); check('server refuses a document for an invoice that does not exist', bad.status === 404); const link = `${SITE}/test/i/${id}#${toUrlKey(sealed.contentKey)}`; console.log(` pay link: ${link}`); const stored = await (await fetchRetry(`${SITE}/api/doc?net=testnet&id=${id}`)).json(); const inv0 = await view('get_invoice', { id }); const opened = await openDocument(stored.ct, fromUrlKey(toUrlKey(sealed.contentKey)), inv0.doc_hash); check('client opens the document with the link key', opened.to.name === 'Acme Ltd'); check('the stored document is ciphertext only', !Buffer.from(stored.ct, 'base64').toString('latin1').includes('Acme')); check('invoice starts as not paid', inv0.status === 'open', inv0.status); step('4. Client pays 40, then 60. The payment itself closes the invoice.'); const pay = async (amount) => { const r = await post('/api/test/pay', { id, amount, refundTo: 'base:USDC:0x1111111111111111111111111111111111111111' }); if (r.status !== 200) throw new Error(r.body.error); console.log(` tx payment: ${tx(r.body.txHash)}`); return r.body; }; await pay('40000000'); const part = await view('get_invoice', { id }); check('part payment recorded', part.status === 'partial' && part.due_now === '60000000', `${usd(part.due_now)} still due`); const paid = await pay('60000000'); const inv1 = await view('get_invoice', { id }); check('invoice is paid, with no server deciding', inv1.status === 'paid', inv1.status); const ev = paid.events.find((e) => e.event === 'invoice_payment'); check('closed and split in the same transaction', ev?.data.closed === true && ev.data.set_asides.length === 2); const shareOf = (to) => ev?.data.set_asides.find((x) => x.to === to)?.amount; check('that transaction split the closing 60: 12 to the tax jar, 6 to the subcontractor', shareOf(taxJar.account) === '12000000' && shareOf(designer) === '6000000', `tax ${usd(shareOf(taxJar.account) ?? 0)}, subcontractor ${usd(shareOf(designer) ?? 0)}`); step('5. Money is already in the right places'); const bal = async (a) => BigInt(await view('balance_of', { account: a })); const tax = await bal(taxJar.account); const mine = await bal(merchant.account); check('tax jar holds 20', tax === 20_000_000n, usd(tax)); check('merchant holds 70', mine === 70_000_000n, usd(mine)); check('per-invoice set-aside recorded', inv1.set_aside === '30000000', usd(inv1.set_aside)); step('6. The contract can always pay what it owes'); const totals = await view('get_totals'); const held = BigInt(await viewCall(net.rpc, net.usdc, 'ft_balance_of', { account_id: net.contract })); check('token balance covers every liability', held >= BigInt(totals.liabilities), `holds ${usd(held)}, owes ${usd(totals.liabilities)}`); step('7. Overpayment, then a refund back to the client'); await pay('5000000'); const over = await view('get_invoice', { id }); check('extra money is flagged, not split', over.excess === '5000000' && over.set_aside === '30000000', usd(over.excess)); await gasless(merchant, 'refund', { id, amount: '5000000', to: { Near: { account: net.testPayer } } }, { deposit: 1n, tgas: 140n }); const after = await view('get_invoice', { id }); check('refund recorded', after.refunded === '5000000' && after.excess === '0'); step('8. The tax jar releases to the merchant, with gas still sponsored'); const jarUp = await post('/api/onboard', { publicKey: taxJar.publicKeyString }); check('jar account created', jarUp.status === 200, jarUp.body.error); await gasless(taxJar, 'send', { to: merchant.account, amount: '5000000' }, { deposit: 1n }); check('jar moved 5 to the merchant', (await bal(merchant.account)) === 75_000_000n); step('9. The relayer refuses to be drained'); const refused = async (what, method, args, deposit, tgas) => { const r = await post('/api/relay', { signedDelegate: await signedDelegate(merchant, method, args, deposit, tgas) }); check(`refused: ${what}`, r.status === 400 && !r.body.ok, r.body.error); }; await refused('a method outside the list', 'ft_on_transfer', {}, 0n, 30n); await refused('a large attached deposit', 'create_invoice', {}, toYocto('1'), 30n); await refused('excess gas', 'withdraw', {}, 1n, 200n); const junk = await post('/api/relay', { signedDelegate: b64encode(crypto.getRandomValues(new Uint8Array(300))) }); check('refused: random bytes', junk.status === 400, junk.body.error); step('10. CSV for the accountant'); const csv = toCsv([invoiceRow(after, doc, '')]); console.log(csv.replace('', '').split('\r\n').slice(0, 2).map((l) => ` ${l}`).join('\n')); check('row shows the client and totals', csv.includes('Acme Ltd') && csv.includes('105.00')); const fingerprint = hex(await sha256(new TextEncoder().encode(csv))).slice(0, 12); console.log(` csv fingerprint ${fingerprint}`); console.log(failures ? `\n${failures} check(s) FAILED` : '\nAll checks passed. Every step above is a real testnet transaction.'); process.exit(failures ? 1 : 0);