// One Durable Object per network. It is the only place the relayer key is used, // and because it runs one request at a time between awaits, the nonce counter and // the spending counters cannot race. import { accessKeyNonce, accountExists, b58decode, b58encode, b64decode, b64encode, failureText, hex, outcomeEvents, outcomeFailure, publicKeyString, rpc, sha256, TGAS, viewCall, } from '../../web/lib/near.js'; import { NETWORKS, ONE_CLICK } from '../../web/lib/config.js'; import { LIVE_MAX_INVOICE, MAX_INVOICE, Refused, authenticate, checkArgs, checkShape, checkSharesDeposit, worstCost } from './policy.js'; import { loadKey, signTransaction } from './keys.js'; import { runAgent, sendTelegram } from './agent.js'; const DAY_MS = 86_400_000; const MAX_DOC_B64 = 44_000; // about 32 KB of ciphertext const MAX_WRAP_B64 = 200; const ONBOARD_COST = 500_000_000_000_000_000_000n; // 0.0005 NEAR, gas and account creation const MILLI = 10n ** 21n; // Counted at worst case. Signing up takes 26, so this leaves room for four invoices on day one. const SENDER_DAILY_MILLINEAR = 70; const OPEN_WATCHES_PER_INVOICE = 5; const B58_KEY = /^(ed25519:)?[1-9A-HJ-NP-Za-km-z]{32,50}$/; function day(now = Date.now()) { return Math.floor(now / DAY_MS); } function tokensBurnt(outcome) { const all = [outcome.transaction_outcome, ...(outcome.receipts_outcome || [])]; return all.reduce((sum, o) => sum + BigInt(o?.outcome?.tokens_burnt || 0), 0n); } function json(data, status = 200) { return new Response(JSON.stringify(data, (_, v) => (typeof v === 'bigint' ? v.toString() : v)), { status, headers: { 'content-type': 'application/json' }, }); } export class Ledger { constructor(state, env) { this.state = state; this.env = env; this.sql = state.storage.sql; this.sql.exec(` CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT NOT NULL); CREATE TABLE IF NOT EXISTS limits (k TEXT PRIMARY KEY, day INTEGER NOT NULL, n INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS docs (id TEXT PRIMARY KEY, ct TEXT NOT NULL, wrap TEXT NOT NULL, at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS watches ( deposit TEXT PRIMARY KEY, invoice TEXT NOT NULL, merchant TEXT, status TEXT NOT NULL, origin_tx TEXT, near_tx TEXT, at INTEGER NOT NULL, done INTEGER NOT NULL DEFAULT 0); CREATE INDEX IF NOT EXISTS watches_open ON watches (done, at); CREATE INDEX IF NOT EXISTS watches_invoice ON watches (invoice); CREATE TABLE IF NOT EXISTS tg (merchant TEXT PRIMARY KEY, chat INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS tg_codes (code TEXT PRIMARY KEY, merchant TEXT NOT NULL, at INTEGER NOT NULL); CREATE TABLE IF NOT EXISTS agent_rules (merchant TEXT PRIMARY KEY, days INTEGER NOT NULL, bps INTEGER NOT NULL, hours INTEGER NOT NULL, doc_terms TEXT); CREATE TABLE IF NOT EXISTS agent_log (k TEXT PRIMARY KEY, at INTEGER NOT NULL); `); for (const change of ['ALTER TABLE watches ADD COLUMN polled_at INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE agent_rules ADD COLUMN last_at INTEGER NOT NULL DEFAULT 0']) { try { this.sql.exec(change); } catch { // the column is already there } } try { this.sql.exec('ALTER TABLE watches ADD COLUMN settle_fixed INTEGER NOT NULL DEFAULT 0'); // Runs once: re-read settled payments stored before the settlement hash was fixed. this.sql.exec("UPDATE watches SET done = 0 WHERE status = 'SUCCESS' AND deposit NOT LIKE 'test-%'"); } catch { // already migrated } } get network() { return NETWORKS[this._network]; } async fetch(request) { const url = new URL(request.url); this._network = url.searchParams.get('net') === 'testnet' ? 'testnet' : 'mainnet'; const ip = request.headers.get('x-duly-ip') || 'unknown'; try { switch (url.pathname) { case '/relay': return await this.relay(await request.json(), ip); case '/onboard': return await this.onboard(await request.json(), ip); case '/doc/put': return await this.docPut(await request.json(), ip); case '/doc/get': return this.docGet(url.searchParams.get('id')); case '/watch': return await this.watch(await request.json(), ip); case '/watch/get': return this.watchGet(url.searchParams.get('id')); case '/test/pay': return await this.testPay(await request.json(), ip); case '/tg/code': return await this.tgCode(await request.json(), ip); case '/tg/update': return await this.tgUpdate(await request.json()); case '/tg/status': return json({ linked: this.tgChat(url.searchParams.get('merchant') || '') !== null }); case '/agent/rules': return await this.agentRules(await request.json(), ip); case '/stats': return this.stats(); case '/tick': return await this.tick(); default: return json({ error: 'Not found' }, 404); } } catch (e) { if (e instanceof Refused) return json({ error: e.message }, e.status); if (e instanceof SyntaxError) return json({ error: 'Bad JSON' }, 400); console.error('ledger', url.pathname, e?.message); return json({ error: 'Something went wrong. Check your invoices before trying again.' }, 502); } } // ------------------------------------------------------- counters /** Test mode spends free testnet NEAR, so repeat demo runs get much more room. */ get scale() { return this._network === 'testnet' ? 10 : 1; } get budgetScale() { return this._network === 'testnet' ? 50 : 1; } /** Count one use of `key` today; refuse past `max`. */ hit(key, max, message, by = 1) { max *= this.scale; const today = day(); const row = this.sql.exec('SELECT day, n FROM limits WHERE k = ?', key).toArray()[0]; const n = row && row.day === today ? row.n : 0; if (n + by > max) throw new Refused(message, 429); this.sql.exec( 'INSERT INTO limits (k, day, n) VALUES (?, ?, ?) ON CONFLICT (k) DO UPDATE SET day = excluded.day, n = excluded.n', key, today, n + by, ); } /** Reserve yoctoNEAR from today's budget for this pool, or refuse. */ spend(yocto, pool = 'general') { const key = `spend:${pool}:${day()}`; const row = this.sql.exec('SELECT v FROM meta WHERE k = ?', key).toArray()[0]; const used = row ? BigInt(row.v) : 0n; const base = pool === 'money' ? this.env.MONEY_BUDGET_YOCTO : this.env.DAILY_BUDGET_YOCTO; if (used + yocto > BigInt(base) * BigInt(this.budgetScale)) { throw new Refused('The daily gas budget is used up. Please try again tomorrow.', 429); } this.setMeta(key, (used + yocto).toString()); } /** Give back the part of a reservation a call did not use. */ release(yocto, pool = 'general') { const key = `spend:${pool}:${day()}`; const row = this.sql.exec('SELECT v FROM meta WHERE k = ?', key).toArray()[0]; if (!row || yocto <= 0n) return; const used = BigInt(row.v); this.setMeta(key, (used > yocto ? used - yocto : 0n).toString()); } setMeta(key, value) { this.sql.exec('INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v', key, value); } nextNonce(onchain) { const row = this.sql.exec("SELECT v FROM meta WHERE k = 'nonce'").toArray()[0]; const next = (row ? [BigInt(row.v), onchain].reduce((a, b) => (a > b ? a : b)) : onchain) + 1n; this.sql.exec( "INSERT INTO meta (k, v) VALUES ('nonce', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v", next.toString(), ); return next; } // -------------------------------------------------------- relaying signerSecret() { return this._network === 'testnet' ? this.env.RELAYER_KEY_TESTNET : this.env.RELAYER_KEY_MAINNET; } signerAccount() { return this._network === 'testnet' ? this.env.RELAYER_ACCOUNT_TESTNET : this.env.RELAYER_ACCOUNT_MAINNET; } /** Sign and send one transaction from the relayer account. */ async submit(receiverId, actions) { const net = this.network; const signer = await loadKey(this.signerSecret()); const accountId = this.signerAccount(); const pub = 'ed25519:' + b58encode(signer.publicKey); for (let attempt = 0; ; attempt++) { const ak = await accessKeyNonce(net.rpc, accountId, pub); if (!ak) throw new Error('Relayer key not found'); const signed = await signTransaction(signer, { signerId: accountId, publicKey: signer.publicKey, nonce: this.nextNonce(ak.nonce), receiverId, blockHash: b58decode(ak.blockHash), actions, }); try { return await rpc(net.rpc, 'send_tx', { signed_tx_base64: b64encode(signed), wait_until: 'EXECUTED_OPTIMISTIC' }, { timeoutMs: 40000 }); } catch (e) { const text = `${e.message} ${JSON.stringify(e.detail || {})}`; if (attempt < 2 && /InvalidNonce|Expired|TIMEOUT_ERROR|timed out/i.test(text)) continue; throw e; } } } async relay(body, ip) { const b64 = body?.signedDelegate; if (typeof b64 !== 'string' || b64.length > 6000) throw new Refused('Bad request'); let bytes; try { bytes = b64decode(b64); } catch { throw new Refused('Bad request'); } // The signature is checked first and offline, so junk costs no RPC quota. const { delegate, signature } = await authenticate(bytes); const net = this.network; const block = await rpc(net.rpc, 'block', { finality: 'final' }); const { rule, method, args } = checkShape(delegate, { contract: net.contract, latestHeight: BigInt(block.header.height) }); const action = delegate.actions[0]; const sender = delegate.senderId; const live = this._network !== 'testnet'; checkArgs(method, args, action, { now: Date.now(), minDueMs: live ? 60000 : 5000, sender, maxInvoice: live ? LIVE_MAX_INVOICE : MAX_INVOICE }); // Nothing is counted against a sender until their key is shown to be theirs, // so a stranger cannot use up someone else's daily limits. const ak = await accessKeyNonce(net.rpc, sender, publicKeyString(delegate.publicKey)).catch(() => null); if (!ak || ak.permission !== 'FullAccess') throw new Refused('This key does not belong to that account'); if (delegate.nonce <= ak.nonce) throw new Refused('This request has already been used'); if (method === 'register') { const existing = await viewCall(net.rpc, net.contract, 'get_merchant', { account: sender }); if (existing) throw new Refused('Already registered'); } else if (method === 'withdraw' || method === 'send') { const bal = await viewCall(net.rpc, net.contract, 'balance_of', { account: sender }); if (BigInt(args.amount) > BigInt(bal)) throw new Refused('That is more than your balance'); } else { const m = await viewCall(net.rpc, net.contract, 'get_merchant', { account: sender }); if (!m) throw new Refused('Register first'); if (method === 'set_shares') checkSharesDeposit(args, action.deposit, m.shares); } const cost = worstCost(rule, action.gas); const pool = rule.pool || 'general'; this.hit(`ip:${ip}:${method}`, rule.perDay * 3, 'Too many requests from this network today'); this.hit(`who:${sender}:${method}`, rule.perDay, 'Daily limit reached for this action'); if (pool === 'general') { this.hit(`gas:${sender}`, SENDER_DAILY_MILLINEAR, 'Daily limit reached for this account', Number(cost / MILLI) + 1); } this.spend(cost, pool); const outcome = await this.submit(sender, [{ type: 'Delegate', delegate, signature }]); const failure = outcomeFailure(outcome); const actual = tokensBurnt(outcome) + action.deposit; this.release(cost - (actual < cost ? actual : cost), pool); return json({ txHash: outcome.transaction?.hash, ok: !failure, error: failure ? failureText(failure) : undefined, events: outcomeEvents(outcome), }); } /** * Bring a new implicit account into existence so its key can sign. NEAR lets a * small account hold zero balance, so one yoctoNEAR is enough to create it. */ async onboard(body, ip) { const given = body?.publicKey; if (typeof given !== 'string' || !B58_KEY.test(given)) throw new Refused('Bad public key'); const bytes = b58decode(given.replace(/^ed25519:/, '')); if (bytes.length !== 32) throw new Refused('Bad public key'); const account = hex(bytes); const net = this.network; if (await accountExists(net.rpc, account)) return json({ account, created: false }); this.hit(`onboard:ip:${ip}`, 5, 'Too many new accounts from this network today'); this.hit('onboard:all', Number(this.env.ONBOARD_DAILY || 40), 'New sign-ups are paused until tomorrow'); this.spend(ONBOARD_COST); const outcome = await this.submit(account, [{ type: 'Transfer', deposit: 1n }]); const failure = outcomeFailure(outcome); if (failure) throw new Refused(`Could not create the account: ${failureText(failure)}`, 502); return json({ account, created: true, txHash: outcome.transaction?.hash }); } // ------------------------------------------------------- documents /** Accept ciphertext only for an invoice whose on-chain hash matches it. */ async docPut(body, ip) { const { id, ct, wrap } = body || {}; if (typeof id !== 'string' || !/^\d{1,10}-\d{1,10}$/.test(id)) throw new Refused('Bad invoice id'); if (typeof ct !== 'string' || ct.length === 0 || ct.length > MAX_DOC_B64) throw new Refused('Document too large'); if (typeof wrap !== 'string' || wrap.length > MAX_WRAP_B64) throw new Refused('Bad key envelope'); this.hit(`doc:ip:${ip}`, 200, 'Too many uploads today'); const inv = await viewCall(this.network.rpc, this.network.contract, 'get_invoice', { id }); if (!inv) throw new Refused('No such invoice', 404); let bytes; try { bytes = b64decode(ct); } catch { throw new Refused('Bad document'); } if (hex(await sha256(bytes)) !== inv.doc_hash) { throw new Refused('This document does not match the invoice on-chain', 409); } this.sql.exec( 'INSERT INTO docs (id, ct, wrap, at) VALUES (?, ?, ?, ?) ON CONFLICT (id) DO NOTHING', id, ct, wrap, Date.now(), ); return json({ ok: true }); } docGet(id) { if (!id || !/^\d{1,10}-\d{1,10}$/.test(id)) throw new Refused('Bad invoice id'); const row = this.sql.exec('SELECT ct, wrap FROM docs WHERE id = ?', id).toArray()[0]; return row ? json(row) : json({ error: 'Not found' }, 404); } // --------------------------------------------------------- watching /** The pay page reports a 1Click deposit address so we can notify the merchant. */ async watch(body, ip) { const { id, deposit } = body || {}; if (typeof id !== 'string' || !/^\d{1,10}-\d{1,10}$/.test(id)) throw new Refused('Bad invoice id'); if (typeof deposit !== 'string' || deposit.length > 100 || !/^[A-Za-z0-9:._-]+$/.test(deposit)) { throw new Refused('Bad deposit address'); } this.hit(`watch:ip:${ip}`, 100, 'Too many requests today'); const net = this.network; const inv = await viewCall(net.rpc, net.contract, 'get_invoice', { id }); if (!inv) throw new Refused('No such invoice', 404); const open = this.sql.exec('SELECT COUNT(*) AS n FROM watches WHERE invoice = ? AND done = 0', id).toArray()[0].n; if (open >= OPEN_WATCHES_PER_INVOICE) throw new Refused('Too many open payments on this invoice', 429); // A deposit address only counts if 1Click itself says it pays this invoice. const res = await fetch(`${ONE_CLICK}/status?depositAddress=${encodeURIComponent(deposit)}`, { signal: AbortSignal.timeout(10000) }).catch(() => null); const status = res?.ok ? await res.json().catch(() => null) : null; const req = status?.quoteResponse?.quoteRequest; const msg = req?.customRecipientMsg || ''; if (req?.recipient !== net.contract || !(msg === id || msg.startsWith(`${id}|`))) { throw new Refused('That payment is not for this invoice'); } this.sql.exec( 'INSERT INTO watches (deposit, invoice, merchant, status, at) VALUES (?, ?, ?, ?, ?) ON CONFLICT (deposit) DO NOTHING', deposit, id, inv.merchant, 'PENDING_DEPOSIT', Date.now(), ); return json({ ok: true }); } watchGet(id) { if (!id || !/^\d{1,10}-\d{1,10}$/.test(id)) throw new Refused('Bad invoice id'); return json({ payments: this.sql .exec('SELECT deposit, status, origin_tx, near_tx, at FROM watches WHERE invoice = ? ORDER BY at DESC LIMIT 10', id) .toArray(), }); } // --------------------------------------------------------- test mode /** Test mode only: pay an invoice with worthless test tokens, as a client would. */ async testPay(body, ip) { if (this._network !== 'testnet') throw new Refused('Test payments only exist in test mode', 403); const { id, amount, refundTo } = body || {}; if (typeof id !== 'string' || !/^\d{1,10}-\d{1,10}$/.test(id)) throw new Refused('Bad invoice id'); const net = this.network; const inv = await viewCall(net.rpc, net.contract, 'get_invoice', { id }); if (!inv) throw new Refused('No such invoice', 404); const units = BigInt(amount || inv.due_now); if (units <= 0n || units > 1_000_000_000n) throw new Refused('Test payments go up to 1,000 tUSDC'); this.hit(`testpay:ip:${ip}`, 60, 'Too many test payments today'); const msg = /^[A-Za-z0-9:._-]{1,80}$/.test(refundTo || '') ? `${id}|${refundTo}` : id; const payer = this.signerAccount(); await this.submit(net.usdc, [ fnCall('mint', { account_id: payer, amount: units.toString() }, 30n, 0n), ]); const outcome = await this.submit(net.usdc, [ fnCall('ft_transfer_call', { receiver_id: net.contract, amount: units.toString(), msg }, 100n, 1n), ]); const failure = outcomeFailure(outcome); if (failure) throw new Refused(`Test payment failed: ${failureText(failure)}`, 502); const hash = outcome.transaction?.hash; this.sql.exec( 'INSERT INTO watches (deposit, invoice, merchant, status, near_tx, at, done) VALUES (?, ?, ?, ?, ?, ?, 1)', `test-${hash}`, id, inv.merchant, 'SUCCESS', hash, Date.now(), ); const chat = this.tgChat(inv.merchant); if (chat) await this.notifyPayment({ invoice: id, merchant: inv.merchant }, 'SUCCESS'); return json({ ok: true, txHash: hash, events: outcomeEvents(outcome) }); } // --------------------------------------------------------- telegram tgChat(merchant) { const row = this.sql.exec('SELECT chat FROM tg WHERE merchant = ?', merchant).toArray()[0]; return row ? row.chat : null; } /** Merchant proves key ownership, gets a one-time deep link code. */ async proveOwner(body, purpose) { const { account, publicKey, signature, at } = body || {}; if (typeof account !== 'string' || account.length > 64) throw new Refused('Bad account'); if (!(Math.abs(Date.now() - Number(at)) <= 5 * 60 * 1000)) throw new Refused('Request expired'); if (typeof publicKey !== 'string' || !B58_KEY.test(publicKey)) throw new Refused('Bad signature'); if (typeof signature !== 'string' || signature.length > 100) throw new Refused('Bad signature'); let pub, sig; try { pub = b58decode(publicKey.replace(/^ed25519:/, '')); sig = b64decode(signature); } catch { throw new Refused('Bad signature'); } if (pub.length !== 32 || sig.length !== 64) throw new Refused('Bad signature'); if (/^[0-9a-f]{64}$/.test(account)) { if (account !== hex(pub)) throw new Refused('Key does not match the account'); } else { const ak = await accessKeyNonce(this.network.rpc, account, 'ed25519:' + publicKey.replace(/^ed25519:/, '')); if (!ak || ak.permission !== 'FullAccess') throw new Refused('Key does not match the account'); } const key = await crypto.subtle.importKey('raw', pub, { name: 'Ed25519' }, false, ['verify']); const message = new TextEncoder().encode(`duly:${purpose}:${account}:${at}`); if (!(await crypto.subtle.verify({ name: 'Ed25519' }, key, sig, message))) { throw new Refused('Signature does not verify'); } return account; } async tgCode(body, ip) { const account = await this.proveOwner(body, 'telegram'); this.hit(`tg:ip:${ip}`, 20, 'Too many requests today'); const code = hex(crypto.getRandomValues(new Uint8Array(12))); this.sql.exec('INSERT INTO tg_codes (code, merchant, at) VALUES (?, ?, ?)', code, account, Date.now()); return json({ code: `${this._network === 'testnet' ? 't' : 'm'}${code}`, bot: this.env.TELEGRAM_BOT_NAME || '' }); } async tgUpdate(update) { const msg = update?.message; const text = typeof msg?.text === 'string' ? msg.text : ''; const chat = msg?.chat?.id; if (!chat || msg?.chat?.type !== 'private') return json({ ok: true }); const m = text.match(/^\/start\s+[tm]([0-9a-f]{24})$/); if (m) { const row = this.sql.exec('SELECT merchant, at FROM tg_codes WHERE code = ?', m[1]).toArray()[0]; this.sql.exec('DELETE FROM tg_codes WHERE code = ?', m[1]); if (row && Date.now() - row.at < 15 * 60 * 1000) { this.sql.exec( 'INSERT INTO tg (merchant, chat) VALUES (?, ?) ON CONFLICT (merchant) DO UPDATE SET chat = excluded.chat', row.merchant, chat, ); await sendTelegram(this.env, chat, 'Connected. You will hear from me when a client pays, and when an invoice goes overdue.'); } else { await sendTelegram(this.env, chat, 'That link has expired. Open Duly, then Settings, and connect Telegram again.'); } } else if (/^\/stop\b/.test(text)) { const gone = this.sql.exec('DELETE FROM tg WHERE chat = ?', chat).rowsWritten; if (gone > 0) await sendTelegram(this.env, chat, 'Disconnected. You will not get more messages.'); } else { await sendTelegram(this.env, chat, 'I only send payment notices. Connect from Duly, Settings. Send /stop to disconnect.'); } return json({ ok: true }); } /** The merchant's rules for the chasing agent. On-chain limits still apply. */ async agentRules(body, ip) { const account = await this.proveOwner(body, 'agent'); const days = Math.floor(Number(body.days)); const bps = Math.floor(Number(body.bps)); const hours = Math.floor(Number(body.hours)); this.hit(`agent:ip:${ip}`, 30, 'Too many requests today'); if (body.enabled === false) { this.sql.exec('DELETE FROM agent_rules WHERE merchant = ?', account); return json({ ok: true, enabled: false }); } // Test mode allows 0 days so the agent can be watched working without waiting a day. const minDays = this._network === 'testnet' ? 0 : 1; if (!(days >= minDays && days <= 60 && bps >= 0 && bps <= 2000 && hours >= 24 && hours <= 240)) { throw new Refused('Rules out of range'); } // Rules only exist for merchants who appointed the agent on-chain. const appointed = this._network === 'testnet' ? this.env.AGENT_ACCOUNT_TESTNET : this.env.AGENT_ACCOUNT_MAINNET; const m = await viewCall(this.network.rpc, this.network.contract, 'get_merchant', { account }); if (m?.agent !== appointed) throw new Refused('Turn collections on first'); this.sql.exec( 'INSERT INTO agent_rules (merchant, days, bps, hours) VALUES (?, ?, ?, ?) ON CONFLICT (merchant) DO UPDATE SET days = excluded.days, bps = excluded.bps, hours = excluded.hours', account, days, bps, hours, ); return json({ ok: true, enabled: true }); } stats() { const read = (pool) => this.sql.exec('SELECT v FROM meta WHERE k = ?', `spend:${pool}:${day()}`).toArray()[0]?.v || '0'; return json({ spentTodayYocto: { general: read('general'), money: read('money') } }); } // ------------------------------------------------------------ cron async tick() { const net = this.network; const open = this.sql .exec('SELECT deposit, invoice, merchant, status FROM watches WHERE done = 0 ORDER BY polled_at LIMIT 20') .toArray(); let polled = 0; for (const w of open) { this.sql.exec('UPDATE watches SET polled_at = ? WHERE deposit = ?', Date.now(), w.deposit); const res = await fetch(`${ONE_CLICK}/status?depositAddress=${encodeURIComponent(w.deposit)}`, { signal: AbortSignal.timeout(10000), }).catch(() => null); if (!res || !res.ok) continue; polled++; const s = await res.json().catch(() => null); if (!s?.status) continue; const d = s.swapDetails || {}; const origin = d.originChainTxHashes?.[0]?.hash || null; // The last NEAR hash is often the refund of the buffer. The delivery to our // contract is the destination hash that is a NEAR hash, not an 0x one. const near = (d.destinationChainTxHashes || []).map((h) => h.hash).find((h) => h && !h.startsWith('0x')) || null; const done = ['SUCCESS', 'REFUNDED', 'FAILED'].includes(s.status) ? 1 : 0; this.sql.exec( 'UPDATE watches SET status = ?, origin_tx = ?, near_tx = ?, done = ? WHERE deposit = ?', s.status, origin, near, done, w.deposit, ); if (done && s.status !== w.status) await this.notifyPayment(w, s.status); } const stale = Date.now() - 3 * DAY_MS; this.sql.exec('UPDATE watches SET done = 1 WHERE done = 0 AND at < ?', stale); const agent = await runAgent(this, net).catch((e) => ({ error: e.message })); return json({ polled, agent }); } async notifyPayment(w, status) { const chat = this.tgChat(w.merchant); if (!chat) return; const inv = await viewCall(this.network.rpc, this.network.contract, 'get_invoice', { id: w.invoice }).catch(() => null); const usd = inv ? `$${(Number(inv.paid) / 1e6).toFixed(2)}` : 'A payment'; const text = status === 'SUCCESS' ? `${usd} received on invoice ${inv?.number ?? w.invoice}. Status: ${inv?.status ?? 'paid'}.` : `A payment on invoice ${w.invoice} did not go through and was returned to your client (${status.toLowerCase()}).`; await sendTelegram(this.env, chat, text); } } export function fnCall(method, args, tgas, deposit) { return { type: 'FunctionCall', method, args: new TextEncoder().encode(JSON.stringify(args)), gas: tgas * TGAS, deposit, }; }