duly / scripts/demo-testnet.mjs ·
raw #!/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);