aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-08 13:44:24 -0700
committerAdam Soltys <[email protected]>2026-10-08 13:44:24 -0700
commit6206eaa76431d0bb09ffa88abb3f02b8976a9ed3 (patch)
tree296fb5774810002fa27e795268a2f418726736a3
parentfcd023fb4b33404eb62afceba0c0232f8b25f200 (diff)
downloadcoinosv3-6206eaa76431d0bb09ffa88abb3f02b8976a9ed3.tar.gz
coinosv3-6206eaa76431d0bb09ffa88abb3f02b8976a9ed3.zip
Renewals show on every device: the sync snapshot carries each device's own renewals as display-only slivers (step, amounts, fee, round txid; no signing material), merged into remoteRenewals furthest-step-wins, never as actions; the coins page lists another device's renewal in progress with its round transaction until it finishes there or a 'Renewed' row syncs (two-day cap)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/features/ark.js60
-rw-r--r--src/i18n.js1
-rw-r--r--tools/ark-renewal-sync-test.js53
3 files changed, 112 insertions, 2 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index c02b9f9..9b7cd65 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -151,6 +151,49 @@ export function installArkWallet(wallet) {
// which it might spend/exit); the movement log for ark history; and
// done board/offboard/exit actions (stripped of hex) so on-chain rows still
// label. In-flight actions and their signing material stay device-local.
+const RENEW_STEP_RANK = { created: 0, submitted: 1, issued: 2, done: 3, failed: 3 };
+// when a renewal began: its action id carries the moment (refresh-<ms>,
+// refresh-rescue-<ms>)
+const renewalStartedAt = (id) => Number(String(id).split('-').pop()) || 0;
+
+function renewalSliver(a) {
+ return {
+ id: a.id, step: a.step, superseded: !!a.superseded, manual: !!a.manual,
+ outAmountSat: a.outAmountSat, feeSat: a.feeSat || 0,
+ unlockHash: a.unlockHash || null, fundingTxid: a.fundingTxid || null,
+ };
+}
+
+// Other devices' renewals, by id: the furthest-along telling wins (a step
+// never goes backwards), this device's own actions never count as remote,
+// and anything begun more than two days ago is dropped — a renewal finishes
+// within hours, and a device that stopped publishing mustn't leave a card
+// behind forever.
+export function mergeRemoteRenewals(a, b, now = Date.now()) {
+ const own = new Set((a.actions || []).map((x) => x.id));
+ // b's own renewals are remote to us; a.renewals only exists when a was
+ // itself adopted whole from a snapshot
+ const byId = new Map();
+ for (const r of [...(a.remoteRenewals || []), ...(a.renewals || []), ...(b.remoteRenewals || []), ...(b.renewals || [])]) {
+ if (!r || !r.id || own.has(r.id)) continue;
+ if (now - renewalStartedAt(r.id) > 2 * 86_400_000) continue;
+ const cur = byId.get(r.id);
+ if (!cur || (RENEW_STEP_RANK[r.step] ?? 0) > (RENEW_STEP_RANK[cur.step] ?? 0)
+ || (!cur.fundingTxid && r.fundingTxid && r.step === cur.step)) byId.set(r.id, { ...r });
+ }
+ return [...byId.values()].sort((x, y) => renewalStartedAt(x.id) - renewalStartedAt(y.id));
+}
+
+// Renewals another device has in flight, as this device should show them:
+// not finished there, and not already finished as far as this device's
+// history knows (the "Renewed" row synced ahead of the final telling).
+export function remoteRenewalsInFlight(state) {
+ const settled = new Set((state.movements || [])
+ .filter((m) => m.type === 'refresh' && m.status === 'complete' && m.unlockHash).map((m) => m.unlockHash));
+ return (state.remoteRenewals || []).filter((r) =>
+ !['done', 'failed'].includes(r.step) && !(r.unlockHash && settled.has(r.unlockHash)));
+}
+
export function slimArkForSync(s) {
if (!s) return null;
const HEAVY = ['bytes', 'destBytes', 'changeBytes', 'vtxoBytes', 'txHex', 'fundingTxHex', 'outputVtxos'];
@@ -183,6 +226,12 @@ export function slimArkForSync(s) {
}));
return [...core, ...spends];
})(),
+ // This device's own renewals, for DISPLAY elsewhere only — a phone that
+ // knew nothing showed no "Renewal in progress" while the laptop's round
+ // ran and the balance sat locked. No signing material: nothing but the
+ // device that started a renewal can ever drive it. Terminal ones ride
+ // too, so the finished telling replaces the in-progress one.
+ renewals: (s.actions || []).filter((a) => a.type === 'refresh').map(renewalSliver),
movements: (s.movements || []).map((m, i, arr) =>
// Older rows only need to render a history line; the bolt11 (~400 chars
// each) and preimage are what pushed snapshots past relay size limits.
@@ -288,6 +337,8 @@ export function mergeArkStates(a, b) {
// cursor with its coins/history, just as the !a case above does, rather
// than replaying the entire mailbox on a first login.
out.mailboxCheckpoint = pristine ? b.mailboxCheckpoint || 0 : a.mailboxCheckpoint || 0;
+ out.remoteRenewals = mergeRemoteRenewals(a, b);
+ delete out.renewals; // a snapshot's own list, adopted above as remote
out.nextKeyIndex = Math.max(a.nextKeyIndex || 1, b.nextKeyIndex || 1);
out.nextLnRecvIndex = Math.max(a.nextLnRecvIndex || 0, b.nextLnRecvIndex || 0);
out.receiveAckTs = Math.max(a.receiveAckTs || 0, b.receiveAckTs || 0);
@@ -2337,12 +2388,17 @@ export function arkFeature(ctx) {
}
if (ui.arkRenewConfirm) ui.arkRenewConfirm = null; // nothing selected any more
const stageOf = (a) => a.step === 'issued' ? t('arkRenewStageFinishing') : a.step === 'submitted' ? t('arkRenewStageRound') : t('arkRenewStageQueued');
- const renewingCard = renewing.length ? h('div', { class: 'card col', style: 'gap:8px' },
+ // another device's renewals, shown but never driven from here (it holds
+ // their signing material); they lock the balance just the same
+ const elsewhere = st ? remoteRenewalsInFlight(st) : [];
+ const renewingRows = [...renewing, ...elsewhere.map((r) => ({ ...r, elsewhere: true }))];
+ const renewingCard = renewingRows.length ? h('div', { class: 'card col', style: 'gap:8px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
h('h4', { style: 'margin:0' }, t('arkRenewingTitle')), h('span', { class: 'tag pending' }, t('arkExitPendingTag'))),
- ...renewing.map((a) => h('div', { class: 'col', style: 'gap:2px' },
+ ...renewingRows.map((a) => h('div', { class: 'col', style: 'gap:2px' },
h('div', { class: 'small' }, t('arkRenewingLine', { amount: fmtSats(a.inAmountSat || (a.outAmountSat + (a.feeSat || 0))), fee: a.feeSat ? t('arkRenewingFee', { fee: fmtSats(a.feeSat) }) : '' })),
h('div', { class: 'small muted' }, stageOf(a)),
+ a.elsewhere ? h('div', { class: 'small faint' }, t('arkRenewElsewhere')) : null,
// the round's transaction, once the round has run: the thing the
// wait is for, there to be looked at
a.fundingTxid ? (() => {
diff --git a/src/i18n.js b/src/i18n.js
index d0a069e..29f77c3 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -583,6 +583,7 @@ const STR = {
arkCoinsIntroRenewing: 'Your Spending balance of {total} is being renewed: the coins below are in a round and come back as one fresh coin. Each can be pulled on-chain without the server’s help, at a cost that grows with the coin’s history.',
arkRenewingTitle: 'Renewal in progress',
arkRenewingLine: '{amount} sats → one fresh coin{fee}',
+ arkRenewElsewhere: 'Started on another device, which finishes it the next time it’s open.',
arkRenewingFee: ', fee {fee} sats',
arkRenewStageQueued: 'Joining the next round. The wallet submits it on its next sync.',
arkRenewStageRound: 'In a round. The fresh coin arrives once the round’s transaction has a confirmation, about a block from now, the next time this wallet syncs.',
diff --git a/tools/ark-renewal-sync-test.js b/tools/ark-renewal-sync-test.js
new file mode 100644
index 0000000..af31cde
--- /dev/null
+++ b/tools/ark-renewal-sync-test.js
@@ -0,0 +1,53 @@
+// A renewal started on one device shows on the others as in progress (a phone
+// knew nothing while the laptop's round ran and the balance sat locked), is
+// never adopted as an action there, and disappears once it finishes.
+// Run: bun tools/ark-renewal-sync-test.js
+import assert from 'node:assert/strict';
+import { slimArkForSync, mergeArkStates, remoteRenewalsInFlight } from '../src/features/ark.js';
+
+const now = Date.now();
+const laptop = (step, extra = {}) => ({
+ serverPubkey: 'srv', vtxos: [], movements: [], actions: [{
+ id: `refresh-${now - 60_000}`, type: 'refresh', step, inputIds: ['a:0'], outKeyIndex: 9,
+ outAmountSat: 62212, feeSat: 125, unlockHash: 'c0'.repeat(32), fundingTxHex: 'deadbeef', outputVtxos: ['00'], ...extra,
+ }],
+});
+let phone = { serverPubkey: 'srv', vtxos: [], movements: [], actions: [{ id: 'ln-recv-1', type: 'ln-recv', step: 'awaiting' }] };
+
+const snap = slimArkForSync(laptop('submitted'));
+assert.equal(snap.renewals.length, 1);
+assert.ok(!('fundingTxHex' in snap.renewals[0]) && !('outputVtxos' in snap.renewals[0]) && !('inputIds' in snap.renewals[0]), 'display fields only');
+phone = mergeArkStates(phone, snap);
+assert.equal(phone.actions.length, 1, 'never adopted as an action of this device');
+assert.equal(remoteRenewalsInFlight(phone).length, 1);
+assert.equal(remoteRenewalsInFlight(phone)[0].outAmountSat, 62212);
+assert.ok(!('renewals' in phone), 'the snapshot field is not stored as is');
+console.log('✓ The other device sees the renewal in progress, as display only');
+
+phone = mergeArkStates(phone, slimArkForSync(laptop('submitted', { fundingTxid: 'e9'.repeat(32) })));
+assert.equal(remoteRenewalsInFlight(phone)[0].fundingTxid, 'e9'.repeat(32));
+// an older snapshot (another slot, still at 'created') never moves it back
+phone = mergeArkStates(phone, slimArkForSync(laptop('created')));
+assert.equal(phone.remoteRenewals[0].step, 'submitted');
+console.log('✓ It learns the round transaction, and a staler snapshot never moves it back');
+
+phone = mergeArkStates(phone, slimArkForSync(laptop('done', { fundingTxid: 'e9'.repeat(32) })));
+assert.equal(remoteRenewalsInFlight(phone).length, 0);
+console.log('✓ Finished there, it is gone here');
+
+{
+ let p2 = mergeArkStates({ serverPubkey: 'srv', vtxos: [], movements: [], actions: [] }, slimArkForSync(laptop('issued')));
+ p2 = mergeArkStates(p2, { serverPubkey: 'srv', vtxos: [], actions: [], movements: [{ id: 'm', type: 'refresh', status: 'complete', unlockHash: 'c0'.repeat(32) }] });
+ assert.equal(remoteRenewalsInFlight(p2).length, 0);
+ console.log('✓ A synced "Renewed" row settles it even before the final telling arrives');
+}
+
+{
+ const own = laptop('submitted');
+ const merged = mergeArkStates(own, slimArkForSync(laptop('submitted')));
+ assert.equal((merged.remoteRenewals || []).length, 0, 'its own renewal never shows twice');
+ const old = laptop('submitted'); old.actions[0].id = `refresh-${now - 3 * 86_400_000}`;
+ assert.equal(mergeArkStates(phone, slimArkForSync(old)).remoteRenewals.filter((r) => r.id === old.actions[0].id).length, 0);
+ console.log('✓ The device that owns it never sees itself as remote; a renewal begun days ago is dropped');
+}
+console.log('\n✅ renewals sync across devices for display');