duly / test/quote.test.mjs · raw
import test from 'node:test';import assert from 'node:assert/strict';import { readFileSync } from 'node:fs';import * as ed from '../web/vendor/ed25519.js';import { b58encode, sha256 } from '../web/lib/near.js';import { USDC_ASSET } from '../web/lib/config.js';import { assertPaymentQuote, assertPayoutQuote, paymentMessage, paymentQuoteBody, plainQuoteError, quoteSignatureValid, stableStringify } from '../web/lib/quote.js';// A local signer stands in for 1Click so every tamper case has a positive control.const seed = new Uint8Array(32).fill(5);const key = b58encode(await ed.getPublicKeyAsync(seed));const te = new TextEncoder();async function sign(res) {  const r = res.quoteRequest; const q = res.quote;  const fields = { 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: q.deadline, customRecipientMsg: r.customRecipientMsg, amountIn: q.amountIn, amountInFormatted: q.amountInFormatted, amountInUsd: q.amountInUsd,    minAmountIn: q.minAmountIn, amountOut: q.amountOut, amountOutFormatted: q.amountOutFormatted, amountOutUsd: q.amountOutUsd, minAmountOut: q.minAmountOut,    depositAddress: q.depositAddress, timeEstimate: q.timeEstimate, timestamp: res.timestamp };  const hash = b58encode(await sha256(te.encode(stableStringify(fields))));  return { ...res, signature: 'ed25519:' + b58encode(await ed.signAsync(te.encode(hash), seed)) };}const contract = 'dulypay.near';const msg = paymentMessage('1-7', 'base:USDC:0xabc');const soon = () => new Date(Date.now() + 3600000).toISOString();const base = () => ({  timestamp: new Date().toISOString(),  quoteRequest: { dry: false, swapType: 'EXACT_OUTPUT', slippageTolerance: 100, originAsset: 'nep141:x', depositType: 'ORIGIN_CHAIN', destinationAsset: USDC_ASSET,    amount: '1000000', refundTo: '0xabc', refundType: 'ORIGIN_CHAIN', recipient: contract, recipientType: 'DESTINATION_CHAIN', customRecipientMsg: msg, deadline: soon() },  quote: { amountIn: '1010229', amountInFormatted: '1.010229', amountInUsd: '1.01', minAmountIn: '1000126', amountOut: '1000000', amountOutFormatted: '1', amountOutUsd: '1',    minAmountOut: '1000000', depositAddress: '0xdeposit', deadline: soon(), timeEstimate: 35 },});const expected = { dry: false, originAsset: 'nep141:x', key, contract, msg, amount: '1000000', refundTo: '0xabc' };test('stable stringify sorts keys and skips undefined', () => {  assert.equal(stableStringify({ b: 1, a: { d: undefined, c: [1, 'x'] } }), '{"a":{"c":[1,"x"]},"b":1}');});test('a genuine quote for this invoice passes', async () => {  await assertPaymentQuote(await sign(base()), expected);});test('swapping the deposit address after signing is caught', async () => {  const s = await sign(base());  s.quote.depositAddress = '0xattacker';  assert.equal(await quoteSignatureValid(s, { dry: false, key }), false);  await assert.rejects(assertPaymentQuote(s, expected), /not signed/);});test('a quote signed by anyone else is refused', async () => {  const s = await sign(base());  const other = b58encode(await ed.getPublicKeyAsync(new Uint8Array(32).fill(6)));  await assert.rejects(assertPaymentQuote(s, { ...expected, key: other }), /not signed/);});test('a correctly signed quote for the wrong destination is still refused', async () => {  const cases = [    ['recipient', (r) => { r.quoteRequest.recipient = 'attacker.near'; }],    ['settlement token', (r) => { r.quoteRequest.destinationAsset = 'nep141:other'; }],    ['invoice reference', (r) => { r.quoteRequest.customRecipientMsg = '1-8'; }],    ['amount', (r) => { r.quoteRequest.amount = '1'; }],    ['refund address', (r) => { r.quoteRequest.refundTo = '0xattacker'; }],    ['quote type', (r) => { r.quoteRequest.swapType = 'EXACT_INPUT'; }],  ];  for (const [what, change] of cases) {    const r = base(); change(r);    await assert.rejects(assertPaymentQuote(await sign(r), expected), new RegExp(what));  }});async function signDry(r) {  const q = r.quote; const q2 = r.quoteRequest;  const fields = { dry: q2.dry, swapType: q2.swapType, slippageTolerance: q2.slippageTolerance, originAsset: q2.originAsset, depositType: q2.depositType,    destinationAsset: q2.destinationAsset, amount: q2.amount, refundTo: q2.refundTo, refundType: q2.refundType, recipient: q2.recipient, recipientType: q2.recipientType,    deadline: q2.deadline, customRecipientMsg: q2.customRecipientMsg, amountIn: q.amountIn, amountInFormatted: q.amountInFormatted, amountInUsd: q.amountInUsd,    minAmountIn: q.minAmountIn, amountOut: q.amountOut, amountOutFormatted: q.amountOutFormatted, amountOutUsd: q.amountOutUsd, minAmountOut: q.minAmountOut, timestamp: r.timestamp };  const hash = b58encode(await sha256(te.encode(stableStringify(fields))));  return { ...r, signature: 'ed25519:' + b58encode(await ed.signAsync(te.encode(hash), seed)) };}test('an attacker address stapled onto a genuine dry quote is refused', async () => {  const r = base();  r.quoteRequest.dry = true;  const s = await signDry(r);  s.quote.depositAddress = '0xattacker';  // Positive control: the dry signature really is valid, and really does not cover the address.  assert.equal(await quoteSignatureValid(s, { dry: true, key }), true);  await assert.rejects(assertPaymentQuote(s, expected), /not signed/);  await assert.rejects(assertPaymentQuote(s, { ...expected, dry: true }).then(() => { throw new Error('expected mode only'); }), /expected mode only/);});test('a real quote with its address removed is refused', async () => {  const r = base(); delete r.quote.depositAddress;  await assert.rejects(assertPaymentQuote(await sign(r), expected), /deposit address/);});test('a stale quote is refused', async () => {  const r = base(); r.timestamp = new Date(Date.now() - 3600000).toISOString();  await assert.rejects(assertPaymentQuote(await sign(r), expected), /quote time/);  const e = base(); e.quote.deadline = new Date(Date.now() - 1000).toISOString();  await assert.rejects(assertPaymentQuote(await sign(e), expected), /quote deadline/);});test('swapping the source token or refund type is refused', async () => {  for (const [what, change] of [['source token', (r) => { r.quoteRequest.originAsset = 'nep141:y'; }], ['refund type', (r) => { r.quoteRequest.refundType = 'INTENTS'; }], ['deposit type', (r) => { r.quoteRequest.depositType = 'INTENTS'; }]]) {    const r = base(); change(r);    await assert.rejects(assertPaymentQuote(await sign(r), expected), new RegExp(what));  }});test('a short-paying quote is refused', async () => {  const r = base(); r.quote.amountOut = '999999';  await assert.rejects(assertPaymentQuote(await sign(r), expected), /amount delivered/);});test('payout quotes are pinned to the chosen address', async () => {  const r = base();  Object.assign(r.quoteRequest, { originAsset: USDC_ASSET, destinationAsset: 'nep141:base', depositType: 'INTENTS', refundTo: 'me.near', recipient: '0xmine', swapType: 'EXACT_INPUT', customRecipientMsg: undefined });  r.quoteRequest.refundType = 'INTENTS';  const exp = { dry: false, key, destinationAsset: 'nep141:base', recipient: '0xmine', amount: '1000000', refundAccount: 'me.near' };  await assertPayoutQuote(await sign(r), exp);  await assert.rejects(assertPayoutQuote(await sign(r), { ...exp, recipient: '0xother' }), /destination address/);});test('stablecoins use a small buffer, since it comes back minus a flat fee', () => {  const b = { dry: true, originAsset: 'x', amount: '1', refundTo: 'y', contract: 'z', msg: '1-1', deadlineMs: 0 };  assert.equal(paymentQuoteBody({ ...b, stable: true }).slippageTolerance, 20);  assert.equal(paymentQuoteBody(b).slippageTolerance, 100);});test('error messages are in plain language', () => {  assert.match(plainQuoteError('Amount is too low for bridge, try at least 2417'), /too small/);  assert.match(plainQuoteError('refundTo is not valid'), /does not look right/);});test('a route minimum tells the client what to do next', () => {  assert.match(plainQuoteError('Temporary swap limits: minimum swap amount is $100'), /at least \$100/);});// A real dry quote for sample invoice 1-2, recorded from 1Click on 2026-11-04. Set// DULY_LIVE_QUOTE to a fresh response file to check another one.const realQuote = () => JSON.parse(readFileSync(process.env.DULY_LIVE_QUOTE || new URL('./fixtures/1click-dry-quote.json', import.meta.url), 'utf8'));test('the signature check matches a real 1Click response', async () => {  const res = realQuote();  assert.equal(res.quoteRequest.recipient, 'dulypay.near');  assert.match(res.quoteRequest.customRecipientMsg, /^1-2\|/);  assert.ok(await quoteSignatureValid(res, { dry: res.quoteRequest.dry }));});test('a real 1Click response with any signed field changed fails the signature check', async () => {  for (const change of [    (r) => (r.quoteRequest.recipient = 'attacker.near'),    (r) => (r.quoteRequest.customRecipientMsg = '1-3'),    (r) => (r.quote.amountOut = '1'),    (r) => (r.quoteRequest.refundTo = '0x0000000000000000000000000000000000000001'),  ]) {    const res = realQuote();    change(res);    assert.equal(await quoteSignatureValid(res, { dry: true }), false);  }});