duly / worker/src/policy.js · raw
// 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;}