aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/ark.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-06 23:52:21 -0700
committerAdam Soltys <[email protected]>2026-09-06 23:52:21 -0700
commit989e29302e479277fb18ee105874c9a1eeb00be5 (patch)
tree43c9eacdde70d34875a5e8cf99068ec8bcb5d834 /src/features/ark.js
parent050c944036c32200618f78f1924c81b8c881bb91 (diff)
downloadcoinosv3-989e29302e479277fb18ee105874c9a1eeb00be5.tar.gz
coinosv3-989e29302e479277fb18ee105874c9a1eeb00be5.zip
Durable sync outbox: fix the fresh-device phantom "Sent" that showed a zero balance (authored by codex).
Root cause of the "signed in and my balance is 0 with a huge Sent I never made": a device's snapshot carrying its change/new coins never reached the relay. The publish was a setTimeout debounce that got canceled on page-hide / logout / feature stop, so coins created by another session were never synced; a fresh device then loaded only the old (now-spent) coins, and reconcile dressed the spent inputs as a definitive "Sent" row and zeroed the balance. No funds ever moved — verified on the ASP that no such payment existed and the change coin was still spendable. Fix (codex): - src/sync-outbox.js (new): a durable outbox. Snapshots are signed synchronously at save time under the current identity (no cross-account mixing) and persisted already-encrypted to localStorage (no keys/plaintext in storage). Delivery retries with backoff and wakes on online/pageshow/visibilitychange/storage, surviving suspension, logout and relay outages. The last copy doubles as a local recovery source if an account cache is cleared. - sync.js: replace the cancel-on-stop debounced publish with the outbox; fetchAllStates also merges the outbox's own (possibly-unpublished) events so a rejected publish is still recoverable locally; forgetAll clears it. - nostr.js: factor out stateEvent() (synchronous signing) and decodeStateEvents(), shared by fetch and the outbox. - ark.js: adoptArkSnapshot() applies a synced snapshot to a LIVE connected manager without restarting payment ceremonies (a snapshot arriving while connected previously stayed invisible until reload); the pre-handshake hold-key merges instead of overwrites; and the reconcile row is reworded from a red "Sent -N" to a neutral "History incomplete / Payment details not synced" (↻) with an explainer, since a spent input proves neither an amount nor a date. - app.js: clearAll also clears the outbox (forgetAll). Tests (codex): sync-outbox-test, sync-browser-test (real Chrome: relay rejects a snapshot, browser closes, reopens and retries; a second browser recovers the balance + history without reload), sync-split-test, plus build and morph smoke — all green. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
Diffstat (limited to 'src/features/ark.js')
-rw-r--r--src/features/ark.js67
1 files changed, 43 insertions, 24 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 6e68200..bc04dd6 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -277,6 +277,26 @@ export function mergeArkStates(a, b) {
return out;
}
+// Apply sync to a connected manager without restarting payment ceremonies.
+// Drivers retain references across awaits, so preserve the state, coin and
+// action objects they already hold while adding/enriching the remote records.
+export function adoptArkSnapshot(manager, snapshot) {
+ if (!snapshot || !manager.state) return false;
+ const state = manager.state;
+ const merged = mergeArkStates(state, snapshot);
+ if (JSON.stringify(merged) === JSON.stringify(state)) return false;
+ for (const field of ['vtxos', 'actions', 'movements', 'gifts']) {
+ const existing = new Map((state[field] || []).map(item => [item.id, item]));
+ merged[field] = (merged[field] || []).map(item => {
+ const current = existing.get(item.id);
+ return current ? Object.assign(current, item) : item;
+ });
+ }
+ Object.assign(state, merged);
+ manager._save();
+ return true;
+}
+
export function arkFeature(ctx) {
const { h, ui, render, wallet, blankSend, fmtAmount, unitLabel, unitTag, copyBtn, toast, openExternal } = ctx;
installArkWallet(wallet); // ark state storage lives outside the core wallet
@@ -313,8 +333,10 @@ export function arkFeature(ctx) {
|| (wallet.loadArkState(cfg.ark) || {}).serverPubkey || null;
if (owner && owner !== mine) {
try {
- localStorage.setItem(wallet._arkHoldKey(owner),
- JSON.stringify({ ...d.arkState, serverPubkey: owner }));
+ const key = wallet._arkHoldKey(owner);
+ const held = JSON.parse(localStorage.getItem(key) || 'null');
+ localStorage.setItem(key,
+ JSON.stringify(mergeArkStates(held, { ...d.arkState, serverPubkey: owner })));
} catch {}
// `mine` is null on a FRESH restore (no local state, not yet
// connected), so even our own snapshot lands in the hold. Connecting
@@ -329,15 +351,15 @@ export function arkFeature(ctx) {
wallet.saveArkState(merged, cfg.ark);
const mergedN = (merged.vtxos || []).length;
const remoteN = (d.arkState.vtxos || []).length;
- const localN = (local && local.vtxos || []).length;
// We know vtxos this snapshot lacked — push our superset back up so the
// sender (and the shared slot) learns them. Guarded by mergedN>remoteN so
// it can't loop once every device has converged to the union.
if (mergedN > remoteN) { try { wallet.saveCache(); } catch {} }
- // The merge brought vtxos our live manager doesn't have — (re)connect so
- // the balance actually shows.
- const novel = mergedN > localN || (mergedN && (!ark || !ark.state));
- if (mergedN && novel) setTimeout(() => maybeInitArk(), 0);
+ // maybeInitArk deliberately does nothing when connected. Update that
+ // manager directly or the newly synced change/history stays invisible
+ // until reload, even though storage already contains the right balance.
+ if (ark) adoptArkSnapshot(ark, merged);
+ else if (mergedN) setTimeout(() => maybeInitArk(), 0);
},
});
@@ -470,6 +492,8 @@ export function arkFeature(ctx) {
arkConnectPromise = mgr.init().then(() => {
if (gen !== arkInitGen) throw new Error('superseded'); // wallet switched mid-connect
ark = mgr;
+ // Sync may have arrived while init awaited the server handshake.
+ adoptArkSnapshot(mgr, wallet.loadArkState(cfg.ark));
// Boards that died before funding (a failed tx build, a closed tab)
// used to linger at 'created' forever — and an in-flight-looking
// action blocks auto-renewal. Anything unfunded after an hour is a
@@ -1145,15 +1169,9 @@ export function arkFeature(ctx) {
: m.type === 'ln-send' ? t('arkLnPaidHistory') : m.type === 'ln-receive' ? t('arkLnReceivedHistory')
: m.type === 'offboard' ? t('arkOffboarded') : m.type === 'exit' ? t('arkExited')
: m.type === 'refresh' ? t('arkRenewedHistory') : t('sent');
- // coins the server saw spent outside this device's story (another
- // device, a swept expiry, an adopted renewal) — the row that keeps the
- // history summing to the balance. Folded per reconcile pass upstream.
- // Deliberately dressed as an ORDINARY Sent row: the user spent their own
- // money and doesn't need an alarm about which device did it. (The ASP's
- // status API returns only a spent bit — no spend time or destination —
- // so the timestamp is when this wallet found out.) The detail shows the
- // hard facts: date, amount, and the coin ids that were consumed. The
- // folded row isn't a stored movement, so the whole thing rides ui state.
+ // A spent input proves neither a payment amount nor its date: it can
+ // contain change, an offboard, or a renewal this device has not synced.
+ // Show the missing history explicitly until its records arrive.
if (m.type === 'reconcile') {
return h('div', {
class: 'item', style: 'cursor:pointer',
@@ -1162,12 +1180,12 @@ export function arkFeature(ctx) {
render();
},
},
- h('div', { class: 'ico out', html: ARK_MARK(15) }),
+ h('div', { class: 'ico' }, '↻'),
h('div', { class: 'grow' },
- h('div', {}, t('sent')),
- h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))),
+ h('div', {}, t('arkHistoryIncomplete')),
+ h('div', { class: 'small faint' }, t('arkDetailsNotSynced'))),
h('div', { style: 'text-align:right' },
- h('div', { class: 'amount-neg' }, '-' + fmtAmount(m.amountSat))));
+ h('div', { class: 'small muted' }, fmtAmount(m.amountSat) + ' ' + unitLabel())));
}
// a renewal moves nothing anywhere — its history amount is what it COST
if (m.type === 'refresh') {
@@ -1277,7 +1295,7 @@ export function arkFeature(ctx) {
h('span', { class: 'small', style: 'text-align:right;word-break:break-all' }, shortTxid(id)))));
// Detail for a folded reconcile row: the hard facts only — when this wallet
- // learned of it, how much left, and which coins were consumed.
+ // learned of it, the original coin total, and which coins were consumed.
function arkReconDetailView(d) {
const row = (k, v) => h('div', { class: 'row between', style: 'gap:12px' },
h('span', { class: 'small muted', style: 'flex-shrink:0' }, k),
@@ -1285,9 +1303,10 @@ export function arkFeature(ctx) {
return h('div', { class: 'card col', style: 'gap:10px' },
h('div', { class: 'row gap6', style: 'align-items:center' },
h('span', { html: ARK_ICON(18) }),
- h('h3', { style: 'margin:0' }, t('sent'))),
- h('div', { class: 'amount-neg', style: 'font-size:20px' }, '-' + fmtAmount(d.amountSat) + ' ' + unitLabel()),
- row(t('dateLabel'), new Date(d.ts).toLocaleString()),
+ h('h3', { style: 'margin:0' }, t('arkHistoryIncomplete'))),
+ h('div', { class: 'small muted' }, t('arkHistoryIncompleteHelp')),
+ row(t('arkOriginalCoins'), fmtAmount(d.amountSat) + ' ' + unitLabel()),
+ row(t('arkDetectedAt'), new Date(d.ts).toLocaleString()),
coinListRows((d.vtxoIds || []).length > 1 ? t('arkVtxoInputs') : t('arkVtxoId'), d.vtxoIds),
h('button', { class: 'btn-ghost btn-block', onClick: () => ctx.goBack(() => { ui.arkReconDetail = null; }) }, t('back')));
}