aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/ark/manager.js34
-rw-r--r--tools/ark/refresh-reprice-test.js58
2 files changed, 91 insertions, 1 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index ac63457..7861b43 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -2037,6 +2037,27 @@ export class ArkManager {
return [{ amountSat: action.outAmountSat, userPubkey: this._key(action.outKeyIndex).pubkey }];
}
+ // Re-read the server's fee schedule and re-price a refused refresh at it:
+ // still 'created' (the next sync resubmits) while the output clears the
+ // 330-sat floor, otherwise failed with its inputs released.
+ async _repriceRefresh(action, inputRecs) {
+ this.info = await getArkInfo(this.arkUrl);
+ const tip = await this.chain.tipHeight();
+ const totalSat = inputRecs.reduce((n, v) => n + v.amountSat, 0);
+ const feeSat = this.refreshFee(inputRecs, tip);
+ if (totalSat - feeSat >= 330 && feeSat !== action.feeSat) {
+ action.feeSat = feeSat;
+ action.outAmountSat = totalSat - feeSat;
+ delete action.lastError;
+ } else {
+ for (const v of inputRecs) if (v.state === 'pending') v.state = 'spendable';
+ action.step = 'failed';
+ action.superseded = true; // never began: no history row
+ action.lastError = 'the renewal fee leaves less than the 330-sat minimum';
+ }
+ this._save();
+ }
+
// Ask the server which of a rejected participation's inputs are dead, mark
// those spent, and rebuild the action around the survivors — or fail it and
// release them if nothing changed (the cause isn't a spent input) or what
@@ -2152,6 +2173,13 @@ export class ArkManager {
// was spent elsewhere and this state didn't know. Repair instead of
// retrying the same doomed set on every sync forever (which also
// holds the inputs pending, blocking every future auto-refresh).
+ // The server raised its renewal fee since this wallet read it (an app
+ // left open across a fee change): re-read the schedule and re-price,
+ // or the same underpaying set is refused on every sync forever.
+ if (/insufficient refresh fee/i.test(e.message || '')) {
+ await this._repriceRefresh(action, inputRecs);
+ return;
+ }
if (!/unusable/i.test(e.message || '')) throw e; // transient: retry on next sync
await this._repairRefresh(action, inputRecs);
return;
@@ -2309,7 +2337,11 @@ export class ArkManager {
// One participation per coin. A shared one dies whole the moment any
// single input is spent, taking the untouched coins' cover with it.
try { await this.scheduleRefresh([v.id], v.expiryHeight - marginBlocks); n++; }
- catch (e) { console.warn('ark: no backstop refresh for', v.id, '—', e.message); }
+ catch (e) {
+ // priced from a stale schedule: re-read it so the next pass registers
+ if (/insufficient refresh fee/i.test(e.message || '')) this.info = await getArkInfo(this.arkUrl).catch(() => this.info);
+ console.warn('ark: no backstop refresh for', v.id, '—', e.message);
+ }
}
return n;
}
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');