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