duly / worker/src/agent.js · raw
// The collections agent. It runs on a timer and works only from public chain state.//// What it can do, for merchants who switched it on://   - notice an overdue invoice and write the reminder//   - offer a discount for quick payment, up to the cap the merchant set on-chain//   - sweep idle balance to the one account the merchant named on-chain//// What it cannot do: the contract refuses any discount above the cap, any action// on an invoice that is not overdue, and any sweep to another account. Its key is// a function-call key limited to `offer_discount` and `agent_sweep`, which cannot// attach the one yoctoNEAR that withdraw, refund or set_agent require.import { accessKeyNonce, b58decode, b58encode, failureText, outcomeFailure, rpc, b64encode, viewCall } from '../../web/lib/near.js';import { signTransaction, loadKey } from './keys.js';import { fnCall } from './ledger.js';export const SWEEP_TGAS = 60n;const MIN_SWEEP_UNITS = 1_000_000n; // 1 USDC: never spend gas moving lessconst SWEEP_RETRY_MS = 24 * 3_600_000;const AGENT_CALLS_PER_DAY = 200;const HOUR_MS = 3_600_000;const DAY_MS = 24 * HOUR_MS;export async function sendTelegram(env, chat, text) {  if (!env.TELEGRAM_BOT_TOKEN) return false;  const res = await fetch(`https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}/sendMessage`, {    method: 'POST',    headers: { 'content-type': 'application/json' },    body: JSON.stringify({ chat_id: chat, text, disable_web_page_preview: true }),    signal: AbortSignal.timeout(8000),  }).catch(() => null);  return !!res?.ok;}/** * Pure decision: what should the agent do about this invoice, given the rules? * Returns null, or the offer to make. Unit tested without a network. */export function decide(invoice, rules, now, onchainCapBps) {  if (!['overdue', 'partial'].includes(invoice.status)) return null;  const dueAt = Number(invoice.due_at_ms);  if (now < dueAt + rules.days * DAY_MS) return null;  if (invoice.discount_bps > 0 && Number(invoice.discount_until_ms) > now) return null;  const bps = Math.min(rules.bps, onchainCapBps);  return { bps, until: now + rules.hours * HOUR_MS };}export function reminderText(invoice, offer, payUrl) {  const owed = (Number(invoice.due_now) / 1e6).toFixed(2);  const lines = [`Invoice ${invoice.number} is overdue: $${owed} is outstanding.`];  if (offer?.bps > 0) {    const pct = (offer.bps / 100).toFixed(offer.bps % 100 ? 2 : 0);    const when = new Date(offer.until).toUTCString().slice(5, 22);    lines.push(`I offered ${pct}% off if it is paid by ${when} UTC, within the limit you set.`);  }  lines.push('You can forward this to your client:');  lines.push(`Hello, a quick reminder that this invoice is past its due date. You can pay it here: ${payUrl}`);  return lines.join('\n\n');}async function agentCall(ledger, net, method, args, tgas = 30n) {  const secret = ledger._network === 'testnet' ? ledger.env.AGENT_KEY_TESTNET : ledger.env.AGENT_KEY_MAINNET;  const accountId = ledger._network === 'testnet' ? ledger.env.AGENT_ACCOUNT_TESTNET : ledger.env.AGENT_ACCOUNT_MAINNET;  if (!secret || !accountId) return null;  const signer = await loadKey(secret);  const ak = await accessKeyNonce(net.rpc, accountId, 'ed25519:' + b58encode(signer.publicKey));  if (!ak) return null;  const signed = await signTransaction(signer, {    signerId: accountId,    publicKey: signer.publicKey,    nonce: ak.nonce + 1n,    receiverId: net.contract,    blockHash: b58decode(ak.blockHash),    actions: [fnCall(method, args, tgas, 0n)],  });  const out = await rpc(net.rpc, 'send_tx', { signed_tx_base64: b64encode(signed), wait_until: 'EXECUTED_OPTIMISTIC' }, { timeoutMs: 40000 });  const failure = outcomeFailure(out);  // A failed agent call must be visible: it changes nothing on-chain, so nobody else would notice.  if (failure) console.error('agent call failed', method, failureText(failure));  return { txHash: out.transaction?.hash, failed: !!failure };}/** A sweep costs gas, so it needs a real amount and a destination that can receive it. */async function sweepDue(ledger, net, m, merchant, now) {  if (!m.agent || !m.sweep_to) return false;  if (BigInt(m.balance) - BigInt(m.sweep_keep) < MIN_SWEEP_UNITS) return false;  const failed = ledger.sql.exec('SELECT at FROM agent_log WHERE k = ?', `sweepfail:${merchant}`).toArray()[0];  if (failed && now - failed.at < SWEEP_RETRY_MS) return false;  return !!(await viewCall(net.rpc, net.usdc, 'storage_balance_of', { account_id: m.sweep_to }).catch(() => null));}/** Runs at most once an hour live, and every tick in test mode. Bounded work per run. */export async function runAgent(ledger, net) {  const now = Date.now();  const stamp = ledger.sql.exec("SELECT v FROM meta WHERE k = 'agent_last'").toArray()[0];  if (ledger._network !== 'testnet' && stamp && now - Number(stamp.v) < HOUR_MS) return { skipped: true };  ledger.sql.exec(    "INSERT INTO meta (k, v) VALUES ('agent_last', ?) ON CONFLICT (k) DO UPDATE SET v = excluded.v",    String(now),  );  const rules = ledger.sql.exec('SELECT merchant, days, bps, hours FROM agent_rules ORDER BY last_at LIMIT 25').toArray();  const site = ledger.env.PUBLIC_URL || '';  const prefix = ledger._network === 'testnet' ? '/test' : '';  let actions = 0;  for (const r of rules) {    ledger.sql.exec('UPDATE agent_rules SET last_at = ? WHERE merchant = ?', now, r.merchant);    const m = await viewCall(net.rpc, net.contract, 'get_merchant', { account: r.merchant }).catch(() => undefined);    if (m === null) ledger.sql.exec('DELETE FROM agent_rules WHERE merchant = ?', r.merchant);    if (!m) continue;    const cap = m.agent ? m.agent_max_discount_bps : 0;    const from = Math.max(1, m.invoices - 49);    const list = await viewCall(net.rpc, net.contract, 'list_invoices', { merchant: r.merchant, from_number: from, limit: 50 }).catch(() => []);    for (const inv of list) {      const offer = decide(inv, r, now, cap);      if (!offer) continue;      const key = `${inv.id}:${Math.floor(now / (7 * DAY_MS))}`;      if (ledger.sql.exec('SELECT 1 FROM agent_log WHERE k = ?', key).toArray().length) continue;      ledger.sql.exec('INSERT INTO agent_log (k, at) VALUES (?, ?)', key, now);      let made = offer;      if (offer.bps > 0) {        ledger.hit('agent:calls', AGENT_CALLS_PER_DAY, 'Agent budget used up');        const res = await agentCall(ledger, net, 'offer_discount', { id: inv.id, bps: offer.bps, until_ms: String(offer.until) });        if (!res || res.failed) made = { ...offer, bps: 0 };      }      const chat = ledger.tgChat(r.merchant);      if (chat) await sendTelegram(ledger.env, chat, reminderText(inv, made, `${site}${prefix}/i/${inv.id}`));      actions++;      if (actions >= 10) return { actions };    }    if (await sweepDue(ledger, net, m, r.merchant, now)) {      ledger.hit('agent:calls', AGENT_CALLS_PER_DAY, 'Agent budget used up');      // The contract refuses to pay out with less gas than the transfer and its callback need.      const res = await agentCall(ledger, net, 'agent_sweep', { merchant: r.merchant }, SWEEP_TGAS);      if (res && !res.failed) actions++;      else ledger.sql.exec('INSERT INTO agent_log (k, at) VALUES (?, ?) ON CONFLICT (k) DO UPDATE SET at = excluded.at', `sweepfail:${r.merchant}`, now);    }  }  return { actions };}