duly / scripts/issue-invoice.mjs ·
raw #!/usr/bin/env node// Operator helper: issue one invoice as a merchant, gasless through the site's relayer,// exactly as the web app does (same signer derivation, same sealed document, same relay).// Dry run by default: builds and prints the public arguments and the document hash.// The merchant root is read from the file given and never printed.//// issue-invoice.mjs <net> <rootfile> <units> <YYYY-MM-DD due> [--to NAME] [--item TEXT] [--note TEXT] [--pending FILE] [--send]// issue-invoice.mjs <net> <rootfile> --upload <id> --pending FILE re-upload the sealed document of an issued invoice//// The due time is the end of that day in UTC (23:59:59Z), as the web app sets it.// --pending keeps the sealed document (ciphertext and wrapped key only) so a failed// upload can be repeated without issuing again. If the relay call errors or times out,// the script reads the merchant's invoice count from the chain and stops: never re-send blind.import { chmodSync, existsSync, lstatSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';import { NETWORKS } from '../web/lib/config.js';import { accessKeyNonce, b58decode, b64encode, delegateHash, encodeSignedDelegate, TGAS, toYocto, viewCall } from '../web/lib/near.js';import { deriveSigner, hkdf } from '../web/lib/session.js';import { sealDocument } from '../web/lib/doc.js';const SITE = process.env.DULY_SITE || 'https://duly.swiftbotlab.workers.dev';const DEPOSIT = toYocto('0.006'); // pinned by the relayer for create_invoice (worker/src/policy.js)const argv = process.argv.slice(2);const flag = (name) => { const i = argv.indexOf(name); if (i < 0) return undefined; const v = argv[i + 1]; if (v === undefined || v.startsWith('--')) throw new Error(`${name} needs a value`); argv.splice(i, 2); return v;};const send = argv.includes('--send');if (send) argv.splice(argv.indexOf('--send'), 1);const toName = flag('--to') ?? 'Mainnet public sample';const itemText = flag('--item') ?? 'Pay card test';const noteText = flag('--note') ?? '';const pendingFile = flag('--pending');const uploadId = flag('--upload');const [netName, rootFile, unitsArg, dueArg] = argv;const net = NETWORKS[netName];if (!net) throw new Error('network? (mainnet or testnet)');function readRoot(file) { if (!file) throw new Error('root file?'); if (lstatSync(file).isSymbolicLink()) throw new Error('Refusing a symlinked root file'); const real = realpathSync(file); if ((lstatSync(real).mode & 0o077) !== 0) throw new Error('Root file must not be readable by others (chmod 600)'); const text = readFileSync(real, 'utf8').trim(); if (!/^[1-9A-HJ-NP-Za-km-z]{30,60}$/.test(text)) throw new Error('Root file is not a base58 root'); return b58decode(text);}const post = async (path, body, timeoutMs = 90000) => { const r = await fetch(`${SITE}${path}?net=${netName}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs), }); return { status: r.status, body: await r.json().catch(() => ({})) };};const root = readRoot(rootFile);const merchant = await deriveSigner(root);const getMerchant = () => viewCall(net.rpc, net.contract, 'get_merchant', { account: merchant.account });if (uploadId) { if (!/^\d{1,10}-\d{1,10}$/.test(uploadId)) throw new Error('Bad invoice id'); if (!pendingFile || !existsSync(pendingFile)) throw new Error('--pending FILE with the sealed document is required'); const sealed = JSON.parse(readFileSync(pendingFile, 'utf8')); const inv = await viewCall(net.rpc, net.contract, 'get_invoice', { id: uploadId }); if (!inv || inv.doc_hash !== sealed.hash) throw new Error('That sealed document is not the one this invoice committed to'); const up = await post('/api/doc', { id: uploadId, ct: sealed.ct, wrap: sealed.wrap }); console.log('doc upload', up.status, up.body.error || 'ok'); process.exit(up.status === 200 ? 0 : 1);}if (!/^\d{1,12}$/.test(unitsArg || '')) throw new Error('units? (USDC base units, 6 decimals)');const units = BigInt(unitsArg);if (units < 10_000n || units > 25_000_000n) throw new Error('Amount outside what the live relayer sponsors (0.01 to 25 USDC)');if (!/^\d{4}-\d{2}-\d{2}$/.test(dueArg || '')) throw new Error('due date? (YYYY-MM-DD)');const dueMs = Date.parse(`${dueArg}T23:59:59Z`);if (!(dueMs > Date.now() + 86400000)) throw new Error('Due date must be at least a day ahead');for (const [k, v, max] of [['--to', toName, 80], ['--item', itemText, 120], ['--note', noteText, 400]]) { if (v.length > max || /[\u0000-\u001f]/.test(v)) throw new Error(`${k} too long or has control characters`);}const price = (Number(units) / 1e6).toFixed(2);if (BigInt(Math.round(Number(price) * 1e6)) !== units) throw new Error('Use a whole number of cents');// Same document shape as the web app's New invoice form (web/merchant.js readDoc).const doc = { v: 1, from: { name: 'Swift Bot Labs', email: '' }, to: { name: toName, email: '' }, items: [{ d: itemText, q: '1', p: price }], taxBps: 0, note: noteText, ccy: 'USD', taxAmount: '0.00',};const sealed = await sealDocument(doc, await hkdf(root, 'documents'));const args = { amount: units.toString(), due_at_ms: String(dueMs), doc_hash: sealed.hash };const before = await getMerchant();if (!before) throw new Error('This root has no registered merchant on that network');console.log('merchant', merchant.account, 'invoices so far', before.invoices, 'next id', `${before.mid}-${before.invoices + 1}`);console.log('document', JSON.stringify(doc));console.log('create_invoice', JSON.stringify(args), 'deposit 0.006 NEAR (paid by the relayer)');console.log('due', new Date(dueMs).toISOString());if (!send) { console.log('DRY RUN: nothing sent. Add --send to issue.'); process.exit(0);}if (pendingFile) { if (existsSync(pendingFile)) throw new Error('--pending file exists already; move it away first'); writeFileSync(pendingFile, JSON.stringify({ ct: sealed.ct, wrap: sealed.wrap, hash: sealed.hash }), { mode: 0o600 }); chmodSync(pendingFile, 0o600);}const ak = await accessKeyNonce(net.rpc, merchant.account, merchant.publicKeyString);if (!ak) throw new Error('The merchant key is not on the account');const d = { senderId: merchant.account, receiverId: net.contract, actions: [{ type: 'FunctionCall', method: 'create_invoice', args: new TextEncoder().encode(JSON.stringify(args)), gas: 30n * TGAS, deposit: DEPOSIT }], nonce: ak.nonce + 1n, maxBlockHeight: ak.blockHeight + 120n, publicKey: merchant.publicKey,};const signedDelegate = b64encode(encodeSignedDelegate(d, await merchant.sign(await delegateHash(d))));let r;try { r = await post('/api/relay', { signedDelegate });} catch (e) { console.log('relay call failed:', e.name);}console.log('relay', r?.status ?? 'no response', r?.body?.ok ? 'ok' : r?.body?.error || '', r?.body?.txHash || '');const id = r?.body?.events?.find((e) => e.event === 'invoice_created')?.data?.id;if (!r?.body?.ok || !id) { // Never retry blind: read what the chain now says. await new Promise((res) => setTimeout(res, 5000)); const after = await getMerchant().catch(() => null); console.log('CHECK THE CHAIN: invoices before', before.invoices, 'now', after?.invoices ?? 'unreadable'); process.exit(1);}console.log('invoice', id, 'tx', r.body.txHash);const up = await post('/api/doc', { id, ct: sealed.ct, wrap: sealed.wrap });console.log('doc upload', up.status, up.body.error || 'ok');process.exit(up.status === 200 ? 0 : 1);