aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/ark.js')
-rw-r--r--src/features/ark.js60
1 files changed, 58 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 ? (() => {