import test from 'node:test';import assert from 'node:assert/strict';import * as ed from '../web/vendor/ed25519.js';import { delegateHash, encodeSignedDelegate, hex, TGAS, toYocto } from '../web/lib/near.js';import { METHODS, Refused, checkSharesDeposit, validate, worstCost } from '../worker/src/policy.js';const seed = new Uint8Array(32).fill(9);const pub = await ed.getPublicKeyAsync(seed);const contract = 'dulypay.testnet';const ctx = { contract, latestHeight: 1000n };const args = (o = {}) => new TextEncoder().encode(JSON.stringify(o));const invoiceArgs = () => ({ amount: '1000000', due_at_ms: String(Date.now() + 86400000), doc_hash: 'a'.repeat(64) });async function signed(over = {}, actionOver = {}) { const d = { senderId: hex(pub), receiverId: contract, nonce: 2n, maxBlockHeight: 1100n, publicKey: pub, actions: [{ type: 'FunctionCall', method: 'create_invoice', args: args(invoiceArgs()), gas: 30n * TGAS, deposit: toYocto('0.006'), ...actionOver }], ...over, }; return encodeSignedDelegate(d, await ed.signAsync(await delegateHash(d), seed));}const refused = (p, re) => assert.rejects(p, (e) => e instanceof Refused && re.test(e.message));test('a valid sponsored call passes', async () => { const r = await validate(await signed(), ctx); assert.equal(r.method, 'create_invoice');});test('calls to any other contract are refused', async () => { await refused(validate(await signed({ receiverId: 'usdc.testnet' }), ctx), /Only calls to the Duly contract/); await refused(validate(await signed({ receiverId: 'relayer-duly.testnet' }), ctx), /Only calls to the Duly contract/);});test('methods outside the list are refused, including object prototype names', async () => { for (const method of ['ft_transfer', 'ft_on_transfer', 'on_transfer', 'agent_sweep', 'constructor', '__proto__', 'hasOwnProperty', 'toString', '']) { await refused(validate(await signed({}, { method }), ctx), /not sponsored/); }});test('the relayer cannot be made to attach more NEAR than the method needs', async () => { await refused(validate(await signed({}, { deposit: toYocto('5') }), ctx), /Deposit outside/); await refused(validate(await signed({}, { deposit: toYocto('0.0061') }), ctx), /Deposit outside/); await refused(validate(await signed({}, { method: 'withdraw', gas: 140n * TGAS, deposit: 2n }), ctx), /Deposit outside/); await refused(validate(await signed({}, { method: 'cancel_invoice', deposit: 1n }), ctx), /Deposit outside/); await refused(validate(await signed({}, { method: 'set_shares', deposit: toYocto('0.009') }), ctx), /Deposit outside/); assert.ok(await validate(await signed({}, { method: 'set_shares', deposit: 0n, args: args({ shares: [] }) }), ctx));});test('gas is capped per method', async () => { await refused(validate(await signed({}, { gas: 31n * TGAS }), ctx), /Gas outside/); await refused(validate(await signed({}, { gas: 0n }), ctx), /Gas outside/); await refused(validate(await signed({}, { gas: TGAS }), ctx), /Gas outside/); await refused(validate(await signed({}, { method: 'withdraw', gas: 151n * TGAS, deposit: 1n }), ctx), /Gas outside/);});test('expired and long-lived requests are refused', async () => { await refused(validate(await signed({ maxBlockHeight: 1000n }), ctx), /expired/); await refused(validate(await signed({ maxBlockHeight: 1201n }), ctx), /too long/);});test('a tampered call fails signature verification', async () => { const bytes = await signed(); const i = bytes.length - 100; bytes[i] ^= 1; await assert.rejects(validate(bytes, ctx), Refused);});test('a signature from another key is refused', async () => { const other = new Uint8Array(32).fill(3); const d = { senderId: hex(pub), receiverId: contract, nonce: 2n, maxBlockHeight: 1100n, publicKey: pub, actions: [{ type: 'FunctionCall', method: 'cancel_invoice', args: args({ id: '1-1' }), gas: 30n * TGAS, deposit: 0n }], }; const bytes = encodeSignedDelegate(d, await ed.signAsync(await delegateHash(d), other)); await refused(validate(bytes, ctx), /Signature does not verify/);});test('an implicit account must match its key', async () => { await refused(validate(await signed({ senderId: 'a'.repeat(64) }), ctx), /does not match/);});test('arguments must be a JSON object', async () => { await refused(validate(await signed({}, { args: new TextEncoder().encode('not json') }), ctx), /must be JSON/); await refused(validate(await signed({}, { args: new TextEncoder().encode('[1]') }), ctx), /JSON object/); await refused(validate(await signed({}, { args: Uint8Array.of(0xff, 0xfe) }), ctx), /must be JSON/);});test('garbage bytes are refused, not crashed on', async () => { await refused(validate(new Uint8Array(10), ctx), /Malformed/); await refused(validate(crypto.getRandomValues(new Uint8Array(500)), ctx), /Malformed|Too long|Truncated/);});const bad = (name, method, a, re, over = {}) => test(`refused before paying: ${name}`, async () => { const rule = METHODS[method]; await refused(validate(await signed({}, { method, args: args(a), gas: over.gas ?? 30n * TGAS, deposit: over.deposit ?? rule.deposit }), ctx), re);});const day = 86400000;const good = () => invoiceArgs();bad('an invoice that cannot be created', 'create_invoice', {}, /amount out of range/);bad('an invoice below the minimum', 'create_invoice', { ...good(), amount: '9999' }, /amount out of range/);bad('an invoice above the maximum', 'create_invoice', { ...good(), amount: '10000000001' }, /amount out of range/);bad('a due date in the past', 'create_invoice', { ...good(), due_at_ms: '1000' }, /too soon/);bad('a bad document hash', 'create_invoice', { ...good(), doc_hash: 'XYZ' }, /document hash/);bad('a discount over 50%', 'create_invoice', { ...good(), discount_bps: 5001 }, /Discount out of range/);bad('a discount window over the limit', 'create_invoice', { ...good(), discount_bps: 100, discount_until_ms: String(Date.now() + 31 * day) }, /window too long/);bad('an unknown argument', 'create_invoice', { ...good(), admin: true }, /Unexpected argument/);bad('register with arguments', 'register', { x: 1 }, /Unexpected argument/);bad('too many set-asides', 'set_shares', { shares: [1, 2, 3, 4, 5] }, /Too many/, { deposit: 0n });bad('a set-aside to yourself', 'set_shares', { shares: [{ to: hex(pub), bps: 1, label: 'A' }] }, /account/, { deposit: 0n });bad('duplicate set-asides', 'set_shares', { shares: [{ to: 'a.near', bps: 1, label: 'A' }, { to: 'a.near', bps: 1, label: 'B' }] }, /account/, { deposit: 0n });bad('set-asides over 100%', 'set_shares', { shares: [{ to: 'a.near', bps: 6000, label: 'A' }, { to: 'b.near', bps: 4001, label: 'B' }] }, /more than 100/, { deposit: 0n });bad('a set-aside name with markup', 'set_shares', { shares: [{ to: 'a.near', bps: 1, label: '<b>' }] }, /name/, { deposit: 0n });bad('a dust withdrawal', 'withdraw', { amount: '1', to: { Near: { account: 'a.near' } } }, /smallest withdrawal/, { gas: 140n * TGAS });bad('a withdrawal with no destination', 'withdraw', { amount: '1000000', to: {} }, /Bad destination/, { gas: 140n * TGAS });bad('an Intents payout with too little gas', 'withdraw', { amount: '1000000', to: { Intents: { deposit_address: 'a.near' } } }, /Gas outside/, { gas: 100n * TGAS });bad('a bad invoice id', 'cancel_invoice', { id: '../../etc' }, /invoice id/);bad('an offer window already over', 'offer_discount', { id: '1-1', bps: 100, until_ms: '1000' }, /offer window/);bad('a bad agent account', 'set_agent', { agent: 'NOT VALID', max_discount_bps: 0, sweep_to: null, sweep_keep: '0' }, /agent account/);test('live invoices are capped below the contract limit', async () => { const big = args({ ...invoiceArgs(), amount: '25000001' }); await refused(validate(await signed({}, { args: big }), { ...ctx, maxInvoice: 25_000_000n }), /limited to 25 USDC/); assert.ok(await validate(await signed({}, { args: big }), ctx));});test('set_shares pays exactly for new payees and nothing else', () => { const a = { shares: [{ to: 'old.near', bps: 1, label: 'A' }, { to: 'new.near', bps: 1, label: 'B' }] }; checkSharesDeposit(a, toYocto('0.002'), [{ to: 'old.near' }]); assert.throws(() => checkSharesDeposit(a, toYocto('0.004'), [{ to: 'old.near' }]), Refused); assert.throws(() => checkSharesDeposit(a, 0n, [{ to: 'old.near' }]), Refused);});test('money-moving methods have their own budget', () => { for (const m of ['withdraw', 'refund', 'send']) assert.equal(METHODS[m].pool, 'money'); for (const m of ['register', 'create_invoice', 'set_shares']) assert.equal(METHODS[m].pool, undefined);});test('worst case cost of every method is small and bounded', () => { for (const [name, rule] of Object.entries(METHODS)) { const cost = worstCost(rule, rule.gas); assert.ok(cost < toYocto('0.03'), name); assert.ok(rule.deposit <= toYocto('0.01'), name); }});