aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-18 16:27:56 -0700
committerAdam Soltys <[email protected]>2026-08-18 16:27:56 -0700
commitecf7da305d56cee8a8d2306ca7fa5dad4b2f0c39 (patch)
treebafe19ba0d4f06124495266f2d05de56dd9246bc
parent1d21e3b9546496a4eccf8ec9ec45934999e07fd8 (diff)
downloadcoinosv3-ecf7da305d56cee8a8d2306ca7fa5dad4b2f0c39.tar.gz
coinosv3-ecf7da305d56cee8a8d2306ca7fa5dad4b2f0c39.zip
A refresh learns to let go: a participation rejected for unusable inputs asks the server which coins died and rebuilds around the survivors instead of resubmitting the doomed set every sync forever; the balance reconciles on a ten-minute cadence so ghosts can't pose as sats; and arkRescue() reclaims a round output whose action a device lost
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012u4oe1KPx9faCsDBLfZYqi
-rw-r--r--src/ark/manager.js100
-rw-r--r--src/features/ark.js10
2 files changed, 106 insertions, 4 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 99ae2c5..e5e04d4 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -266,6 +266,14 @@ export class ArkManager {
}
if (changed) this._save();
await this.resumePending();
+ // The display is only as honest as the last reconcile: state restored
+ // from a snapshot (or mirrored by the NWC worker) can show coins the
+ // server already saw spent — a balance you can look at but not spend.
+ // Sweep that drift on a slow cadence, not just at pay time.
+ if (Date.now() - (this._reconciledAt || 0) > 10 * 60_000) {
+ this._reconciledAt = Date.now();
+ await this.reconcile().catch(() => {});
+ }
}
// Handle one mailbox message (from a poll or the live stream). Returns
@@ -1481,6 +1489,38 @@ export class ArkManager {
return [{ amountSat: action.outAmountSat, userPubkey: this._key(action.outKeyIndex).pubkey }];
}
+ // 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
+ // remains is under the server's output minimum.
+ async _repairRefresh(action, inputRecs) {
+ const states = await Promise.all(inputRecs.map((v) =>
+ getVtxoStatus(this.arkUrl, this._decoded(v).point.raw, this._keyForVtxo(v).privkey)
+ .catch(() => null)));
+ const live = [];
+ inputRecs.forEach((v, i) => {
+ if (states[i] === VTXO_STATE_SPENT) {
+ v.state = 'spent';
+ this._movement({ type: 'reconcile', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, detail: 'spent elsewhere (server vtxo status)' });
+ } else live.push(v);
+ });
+ const dropped = inputRecs.length - live.length;
+ const tip = await this.chain.tipHeight();
+ const totalSat = live.reduce((n, v) => n + v.amountSat, 0);
+ const feeSat = live.length ? this.refreshFee(live, tip) : 0;
+ if (dropped && live.length && totalSat - feeSat >= 330) {
+ action.inputIds = live.map((v) => v.id);
+ action.outAmountSat = totalSat - feeSat;
+ action.feeSat = feeSat;
+ // still 'created': the next sync submits the repaired set
+ } else {
+ for (const v of live) v.state = 'spendable';
+ action.step = 'failed';
+ action.lastError = dropped ? 'refresh inputs were spent elsewhere' : 'server rejected refresh inputs';
+ }
+ this._save();
+ }
+
async _driveRefresh(action) {
const outputs = this._refreshOutputs(action);
const inputRecs = action.inputIds.map((id) => this._vtxo(id));
@@ -1488,10 +1528,21 @@ export class ArkManager {
if (action.step === 'created') {
const inputBytes = inputRecs.map((v) => hex.decode(v.bytes));
await registerVtxoTransactions(this.arkUrl, inputBytes);
- const unlockHash = await submitRoundParticipation(this.arkUrl, {
- inputs: inputRecs.map((v) => ({ vtxo: this._decoded(v), keys: this._keyForVtxo(v) })),
- outputs,
- });
+ let unlockHash;
+ try {
+ unlockHash = await submitRoundParticipation(this.arkUrl, {
+ inputs: inputRecs.map((v) => ({ vtxo: this._decoded(v), keys: this._keyForVtxo(v) })),
+ outputs,
+ });
+ } catch (e) {
+ // "unusable inputs" never becomes usable by resubmission — some input
+ // 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).
+ if (!/unusable/i.test(e.message || '')) throw e; // transient: retry on next sync
+ await this._repairRefresh(action, inputRecs);
+ return;
+ }
action.unlockHash = hex.encode(unlockHash);
action.step = 'submitted';
this._save();
@@ -1541,4 +1592,45 @@ export class ArkManager {
this._save();
}
}
+
+ // Rebuild a refresh claim from its unlock hash — for a participation whose
+ // action was lost (snapshot rollback, device wipe) after its round ran,
+ // leaving the output stranded 'unclaimed' on the server. The server replays
+ // the funding tx and output vtxos; our key index is rediscovered by
+ // scanning; the forfeit handshake needs the original input records, which
+ // must still exist in this wallet's state (already marked spent is fine).
+ // inputVtxoIds comes from the operator (the server knows which inputs the
+ // participation locked; there is no client-facing API for that lookup).
+ async rescueParticipation(unlockHashHex, inputVtxoIds) {
+ const missing = (inputVtxoIds || []).filter((id) => !this._vtxo(id));
+ if (!inputVtxoIds?.length) throw new Error('input vtxo ids required');
+ if (missing.length) throw new Error('input records not in this wallet: ' + missing.join(', '));
+ if (this.state.actions.some((a) => a.unlockHash === unlockHashHex && a.step !== 'failed')) {
+ throw new Error('an action for this participation already exists — sync drives it');
+ }
+ const status = await roundParticipationStatus(this.arkUrl, hex.decode(unlockHashHex));
+ if (status.status === 0 || !status.fundingTx) throw new Error('the round for this participation has not run');
+ const outs = status.outputVtxos.map((b) => decodeVtxo(b));
+ if (!outs.length) throw new Error('participation has no outputs');
+ const want = new Set(outs.map((v) => v.policy.userPubkey));
+ let outKeyIndex = null;
+ // The index was minted by the lost action, so scan a window past the counter.
+ for (let i = 0; i < this.state.nextKeyIndex + 64 && outKeyIndex == null; i++) {
+ if (want.has(hex.encode(this._key(i).pubkey))) outKeyIndex = i;
+ }
+ if (outKeyIndex == null) throw new Error('no wallet key matches the participation outputs');
+ if (outKeyIndex >= this.state.nextKeyIndex) this.state.nextKeyIndex = outKeyIndex + 1;
+ const action = {
+ id: `refresh-rescue-${Date.now()}`, type: 'refresh', step: 'issued',
+ inputIds: [...inputVtxoIds], outKeyIndex,
+ outAmountSat: outs.reduce((n, v) => n + v.amountSat, 0), feeSat: 0,
+ unlockHash: unlockHashHex,
+ fundingTxHex: hex.encode(status.fundingTx),
+ outputVtxos: status.outputVtxos.map((b) => hex.encode(b)),
+ };
+ this.state.actions.push(action);
+ this._save();
+ await this._driveRefresh(action);
+ return this.state.actions.find((a) => a.id === action.id);
+ }
}
diff --git a/src/features/ark.js b/src/features/ark.js
index 66e4e44..30b167a 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -308,6 +308,16 @@ export function arkFeature(ctx) {
initArk();
}
+ // Operator console rescue for a round participation stranded server-side
+ // 'unclaimed' (its action lost client-side): arkRescue('<unlockHash>',
+ // ['<vtxoId>', ...]) from devtools, ids supplied from the server's DB.
+ if (typeof globalThis !== 'undefined') {
+ globalThis.arkRescue = async (unlockHash, inputIds) => {
+ const mgr = await connectArk();
+ return mgr.rescueParticipation(unlockHash, inputIds);
+ };
+ }
+
function connectArk() {
if (ark) return Promise.resolve(ark);
if (arkConnectPromise) return arkConnectPromise;