From fcd023fb4b33404eb62afceba0c0232f8b25f200 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 8 Oct 2026 13:41:24 -0700 Subject: A renewal refused for 'insufficient refresh fee' re-reads the server's fee schedule and re-prices instead of resubmitting the same underpaying set on every sync; if the new fee leaves under 330 sats (or the price didn't change) it fails and frees the coins. Backstop registration re-reads the schedule on the same refusal Co-Authored-By: Claude Opus 5.5 --- tools/ark/refresh-reprice-test.js | 58 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tools/ark/refresh-reprice-test.js (limited to 'tools') diff --git a/tools/ark/refresh-reprice-test.js b/tools/ark/refresh-reprice-test.js new file mode 100644 index 0000000..2936036 --- /dev/null +++ b/tools/ark/refresh-reprice-test.js @@ -0,0 +1,58 @@ +// A renewal priced from a fee schedule the server has since raised (an app +// left open across the change) is re-priced and resubmitted, not refused on +// every sync forever; one the new fee can't cover gives its coins back. +// Run: bun tools/ark/refresh-reprice-test.js +import assert from 'node:assert/strict'; +import { mock } from 'bun:test'; + +const refresh = await import('../../src/ark/refresh.js'); +const proto = await import('../../src/ark/proto.js'); +const send = await import('../../src/ark/send.js'); +mock.module('../../src/ark/send.js', () => ({ ...send, registerVtxoTransactions: async () => {} })); +let fees = { baseFeeSat: 300, ppmExpiryTable: [{ thresholdBlocks: 0, ppm: 0 }] }; +mock.module('../../src/ark/refresh.js', () => ({ + ...refresh, + submitRoundParticipation: async () => { throw new Error('bad user input: insufficient refresh fee: expected at least 300 sat, got 0 sat'); }, +})); +mock.module('../../src/ark/proto.js', () => ({ + ...proto, + getArkInfo: async () => ({ refreshFees: fees }), +})); +const { ArkManager } = await import('../../src/ark/manager.js'); +const mgr = new ArkManager({ storage: { save() {} }, arkUrl: 'http://test.invalid' }); +Object.defineProperty(mgr, 'chain', { value: { tipHeight: async () => 1000 } }); +mgr._refreshOutputs = () => []; +mgr._key = () => ({}); +mgr._mailboxKey = () => ({ pubkey: new Uint8Array(33) }); +mgr._decoded = () => ({}); +mgr._keyForVtxo = () => ({}); +mgr._tipH = 1000; + +{ + mgr.info = { refreshFees: { baseFeeSat: 0, ppmExpiryTable: [{ thresholdBlocks: 0, ppm: 0 }] } }; + const action = { id: 'refresh-1', type: 'refresh', step: 'created', inputIds: ['a:0'], outKeyIndex: 1, outAmountSat: 5000, feeSat: 0 }; + mgr.state = { nextKeyIndex: 2, movements: [], actions: [action], vtxos: [{ id: 'a:0', amountSat: 5000, expiryHeight: 9000, state: 'pending', bytes: 'AA==' }] }; + await mgr._driveRefresh(action); + assert.equal(action.step, 'created', 'kept for the next sync to resubmit'); + assert.equal(action.feeSat, 300); + assert.equal(action.outAmountSat, 4700); + assert.equal(mgr.info.refreshFees.baseFeeSat, 300, 'the new schedule is now the wallet’s'); + console.log('✓ A renewal priced before a fee rise is re-priced at the new fee'); + // the same refusal again, nothing left to change: give up rather than loop + await mgr._driveRefresh(action); + assert.equal(action.step, 'failed'); + assert.equal(mgr.state.vtxos[0].state, 'spendable'); + console.log('✓ Refused again at the new price, it stops and frees the coins'); +} + +{ + mgr.info = { refreshFees: { baseFeeSat: 0, ppmExpiryTable: [{ thresholdBlocks: 0, ppm: 0 }] } }; + const action = { id: 'refresh-2', type: 'refresh', step: 'created', inputIds: ['b:0'], outKeyIndex: 1, outAmountSat: 500, feeSat: 0 }; + mgr.state = { nextKeyIndex: 2, movements: [], actions: [action], vtxos: [{ id: 'b:0', amountSat: 500, expiryHeight: 9000, state: 'pending', bytes: 'AA==' }] }; + await mgr._driveRefresh(action); + assert.equal(action.step, 'failed'); + assert.equal(action.superseded, true, 'no history row for a renewal that never began'); + assert.equal(mgr.state.vtxos[0].state, 'spendable'); + console.log('✓ A balance the new fee leaves under 330 sats gets its coins back'); +} +console.log('\n✅ refresh re-pricing passes'); -- cgit v1.2.3