#!/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 [--to NAME] [--item TEXT] [--note TEXT] [--pending FILE] [--send] // issue-invoice.mjs --upload --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);