aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-29 07:05:47 -0700
committerAdam Soltys <[email protected]>2026-08-29 07:05:47 -0700
commitdd011c2c98033637829d83fc320ab4fdce4ede8c (patch)
tree67be07e6e8480354bfd60589ca68dbcaeac0c409 /src
parentc4b566651d6afac2c48c88611094cfa71c16289b (diff)
downloadcoinosv3-dd011c2c98033637829d83fc320ab4fdce4ede8c.tar.gz
coinosv3-dd011c2c98033637829d83fc320ab4fdce4ede8c.zip
A round's output can no longer strand: every refresh asks the server to drop its unlock hash in the mailbox, and any device that finds such a breadcrumb with no matching action adopts the claim itself (input ids now read from the status response — the rescue needs no operator). History learns to tell the whole truth and nothing more: coins the server saw spent elsewhere get a folded row (skipping coins a known renewal consumed), renewals fold across devices on their unlock hash and appear only when they cost something, and a rescue records the fee the round actually took. The account page keeps its header icons when reached from an open wallet; the messages page folds the control/guestbook replay once per burst instead of painting every intermediate state — no more blinking messages from superseded ban lists or member counts crawling to their total, which now paints from last session's settled figure
Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019WboeoetMF3MTmgT7Ew8yi
Diffstat (limited to 'src')
-rw-r--r--src/app.js5
-rw-r--r--src/ark/manager.js51
-rw-r--r--src/ark/proto.js6
-rw-r--r--src/ark/refresh.js14
-rw-r--r--src/features/ark.js53
-rw-r--r--src/features/messages.js32
-rw-r--r--src/i18n.js2
7 files changed, 136 insertions, 27 deletions
diff --git a/src/app.js b/src/app.js
index b6be6f0..2eeb67c 100644
--- a/src/app.js
+++ b/src/app.js
@@ -2440,7 +2440,10 @@ function accountsScreen() {
return h(
'div',
{ class: 'col', style: 'gap:16px' },
- brandHeader(false),
+ // Reached from an open wallet this is a page, not a boot screen — the
+ // header icons (search, messages, settings, lock, avatar) stay put.
+ // Cold boot lands here with nothing loaded yet, and then they hide.
+ brandHeader(wallet.loaded),
h('div', { class: 'card col' },
h('h3', {}, t('accounts')),
h('div', { class: 'col', style: 'gap:0' },
diff --git a/src/ark/manager.js b/src/ark/manager.js
index 2a6c956..920eda3 100644
--- a/src/ark/manager.js
+++ b/src/ark/manager.js
@@ -319,6 +319,21 @@ export class ArkManager {
if (a) this._driveLnRecv(a).catch(() => {}); // claim promptly; sync poll is the fallback
return changed;
}
+ if (m.kind === 'roundCompleted') {
+ // A round ran for one of our participations. Normally the action that
+ // submitted it drives the claim — but if that action is gone (crash
+ // mid-claim, snapshot rollback, a foreign device that never came
+ // back), this breadcrumb is the only trace, and money sits 'unclaimed'
+ // on the server until someone acts on it (cooney's 28,833 sats did,
+ // for 8 days). Adopt it; rescueParticipation no-ops on inputs we don't
+ // hold and the server side is idempotent.
+ if (m.unlockHash
+ && !this.state.actions.some((a) => a.unlockHash === m.unlockHash && a.step !== 'failed')) {
+ this.rescueParticipation(m.unlockHash).catch((e) =>
+ console.warn('ark: stranded participation not adopted —', e.message));
+ }
+ return changed;
+ }
if (m.kind !== 'arkoor') return changed;
const ourKeys = [hex.encode(this._key(0).pubkey)];
const tip = await this._tipMemo();
@@ -1610,6 +1625,10 @@ export class ArkManager {
unlockHash = await submitRoundParticipation(this.arkUrl, {
inputs: inputRecs.map((v) => ({ vtxo: this._decoded(v), keys: this._keyForVtxo(v) })),
outputs,
+ // recovery breadcrumb: the round posts our unlock hash to the
+ // mailbox, so a lost action (crash, foreign device) is found and
+ // claimed by whichever device reads the mailbox next
+ mailboxId: this._mailboxKey().pubkey,
});
} catch (e) {
// "unusable inputs" never becomes usable by resubmission — some input
@@ -1665,28 +1684,34 @@ export class ArkManager {
}
for (const v of inputRecs) v.state = 'spent';
action.step = 'done';
- this._movement({ type: 'refresh', amountSat: action.outAmountSat, feeSat: action.feeSat || 0, status: 'complete', detail: `${inputRecs.length} in -> ${newVtxos.length} out${action.feeSat ? ` · fee ${action.feeSat} sat` : ''}` });
+ // unlockHash keys the cross-device display fold (two devices adopting
+ // the same participation must not become two "Renewed" rows);
+ // inputIds let the history skip "spent elsewhere" rows for coins this
+ // renewal consumed — reconciled before the claim, they'd otherwise
+ // stand as spends that the renewed coin silently contradicts.
+ this._movement({ type: 'refresh', amountSat: action.outAmountSat, feeSat: action.feeSat || 0, status: 'complete', unlockHash: action.unlockHash, inputIds: [...(action.inputIds || [])], detail: `${inputRecs.length} in -> ${newVtxos.length} out${action.feeSat ? ` · fee ${action.feeSat} sat` : ''}` });
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).
+ // action was lost (snapshot rollback, device wipe, crash mid-claim) after
+ // its round ran, leaving the output stranded 'unclaimed' on the server.
+ // The server replays the funding tx, output vtxos AND input ids; 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 may be passed explicitly
+ // (the operator recipe) — omitted, they come from the status response.
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');
+ if (!inputVtxoIds?.length) inputVtxoIds = status.inputVtxoIds;
+ if (!inputVtxoIds?.length) throw new Error('input vtxo ids required');
+ const missing = inputVtxoIds.filter((id) => !this._vtxo(id));
+ if (missing.length) throw new Error('input records not in this wallet: ' + missing.join(', '));
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));
@@ -1697,10 +1722,12 @@ export class ArkManager {
}
if (outKeyIndex == null) throw new Error('no wallet key matches the participation outputs');
if (outKeyIndex >= this.state.nextKeyIndex) this.state.nextKeyIndex = outKeyIndex + 1;
+ const outSat = outs.reduce((n, v) => n + v.amountSat, 0);
+ const inSat = inputVtxoIds.reduce((n, id) => n + (this._vtxo(id).amountSat || 0), 0);
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,
+ outAmountSat: outSat, feeSat: Math.max(0, inSat - outSat),
unlockHash: unlockHashHex,
fundingTxHex: hex.encode(status.fundingTx),
outputVtxos: status.outputVtxos.map((b) => hex.encode(b)),
diff --git a/src/ark/proto.js b/src/ark/proto.js
index a1f2fdc..b8c091d 100644
--- a/src/ark/proto.js
+++ b/src/ark/proto.js
@@ -511,6 +511,12 @@ export function decodeMailboxMessage(value) {
if (a.field === 1) msg.vtxos.push(decodeVtxo(a.value));
}
}
+ if (f.field === 4) { // RoundParticipationCompleted — wallet-recovery breadcrumb
+ msg.kind = 'roundCompleted';
+ for (const a of pbFields(f.value)) {
+ if (a.field === 2) msg.unlockHash = hex.encode(a.value);
+ }
+ }
if (f.field === 5) { // IncomingLightningPaymentMessage
msg.kind = 'lnIncoming';
for (const a of pbFields(f.value)) {
diff --git a/src/ark/refresh.js b/src/ark/refresh.js
index 40c6faa..2894977 100644
--- a/src/ark/refresh.js
+++ b/src/ark/refresh.js
@@ -291,8 +291,11 @@ export function participationAttestation(inputVtxoIdRaw, outputs, vtxoPrivkey) {
return schnorr.sign(msg, vtxoPrivkey);
}
-// inputs: [{ vtxo, keys }] — each input's attestation is signed by its own key
-export async function submitRoundParticipation(ark, { inputs, outputs }) {
+// inputs: [{ vtxo, keys }] — each input's attestation is signed by its own key.
+// mailboxId: when given, the server posts a recovery breadcrumb (the unlock
+// hash) to that mailbox once the round runs — any device of this wallet can
+// then claim the output even if the action that submitted it is lost.
+export async function submitRoundParticipation(ark, { inputs, outputs, mailboxId }) {
const w = pbWriter();
for (const { vtxo, keys } of inputs) {
const iv = pbWriter();
@@ -306,6 +309,7 @@ export async function submitRoundParticipation(ark, { inputs, outputs }) {
vr.bytesField(2, concatBytes(Uint8Array.of(0x00), o.userPubkey));
w.bytesField(3, vr.finish());
}
+ if (mailboxId) w.bytesField(4, mailboxId);
const resp = await grpcCall(ark, 'bark_server.ArkService/SubmitRoundParticipation', w.finish());
for (const { field, value } of pbFields(resp)) if (field === 1) return value; // unlock_hash
throw new Error('no unlock hash in response');
@@ -315,12 +319,16 @@ export async function roundParticipationStatus(ark, unlockHash) {
const w = pbWriter();
w.bytesField(1, unlockHash);
const resp = await grpcCall(ark, 'bark_server.ArkService/RoundParticipationStatus', w.finish());
- const out = { status: 0, outputVtxos: [] };
+ const out = { status: 0, outputVtxos: [], inputVtxoIds: [] };
for (const { field, value } of pbFields(resp)) {
if (field === 1) out.status = Number(value);
if (field === 2) out.fundingTx = value;
if (field === 3) out.outputVtxos.push(value);
if (field === 4) out.unlockPreimage = value;
+ if (field === 5 && value.length === 36) { // input vtxo ids (LE txid + LE vout)
+ const vout = value[32] | (value[33] << 8) | (value[34] << 16) | (value[35] << 24);
+ out.inputVtxoIds.push(`${hex.encode(value.slice(0, 32).reverse())}:${vout}`);
+ }
}
return out;
}
diff --git a/src/features/ark.js b/src/features/ark.js
index 291ae92..6d122a0 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -873,6 +873,19 @@ 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.
+ if (m.type === 'reconcile') {
+ return h('div', { class: 'item' },
+ h('div', { class: 'ico out', html: ARK_MARK(15) }),
+ h('div', { class: 'grow' },
+ h('div', {}, t('arkSpentElsewhere')),
+ h('div', { class: 'small faint' },
+ timeAgo(m.ts / 1000) + (m.count > 1 ? ` · ${t('arkSpentElsewhereN', { n: m.count })}` : ''))),
+ h('div', { style: 'text-align:right' },
+ h('div', { class: 'amount-neg' }, '-' + fmtAmount(m.amountSat))));
+ }
// a renewal moves nothing anywhere — its history amount is what it COST
if (m.type === 'refresh') {
return h('div', { class: 'item', style: 'cursor:pointer', onClick: () => { ui.arkMoveDetail = m.id; render(); } },
@@ -2589,18 +2602,46 @@ export function arkFeature(ctx) {
// 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.
+ // Renewals fold on their unlock hash (two devices can adopt the same
+ // participation), and only ones that COST something get a row — a free
+ // renewal is net zero, invisible money movement, noise.
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')
+ const moves = (s.movements || [])
+ .filter((m) => ['receive', 'send', 'board', 'offboard', 'exit', 'ln-send', 'ln-receive', 'refresh', 'reconcile'].includes(m.type) && m.status === 'complete')
+ .filter((m) => m.type !== 'refresh' || m.feeSat > 0)
.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 (!m.type.startsWith('ln-') && !(m.type === 'refresh' && m.unlockHash)) return true;
+ const key = m.type + ':' + (m.unlockHash || 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) })));
+ });
+ // Coins the server reported spent elsewhere: without a row for them the
+ // history stops summing to the balance (cooney's looked 29k sats rich).
+ // One reconcile pass marks many coins in the same instant — fold each
+ // pass (and re-imported duplicates, deduped by coin) into one row.
+ // Coins a renewal is known to have consumed are excluded outright: the
+ // reconcile that noticed them gone was pre-empting the claim, and the
+ // renewed coin already balances the books invisibly.
+ const renewed = new Set();
+ for (const a of s.actions || []) if (a.type === 'refresh') for (const id of a.inputIds || []) renewed.add(id);
+ for (const m of s.movements || []) if (m.type === 'refresh') for (const id of m.inputIds || []) renewed.add(id);
+ const out = [];
+ const seenCoin = new Set();
+ for (const m of moves) {
+ if (m.type !== 'reconcile') { out.push(m); continue; }
+ if (m.vtxoId && (seenCoin.has(m.vtxoId) || renewed.has(m.vtxoId))) continue;
+ if (m.vtxoId) seenCoin.add(m.vtxoId);
+ const last = out[out.length - 1];
+ if (last && last.type === 'reconcile' && last.ts === m.ts) {
+ last.amountSat += m.amountSat;
+ last.count += 1;
+ } else {
+ out.push({ ...m, count: 1 });
+ }
+ }
+ return exits.concat(out.map((m) => ({ time: m.ts, render: () => arkHistoryItem(m) })));
},
historyDetail() {
if (ui.arkExitDetail) {
diff --git a/src/features/messages.js b/src/features/messages.js
index eea4fbf..d5c0441 100644
--- a/src/features/messages.js
+++ b/src/features/messages.js
@@ -263,6 +263,27 @@ export function messagesFeature(ctx) {
if (tms) observeAuthor(room.members, author, tms);
}
};
+ // The replay of stored wraps arrives one event at a time, and folding
+ // after every wrap paints each intermediate state: a superseded ban-list
+ // version blinks members' messages out until the newest version lands,
+ // and the member count creeps upward as joins stream in. Fold once per
+ // burst instead — a short quiet gap ends the burst — so the page goes
+ // from cache straight to the settled state.
+ let foldTimer = 0;
+ const scheduleFold = () => {
+ clearTimeout(foldTimer);
+ foldTimer = setTimeout(() => {
+ refold();
+ refoldGuestbook();
+ // remember the settled count — next session's list paints it
+ // immediately instead of "encrypted" flipping to a number
+ const s = st();
+ (s.memberCounts ||= {})[jm.community_id] =
+ [...room.members.values()].filter((m) => m.state === 'join').length;
+ save(s);
+ scheduleRepaint();
+ }, 250);
+ };
allUnsubs.push(
subscribeOn(room.relays, { kinds: [1059], authors: [room.control.pk], limit: 500 }, (wrap) => {
@@ -271,8 +292,7 @@ export function messagesFeature(ctx) {
const opened = openWrap(wrap, room.control);
if (!opened || opened.rumor.kind !== 3308) return;
room.controlEntries.push(opened);
- refold();
- scheduleRepaint();
+ scheduleFold();
}),
subscribeOn(room.relays, { kinds: [1059], authors: [room.guestbook.pk], limit: 500 }, (wrap) => {
if (seenWraps.has(wrap.id)) return;
@@ -280,8 +300,7 @@ export function messagesFeature(ctx) {
const opened = openWrap(wrap, room.guestbook);
if (!opened) return;
room.guestEntries.push(opened);
- refoldGuestbook();
- scheduleRepaint();
+ scheduleFold();
})
);
for (const c of jm.channels || []) subChannel(room, c.id);
@@ -2128,7 +2147,10 @@ export function messagesFeature(ctx) {
kids.push(h('div', { class: 'list' }, communities().map((jm) => {
const room = rooms.get(jm.community_id);
const name = room?.folded?.metadata?.name || jm.name;
- const memberCount = room ? [...room.members.values()].filter((m) => m.state === 'join').length : 0;
+ // until the guestbook fold lands, last session's settled count beats
+ // a placeholder that flips a moment later
+ const live = room ? [...room.members.values()].filter((m) => m.state === 'join').length : 0;
+ const memberCount = live || (st().memberCounts || {})[jm.community_id] || 0;
const unread = room ? roomUnread(room) : false;
return h('div', {
class: 'item chat-thread-row' + (unread ? ' unread' : ''),
diff --git a/src/i18n.js b/src/i18n.js
index c65211a..ab52fcd 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -206,6 +206,8 @@ const STR = {
arkDepthBtn: 'Manage',
arkDepthRenewed: 'Renewal requested',
arkDepthRenewBtn: 'Renew now',
+ arkSpentElsewhere: 'Spent on another device',
+ arkSpentElsewhereN: '{n} coins',
arkCoinsTitle: 'Spending coins',
arkCoinsIntro: 'Your Spending balance lives in {n} coins totalling {total}. Each can be pulled on-chain without the server’s help — at a cost that grows with the coin’s history.',
arkCoinsColAmount: 'Amount',