duly / worker/src/ledger.js · raw
// 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 ciphertextconst MAX_WRAP_B64 = 200;const ONBOARD_COST = 500_000_000_000_000_000_000n; // 0.0005 NEAR, gas and account creationconst 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,  };}