// Duly edge API. The Worker never touches client or merchant money: it sponsors gas// for calls to our own contract, stores encrypted invoice documents, and sends notices.import { Ledger } from './ledger.js';import { NETWORKS } from '../../web/lib/config.js';import { rpc, viewCall } from '../../web/lib/near.js';import { MAINNET } from '../../evidence/mainnet.js';import { SECURITY_HEADERS, injectApp, invoiceHtml, landingHtml, pageFor } from './pages.js';export { Ledger };const MAX_BODY = 64 * 1024;const FX_TTL_S = 6 * 60 * 60;function json(data, status = 200, extra = {}) { return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json', 'cache-control': 'no-store', ...extra }, });}function netOf(url) { return url.searchParams.get('net') === 'testnet' ? 'testnet' : 'mainnet';}function ledger(env, net) { return env.LEDGER.get(env.LEDGER.idFromName(net));}/** Rate limits key on the network, because one IPv6 host often owns a whole /64. */function clientKey(ip) { return ip.includes(':') ? `${ip.split(':').slice(0, 4).join(':')}/64` : ip;}async function forward(env, net, path, request, ip, search = '') { const init = { method: request.method, headers: { 'x-duly-ip': clientKey(ip) } }; if (request.method === 'POST' || request.method === 'PUT') { if (Number(request.headers.get('content-length') || 0) > MAX_BODY) return json({ error: 'Too large' }, 413); const text = await request.text(); if (text.length > MAX_BODY) return json({ error: 'Too large' }, 413); init.body = text; init.headers['content-type'] = 'application/json'; } return ledger(env, net).fetch(`https://ledger${path}?net=${net}${search}`, init);}async function fx(ctx) { const key = new Request('https://cache.duly/fx'); const cache = caches.default; const hit = await cache.match(key); if (hit) return hit; const res = await fetch('https://api.frankfurter.dev/v1/latest?base=USD', { signal: AbortSignal.timeout(8000) }).catch(() => null); if (!res?.ok) return json({ error: 'Exchange rates are unavailable right now' }, 502); const data = await res.json(); const out = json({ base: 'USD', date: data.date, rates: data.rates, source: 'European Central Bank via frankfurter.dev' }, 200, { 'cache-control': `public, max-age=${FX_TTL_S}`, }); ctx.waitUntil(cache.put(key, out.clone())); return out;}const RPC_TIMEOUT_MS = 4000;const LIVE_TTL_S = 60;/** Chain totals for the landing page, cached for a minute so page views cost no RPC calls. */async function liveTotals(ctx) { const key = new Request('https://cache.duly/live-totals'); const hit = await caches.default.match(key); if (hit) return hit.json(); const net = NETWORKS.mainnet; const opts = { timeoutMs: RPC_TIMEOUT_MS }; const [totals, held] = await Promise.all([ viewCall(net.rpc, net.contract, 'get_totals', {}, opts), viewCall(net.rpc, net.usdc, 'ft_balance_of', { account_id: net.contract }, opts), ]); const live = { at: Date.now(), invoices: String(totals.invoices), received: String(totals.received), liabilities: String(totals.liabilities), held: String(held) }; ctx.waitUntil(caches.default.put(key, new Response(JSON.stringify(live), { headers: { 'cache-control': `public, max-age=${LIVE_TTL_S}` } }))); return live;}/** One invoice as the chain holds it, plus the settlement records the watcher kept. */async function invoiceFacts(env, page) { const net = NETWORKS[page.test ? 'testnet' : 'mainnet']; const opts = { timeoutMs: RPC_TIMEOUT_MS }; let inv; try { inv = await viewCall(net.rpc, net.contract, 'get_invoice', { id: page.id }, opts); } catch { return { inv: undefined }; } if (!inv) return { inv: null }; const [payments, verified] = await Promise.all([ BigInt(inv.paid) > 0n ? ledger(env, net.id).fetch(`https://ledger/watch/get?net=${net.id}&id=${encodeURIComponent(page.id)}`).then((r) => r.json()).then((j) => j.payments || []).catch(() => []) : [], net.codeHash ? rpc(net.rpc, 'query', { request_type: 'view_account', finality: 'final', account_id: net.contract }, opts).then((a) => a.code_hash === net.codeHash).catch(() => null) : null, ]); return { inv, payments, verified };}/** index.html with the server-rendered page in its app root. Falls back to the plain file. */async function renderPage(request, env, ctx, page) { const shell = await env.ASSETS.fetch(new Request(new URL('/', request.url))); if (!shell.ok) return shell; const html = await shell.text(); let inner; let meta; if (page.kind === 'landing') { const live = await liveTotals(ctx).catch(() => null); inner = landingHtml({ test: page.test, live }); meta = { title: page.test ? 'Duly test mode' : 'Duly', description: `Pay-by-link invoicing settled on NEAR: the payment closes the invoice on-chain and sets tax aside in the same step. Mainnet contract ${MAINNET.contract}.` }; } else { const facts = await invoiceFacts(env, page); inner = invoiceHtml({ id: page.id, test: page.test, ...facts }); const status = facts.inv ? `${facts.inv.status}, ${Number(facts.inv.amount) / 1e6} USDC` : 'not available'; meta = { title: `Duly invoice ${page.id}`, description: `Invoice ${page.id} on contract ${page.test ? 'dulypay.testnet' : MAINNET.contract}: ${status}.` }; } const body = injectApp(html, inner, meta); return new Response(request.method === 'HEAD' ? null : body, { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': page.kind === 'landing' ? 'public, max-age=60' : 'no-cache', ...SECURITY_HEADERS }, });}export default { async fetch(request, env, ctx) { const url = new URL(request.url); const { pathname } = url; if (!pathname.startsWith('/api/')) { const page = (request.method === 'GET' || request.method === 'HEAD') && pageFor(pathname); if (page) { try { return await renderPage(request, env, ctx, page); } catch (e) { console.error('render failed', e?.message); } } return env.ASSETS.fetch(request); } const ip = request.headers.get('cf-connecting-ip') || 'unknown'; const net = netOf(url); const post = request.method === 'POST'; if (post) { const type = request.headers.get('content-type') || ''; if (!type.startsWith('application/json')) return json({ error: 'Send JSON' }, 415); } if (pathname === '/api/relay' && post) return forward(env, net, '/relay', request, ip); if (pathname === '/api/onboard' && post) return forward(env, net, '/onboard', request, ip); if (pathname === '/api/doc' && post) return forward(env, net, '/doc/put', request, ip); if (pathname === '/api/doc' && request.method === 'GET') { return forward(env, net, '/doc/get', request, ip, `&id=${encodeURIComponent(url.searchParams.get('id') || '')}`); } if (pathname === '/api/watch' && post) return forward(env, net, '/watch', request, ip); if (pathname === '/api/watch' && request.method === 'GET') { return forward(env, net, '/watch/get', request, ip, `&id=${encodeURIComponent(url.searchParams.get('id') || '')}`); } if (pathname === '/api/test/pay' && post) return forward(env, 'testnet', '/test/pay', request, ip); if (pathname === '/api/telegram/link' && post) return forward(env, net, '/tg/code', request, ip); if (pathname === '/api/telegram/status' && request.method === 'GET') { return forward(env, net, '/tg/status', request, ip, `&merchant=${encodeURIComponent(url.searchParams.get('merchant') || '')}`); } if (pathname === '/api/agent/rules' && post) return forward(env, net, '/agent/rules', request, ip); if (pathname === '/api/fx' && request.method === 'GET') return fx(ctx); if (pathname === '/api/stats' && request.method === 'GET') return forward(env, net, '/stats', request, ip); if (pathname === '/api/telegram/webhook' && post) { const given = request.headers.get('x-telegram-bot-api-secret-token') || ''; if (!env.TELEGRAM_WEBHOOK_SECRET || given !== env.TELEGRAM_WEBHOOK_SECRET) { return json({ error: 'Forbidden' }, 403); } // One webhook serves both networks: a link's code says which ledger it belongs to. const body = await request.text(); if (body.length > MAX_BODY) return json({ ok: true }); let text = ''; try { text = String(JSON.parse(body)?.message?.text ?? ''); } catch { return json({ ok: true }); } const nets = /^\/stop\b/.test(text) ? ['mainnet', 'testnet'] : [/^\/start\s+t/.test(text) ? 'testnet' : 'mainnet']; for (const n of nets) { await ledger(env, n).fetch(`https://ledger/tg/update?net=${n}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body }); } return json({ ok: true }); } return json({ error: 'Not found' }, 404); }, async scheduled(_event, env, ctx) { for (const net of ['mainnet', 'testnet']) { ctx.waitUntil(ledger(env, net).fetch(`https://ledger/tick?net=${net}`).catch(() => null)); } },};