duly / web/lib/quote.js · raw
// 1Click quotes, called directly from the browser so no Duly server sits in the// money path. Every quote is checked before a client is shown an address: the// signature must be 1Click's, and the signed fields must send exactly this// invoice's amount to our contract with this invoice's reference.import { b58decode, b58encode, sha256 } from './near.js';import { ONE_CLICK, ONE_CLICK_QUOTE_KEY, USDC_ASSET } from './config.js';import { verifyAsync } from '../vendor/ed25519.js';const te = new TextEncoder();/** Sorted-key JSON, the same form 1Click signs. Undefined values are skipped. */export function stableStringify(v) {  if (v === null || typeof v !== 'object') return JSON.stringify(v);  if (Array.isArray(v)) return `[${v.map((x) => (x === undefined ? 'null' : stableStringify(x))).join(',')}]`;  const parts = [];  for (const k of Object.keys(v).sort()) {    if (v[k] !== undefined) parts.push(`${JSON.stringify(k)}:${stableStringify(v[k])}`);  }  return `{${parts.join(',')}}`;}function signedFields(res, dry) {  const r = res.quoteRequest;  const q = res.quote;  const req = {    dry: r.dry,    swapType: r.swapType,    slippageTolerance: r.slippageTolerance,    originAsset: r.originAsset,    depositType: r.depositType,    destinationAsset: r.destinationAsset,    amount: r.amount,    refundTo: r.refundTo,    refundType: r.refundType,    recipient: r.recipient,    recipientType: r.recipientType,    deadline: r.deadline,    quoteWaitingTimeMs: r.quoteWaitingTimeMs || undefined,    referral: r.referral || undefined,    virtualChainRecipient: r.virtualChainRecipient || undefined,    virtualChainRefundRecipient: r.virtualChainRefundRecipient || undefined,    customRecipientMsg: r.customRecipientMsg || undefined,  };  const base = {    amountIn: q.amountIn,    amountInFormatted: q.amountInFormatted,    amountInUsd: q.amountInUsd,    minAmountIn: q.minAmountIn,    amountOut: q.amountOut,    amountOutFormatted: q.amountOutFormatted,    amountOutUsd: q.amountOutUsd,    minAmountOut: q.minAmountOut,  };  if (dry) return { ...req, ...base };  return {    ...req,    ...base,    depositAddress: q.depositAddress || undefined,    depositMemo: q.depositMemo || undefined,    deadline: q.deadline || undefined,    timeWhenInactive: q.timeWhenInactive || undefined,    timeEstimate: q.timeEstimate || undefined,    refundFee: q.refundFee || undefined,    withdrawFee: q.withdrawFee || undefined,  };}/** * `dry` comes from the caller. A dry signature does not cover a deposit address, so * reading the mode from the response would let anyone staple their own address onto * a genuine dry quote. */export async function quoteSignatureValid(res, { dry, key = ONE_CLICK_QUOTE_KEY }) {  if (typeof dry !== 'boolean') throw new Error('Quote mode is required');  try {    const data = stableStringify({ ...signedFields(res, dry), timestamp: res.timestamp });    const hash = b58encode(await sha256(te.encode(data)));    const sig = b58decode(String(res.signature).replace(/^ed25519:/, ''));    return await verifyAsync(sig, te.encode(hash), b58decode(key));  } catch {    return false;  }}const MAX_QUOTE_AGE_MS = 5 * 60 * 1000;function commonChecks(res, expected) {  const r = res.quoteRequest;  const age = Math.abs(Date.now() - Date.parse(res.timestamp));  return [    [r.dry === expected.dry, 'quote mode'],    [expected.dry || !!res.quote.depositAddress, 'deposit address'],    [age < MAX_QUOTE_AGE_MS, 'quote time'],    [expected.dry || Date.parse(res.quote.deadline) > Date.now(), 'quote deadline'],  ];}/** Throws unless this quote pays exactly what the invoice asks, to the contract. */export async function assertPaymentQuote(res, expected) {  if (!(await quoteSignatureValid(res, expected))) throw new Error('This quote is not signed by NEAR Intents');  const r = res.quoteRequest;  const checks = [    ...commonChecks(res, expected),    [r.originAsset === expected.originAsset, 'source token'],    [r.depositType === 'ORIGIN_CHAIN', 'deposit type'],    [r.refundType === 'ORIGIN_CHAIN', 'refund type'],    [r.recipient === expected.contract, 'recipient'],    [r.recipientType === 'DESTINATION_CHAIN', 'recipient type'],    [r.destinationAsset === USDC_ASSET, 'settlement token'],    [r.customRecipientMsg === expected.msg, 'invoice reference'],    [r.swapType === 'EXACT_OUTPUT', 'quote type'],    [r.amount === expected.amount, 'amount'],    [BigInt(res.quote.amountOut) >= BigInt(expected.amount), 'amount delivered'],    [r.refundTo === expected.refundTo, 'refund address'],  ];  for (const [ok, what] of checks) if (!ok) throw new Error(`Quote does not match this invoice (${what})`);}export function paymentMessage(invoiceId, refundTag) {  return refundTag ? `${invoiceId}|${refundTag}` : invoiceId;}export async function postQuote(body, fetchImpl = fetch) {  const res = await fetchImpl(`${ONE_CLICK}/quote`, {    method: 'POST',    headers: { 'content-type': 'application/json' },    body: JSON.stringify(body),    signal: AbortSignal.timeout(30000),  });  const data = await res.json().catch(() => ({}));  if (!res.ok) throw new Error(plainQuoteError(data.message));  return data;}export function plainQuoteError(m = '') {  const min = m.match(/minimum swap amount is (\$[\d.,]+)/i);  if (min) return `This token needs at least ${min[1]} right now. Try a smaller route such as USDC on Base.`;  if (/too low/i.test(m)) return 'This amount is too small for that route. Try a larger amount or another network.';  if (/refundTo is not valid/i.test(m)) return 'That address does not look right for this network.';  if (/recipient is not valid/i.test(m)) return 'That address does not look right for this network.';  if (/no route|not supported|No quotes/i.test(m)) return 'That token is not available right now. Try another one.';  return 'We could not get a price just now. Please try again.';}/** Client pays any token; the contract receives exactly `amount` USDC. */export function paymentQuoteBody({ dry, originAsset, amount, refundTo, contract, msg, deadlineMs, stable = false }) {  return {    dry,    swapType: 'EXACT_OUTPUT',    // The buffer comes back minus a flat fee, so stablecoins keep it small.    slippageTolerance: stable ? 20 : 100,    originAsset,    depositType: 'ORIGIN_CHAIN',    destinationAsset: USDC_ASSET,    amount,    refundTo,    refundType: 'ORIGIN_CHAIN',    recipient: contract,    recipientType: 'DESTINATION_CHAIN',    customRecipientMsg: msg,    deadline: new Date(deadlineMs).toISOString(),    referral: 'duly',  };}/** USDC leaves the contract and arrives as `destinationAsset` at `recipient`. */export function payoutQuoteBody({ dry, destinationAsset, amount, recipient, refundAccount, deadlineMs }) {  return {    dry,    swapType: 'EXACT_INPUT',    slippageTolerance: 100,    originAsset: USDC_ASSET,    depositType: 'INTENTS',    destinationAsset,    amount,    refundTo: refundAccount,    refundType: 'INTENTS',    recipient,    recipientType: 'DESTINATION_CHAIN',    deadline: new Date(deadlineMs).toISOString(),    referral: 'duly',  };}export async function assertPayoutQuote(res, expected) {  if (!(await quoteSignatureValid(res, expected))) throw new Error('This quote is not signed by NEAR Intents');  const r = res.quoteRequest;  const checks = [    ...commonChecks(res, expected),    [r.swapType === 'EXACT_INPUT', 'quote type'],    [r.recipientType === 'DESTINATION_CHAIN', 'recipient type'],    [r.refundType === 'INTENTS', 'refund type'],    [r.originAsset === USDC_ASSET, 'source token'],    [r.destinationAsset === expected.destinationAsset, 'destination token'],    [r.recipient === expected.recipient, 'destination address'],    [r.amount === expected.amount, 'amount'],    [r.depositType === 'INTENTS', 'deposit type'],    [r.refundTo === expected.refundAccount, 'refund account'],  ];  for (const [ok, what] of checks) if (!ok) throw new Error(`Quote does not match this payout (${what})`);}