// What the relayer agrees to pay for. Everything here is pure so it can be tested// without a network. The relayer signs a transaction only after these checks pass.import { decodeSignedDelegate, delegateHash, hex, TGAS, toYocto } from '../../web/lib/near.js';export const YOCTO_PER_GAS = 100_000_000n; // mainnet minimum gas priceconst ONE_YOCTO = 1n;/** * The full list of calls the relayer sponsors, all on our own contract. * The relayer also pays any deposit attached inside a delegate action, so the * deposit is pinned per method: this table is what stops a signed call from * spending relayer money beyond the storage it legitimately costs. */export const METHODS = { register: { gas: 30n * TGAS, deposit: toYocto('0.01'), exact: true, perDay: 2 }, set_shares: { gas: 30n * TGAS, deposit: toYocto('0.008'), exact: false, perDay: 10 }, create_invoice: { gas: 30n * TGAS, deposit: toYocto('0.006'), exact: true, perDay: 30 }, cancel_invoice: { gas: 30n * TGAS, deposit: 0n, exact: true, perDay: 30 }, offer_discount: { gas: 30n * TGAS, deposit: 0n, exact: true, perDay: 30 }, set_agent: { gas: 30n * TGAS, deposit: ONE_YOCTO, exact: true, perDay: 10 }, // Moving money draws on its own budget, so nobody can lock merchants out of their // balance by spending the sponsored budget on other calls. withdraw: { gas: 150n * TGAS, deposit: ONE_YOCTO, exact: true, perDay: 20, pool: 'money' }, refund: { gas: 150n * TGAS, deposit: ONE_YOCTO, exact: true, perDay: 20, pool: 'money' }, send: { gas: 30n * TGAS, deposit: ONE_YOCTO, exact: true, perDay: 20, pool: 'money' },};/** * NEAR returns a failed delegate's deposit to the sender, not the relayer. Calls must * therefore not be able to fail cheaply on purpose: gas has a floor, and arguments * are checked here with the contract's own rules before the relayer pays for them. */const MIN_GAS = 20n * TGAS;const MIN_NEAR_PAYOUT_GAS = 45n * TGAS;const MIN_INTENTS_PAYOUT_GAS = 130n * TGAS;export const MIN_INVOICE = 10_000n;export const MAX_INVOICE = 10_000_000_000n;/** The contract is unaudited, so sponsored live invoices stay small. */export const LIVE_MAX_INVOICE = 25_000_000n;const MIN_PAYOUT = 10_000n;const MAX_OFFER_MS = 30 * 24 * 60 * 60 * 1000;const PAYEE_ROW = toYocto('0.002');const ACCOUNT = /^(?=.{2,64}$)(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/;const ID = /^\d{1,10}-\d{1,10}$/;const LABEL = /^[A-Za-z0-9 -]{1,24}$/;/** Longest a signed delegate may stay valid, in blocks (about two minutes). */export const MAX_DELEGATE_BLOCKS = 200n;export class Refused extends Error { constructor(message, status = 400) { super(message); this.status = status; }}const units = (v) => (typeof v === 'string' && /^\d{1,30}$/.test(v) ? BigInt(v) : null);const isAccount = (v) => typeof v === 'string' && ACCOUNT.test(v);function need(ok, message) { if (!ok) throw new Refused(message);}function onlyKeys(args, allowed) { for (const k of Object.keys(args)) need(allowed.includes(k), `Unexpected argument: ${k}`);}/** Returns the payout route, so its gas floor can be checked. */function checkDest(to) { need(to !== null && typeof to === 'object' && Object.keys(to).length === 1, 'Bad destination'); if ('Near' in to) { need(isAccount(to.Near?.account), 'Bad destination'); return 'near'; } need(isAccount(to.Intents?.deposit_address), 'Bad destination'); return 'intents';}function checkPayoutGas(route, gas) { need(gas >= (route === 'intents' ? MIN_INTENTS_PAYOUT_GAS : MIN_NEAR_PAYOUT_GAS), 'Gas outside the allowed range');}/** The contract's own rules, applied before any money is spent on the call. */export function checkArgs(method, args, action, { now, minDueMs, sender, maxInvoice = MAX_INVOICE }) { need(action.gas >= MIN_GAS, 'Gas outside the allowed range'); switch (method) { case 'register': onlyKeys(args, []); break; case 'create_invoice': { onlyKeys(args, ['amount', 'due_at_ms', 'doc_hash', 'discount_bps', 'discount_until_ms']); const amount = units(args.amount); need(amount !== null && amount >= MIN_INVOICE, 'Invoice amount out of range'); need(amount <= maxInvoice, maxInvoice < MAX_INVOICE ? 'Live invoices are limited to 25 USDC for now' : 'Invoice amount out of range'); const due = units(args.due_at_ms); need(due !== null && due >= BigInt(now + minDueMs), 'Due date is too soon'); need(typeof args.doc_hash === 'string' && /^[0-9a-f]{64}$/.test(args.doc_hash), 'Bad document hash'); const bps = args.discount_bps ?? 0; need(Number.isInteger(bps) && bps >= 0 && bps <= 5000, 'Discount out of range'); if (bps > 0) { const until = units(args.discount_until_ms); need(until !== null && until <= BigInt(now + MAX_OFFER_MS - 60_000), 'Discount window too long'); } break; } case 'set_shares': { onlyKeys(args, ['shares']); const { shares } = args; need(Array.isArray(shares) && shares.length <= 4, 'Too many set-asides'); let total = 0; const seen = new Set(); for (const s of shares) { need(s !== null && typeof s === 'object', 'Bad set-aside'); onlyKeys(s, ['to', 'bps', 'label']); need(isAccount(s.to) && s.to !== sender && !seen.has(s.to), 'Bad set-aside account'); need(Number.isInteger(s.bps) && s.bps > 0 && s.bps <= 10000, 'Bad set-aside percentage'); need(typeof s.label === 'string' && LABEL.test(s.label), 'Bad set-aside name'); seen.add(s.to); total += s.bps; } need(total <= 10000, 'Set-asides add up to more than 100%'); break; } case 'cancel_invoice': onlyKeys(args, ['id']); need(typeof args.id === 'string' && ID.test(args.id), 'Bad invoice id'); break; case 'offer_discount': { onlyKeys(args, ['id', 'bps', 'until_ms']); need(typeof args.id === 'string' && ID.test(args.id), 'Bad invoice id'); need(Number.isInteger(args.bps) && args.bps >= 0 && args.bps <= 5000, 'Discount out of range'); const until = units(args.until_ms); need(until !== null && until > BigInt(now + 60_000) && until <= BigInt(now + MAX_OFFER_MS - 60_000), 'Bad offer window'); break; } case 'set_agent': onlyKeys(args, ['agent', 'max_discount_bps', 'sweep_to', 'sweep_keep']); need(args.agent === null || isAccount(args.agent), 'Bad agent account'); need(args.sweep_to === null || isAccount(args.sweep_to), 'Bad sweep account'); need(Number.isInteger(args.max_discount_bps) && args.max_discount_bps >= 0 && args.max_discount_bps <= 5000, 'Discount out of range'); need(units(args.sweep_keep) !== null, 'Bad floor'); break; case 'withdraw': { onlyKeys(args, ['amount', 'to']); const amount = units(args.amount); need(amount !== null && amount >= MIN_PAYOUT, 'The smallest withdrawal is 0.01 USDC'); checkPayoutGas(checkDest(args.to), action.gas); break; } case 'refund': { onlyKeys(args, ['id', 'amount', 'to']); need(typeof args.id === 'string' && ID.test(args.id), 'Bad invoice id'); const amount = units(args.amount); need(amount !== null && amount > 0n, 'Nothing to refund'); checkPayoutGas(checkDest(args.to), action.gas); break; } case 'send': { onlyKeys(args, ['to', 'amount']); need(isAccount(args.to), 'Bad account'); const amount = units(args.amount); need(amount !== null && amount > 0n, 'Nothing to send'); break; } default: throw new Refused('This method is not sponsored'); }}/** A set_shares deposit pays for new payees only, and must be exact. */export function checkSharesDeposit(args, deposit, currentShares) { const known = new Set(currentShares.map((s) => s.to)); const fresh = BigInt(args.shares.filter((s) => !known.has(s.to)).length); need(deposit === fresh * PAYEE_ROW, 'Deposit outside the allowed range');}export function checkShape(delegate, { contract, latestHeight }) { if (delegate.receiverId !== contract) throw new Refused('Only calls to the Duly contract are sponsored'); const action = delegate.actions[0]; const rule = Object.hasOwn(METHODS, action.method) ? METHODS[action.method] : null; if (!rule) throw new Refused('This method is not sponsored'); if (action.gas <= 0n || action.gas > rule.gas) throw new Refused('Gas outside the allowed range'); if (rule.exact ? action.deposit !== rule.deposit : action.deposit > rule.deposit) { throw new Refused('Deposit outside the allowed range'); } if (delegate.maxBlockHeight <= latestHeight) throw new Refused('This request has expired'); if (delegate.maxBlockHeight > latestHeight + MAX_DELEGATE_BLOCKS) { throw new Refused('This request stays valid for too long'); } let args; try { args = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(action.args)); } catch { throw new Refused('Arguments must be JSON'); } if (args === null || typeof args !== 'object' || Array.isArray(args)) { throw new Refused('Arguments must be a JSON object'); } if (/^[0-9a-f]{64}$/.test(delegate.senderId) && delegate.senderId !== hex(delegate.publicKey)) { throw new Refused('Key does not match the account'); } return { rule, method: action.method, args };}export async function verifySignature(delegate, signature) { const key = await crypto.subtle.importKey('raw', delegate.publicKey, { name: 'Ed25519' }, false, ['verify']); const ok = await crypto.subtle.verify({ name: 'Ed25519' }, key, signature, await delegateHash(delegate)); if (!ok) throw new Refused('Signature does not verify');}/** Decode and verify the signature. No network, so junk costs almost nothing. */export async function authenticate(bytes) { let decoded; try { decoded = decodeSignedDelegate(bytes); } catch (e) { throw new Refused(`Malformed request: ${e.message}`); } await verifySignature(decoded.delegate, decoded.signature); return decoded;}/** Everything that can be decided without the network. Throws Refused otherwise. */export async function validate(bytes, ctx) { const decoded = await authenticate(bytes); const shape = checkShape(decoded.delegate, ctx); checkArgs(shape.method, shape.args, decoded.delegate.actions[0], { now: Date.now(), minDueMs: 5000, sender: decoded.delegate.senderId, ...ctx }); return { ...decoded, ...shape };}/** Most this call can cost the relayer: the deposit plus all gas at the base price. */export function worstCost(rule, gas) { return rule.deposit + gas * YOCTO_PER_GAS;}