// 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 };}