diff options
| author | Adam Soltys <[email protected]> | 2026-08-29 00:08:41 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-29 00:08:41 -0700 |
| commit | f06644e729ab5be2fcfa45e32f8b1de52ea36eb3 (patch) | |
| tree | 5e5737a15a124c0f21fd2580a3de415b778441bb /src | |
| parent | 63b26f2d7e3af485479068af40a9ef7a7386d1d2 (diff) | |
| download | coinosv3-f06644e729ab5be2fcfa45e32f8b1de52ea36eb3.tar.gz coinosv3-f06644e729ab5be2fcfa45e32f8b1de52ea36eb3.zip | |
The mailbox checkpoint was a timestamp all along: receives are dated by when the server actually delivered them (verified to the millisecond), so an import shows hours-old payments as hours old — no celebration for news that isn't; a wallet whose Savings holds nothing opens onto the Spending that holds everything; and same-payment duplicates fold at display time on every device, however the state arrived
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019eA8JRq9Wr7z2fRQk99HLi
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 18 | ||||
| -rw-r--r-- | src/features/ark.js | 33 |
2 files changed, 42 insertions, 9 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index 508cd85..50214fc 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -321,19 +321,23 @@ export class ArkManager { if (m.kind !== 'arkoor') return changed; const ourKeys = [hex.encode(this._key(0).pubkey)]; const tip = await this._tipMemo(); - // Expiry-based dating is for the FIRST catch-up only, where old receipts - // deserve old dates. A delivery landing on a live wallet happened NOW — - // the estimate mis-dated a fresh receive by a month when the server's - // vtxo lifetime changed under it (the math assumes every coin began with - // the full current lifetime). + // The mailbox checkpoint IS the delivery timestamp (ms << 20, verified + // to the millisecond against the server's rows) — the one honest date + // for a receive, whether it lands live or surfaces in an import's + // catch-up. Expiry-based estimation stays only as the fallback for + // messages without a usable checkpoint. const live = !this.state.baselinePending; + const ckTs = (() => { + const t = Math.floor(Number(m.checkpoint || 0) / 1048576); + return t > 1.6e12 && t < Date.now() + 60_000 ? t : null; + })(); for (const v of m.vtxos) { if (this._vtxo(v.id)) { // The vtxo beat the message here (a mirror or snapshot merge carried // it) — the money is present but the story is missing. Backfill the // receive movement so history matches the balance. if (!this.state.movements.some((x) => x.vtxoId === v.id)) { - this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, ts: live ? Date.now() : this._estimateReceiveTs(this._decoded(this._vtxo(v.id)), tip) }); + this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(this._decoded(this._vtxo(v.id)), tip)) }); changed = true; } continue; @@ -354,7 +358,7 @@ export class ArkManager { if (!this._addVtxo(v, v._raw.bytes, 0)) continue; this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, - ts: live ? Date.now() : this._estimateReceiveTs(v, tip), + ts: ckTs ?? (live ? Date.now() : this._estimateReceiveTs(v, tip)), }); changed = true; } diff --git a/src/features/ark.js b/src/features/ark.js index 15b7687..90249a9 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -362,7 +362,7 @@ export function arkFeature(ctx) { const tx = (wallet.txs || []).find((t2) => t2.txid === txid2); return tx && tx.confirmed ? { confirmed: true, block_height: tx.blockHeight || NaN } : null; }, - onUpdate: () => render(), + onUpdate: () => { maybeAutoSelectSpending(); render(); }, }); ui.arkError = ''; arkConnectPromise = mgr.init().then(() => { @@ -419,6 +419,23 @@ export function arkFeature(ctx) { return wallet.loadArkState(cfg.ark); } + // A wallet that opens onto "Savings: 0" while its money sits in Spending + // is answering the wrong question — a stale device-global selection (or + // the default) shouldn't hide the balance. One shot per wallet open; any + // manual switch afterwards is respected. + let _autoSelected = false; + function maybeAutoSelectSpending() { + if (_autoSelected || wallet.watchOnly) return; + const b = arkBalance(); + if (!b || b.spendableSat + b.pendingSat <= 0) return; + _autoSelected = true; + if ((wallet.spendable || 0) === 0 && ctx.getAccount() === 'savings') { + ui.navAnimSkip = true; // a correction at open paints, never slides + ctx.setAccount('spending'); // renders synchronously; the flip is decided here + ui.navAnimSkip = false; // don't let the one-shot eat the next real navigation + } + } + function arkBalance() { const s = arkStateNow(); if (!s) return null; @@ -2338,7 +2355,7 @@ export function arkFeature(ctx) { return { id: 'ark', - init() { initArk(); }, + init() { _autoSelected = false; initArk(); }, stop() { stopArk(); }, screenView() { return ui.arkExitPage ? arkExitPage() : null; }, receiveTakeover() { @@ -2515,8 +2532,20 @@ export function arkFeature(ctx) { const exits = (s.actions || []) .filter((a) => a.type === 'exit' && !['done', 'failed'].includes(a.step)) .map((a) => ({ time: exitStartedTs(a), render: () => arkExitActionItem(a) })); + // Same-payment duplicates (the old settle race, or a snapshot adopted + // whole before the merge learned to dedupe) fold at display time too — + // the earliest telling wins, everywhere, however the state got here. + const seenPay = new Set(); return exits.concat((s.movements || []) .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit', 'ln-send', 'ln-receive', 'refresh'].includes(m.type) && m.status === 'complete') + .sort((m, n) => (m.ts || 0) - (n.ts || 0)) + .filter((m) => { + if (!m.type.startsWith('ln-')) return true; + const key = m.type + ':' + (m.paymentHash || m.preimage || m.invoice || m.id); + if (seenPay.has(key)) return false; + seenPay.add(key); + return true; + }) .map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) }))); }, historyDetail() { |
