diff options
| author | Adam Soltys <[email protected]> | 2026-08-14 15:24:17 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-08-14 15:24:17 -0700 |
| commit | 4bd22c0c45ae35388b2a24e673bedcde5df77961 (patch) | |
| tree | 74eed71c3b477dd14be848f5f4d9d72814a04f47 /src | |
| parent | 7a7b2c61fe8394844fa306debb84917454ade7b0 (diff) | |
| download | coinosv3-4bd22c0c45ae35388b2a24e673bedcde5df77961.tar.gz coinosv3-4bd22c0c45ae35388b2a24e673bedcde5df77961.zip | |
A mailbox backlog claimed at sign-in reads as the old payments they are, not a wall of seconds-ago windfalls; a gift send knows itself in history — status shown, link recopyable, revoke at hand
Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
Diffstat (limited to 'src')
| -rw-r--r-- | src/ark/manager.js | 28 | ||||
| -rw-r--r-- | src/features/ark.js | 77 | ||||
| -rw-r--r-- | src/i18n.js | 2 |
3 files changed, 86 insertions, 21 deletions
diff --git a/src/ark/manager.js b/src/ark/manager.js index b2c2837..143242a 100644 --- a/src/ark/manager.js +++ b/src/ark/manager.js @@ -150,6 +150,28 @@ export class ArkManager { _movement(m) { this.state.movements.push({ id: `${Date.now()}-${this.state.movements.length}`, ts: Date.now(), ...m }); } + + // When did a mailbox vtxo come into being? Its expiry counts down from the + // server's vtxo lifetime, so (lifetime - blocksLeft) blocks have passed + // since the sender created it. At ~10 minutes a block that dates a backlog + // claimed at first sign-in as the old payments they are — instead of a wall + // of "seconds ago" receives and a bogus celebration. + _estimateReceiveTs(v, tip) { + const life = (this.info && this.info.vtxoExpiryDelta) || 0; + if (!life || !tip || !v.expiryHeight) return Date.now(); + const age = life - (v.expiryHeight - tip); + if (age <= 0) return Date.now(); + return Date.now() - age * 600_000; + } + + async _tipMemo() { + if (this._tipAt && Date.now() - this._tipAt < 60_000) return this._tipH; + try { + this._tipH = await this.chain.tipHeight(); + this._tipAt = Date.now(); + } catch { /* keep whatever we had */ } + return this._tipH || 0; + } _vtxo(id) { return this.state.vtxos.find((v) => v.id === id); } _addVtxo(decoded, bytes, keyIndex, state = 'spendable') { if (this._vtxo(decoded.id)) return false; @@ -264,6 +286,7 @@ export class ArkManager { } if (m.kind !== 'arkoor') return changed; const ourKeys = [hex.encode(this._key(0).pubkey)]; + const tip = await this._tipMemo(); for (const v of m.vtxos) { if (this._vtxo(v.id)) continue; try { @@ -280,7 +303,10 @@ export class ArkManager { // same message concurrently (validation awaits in between), and only the // copy that actually added the vtxo may record the receive movement. if (!this._addVtxo(v, v._raw.bytes, 0)) continue; - this._movement({ type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id }); + this._movement({ + type: 'receive', amountSat: v.amountSat, status: 'complete', vtxoId: v.id, + ts: this._estimateReceiveTs(v, tip), + }); changed = true; } return changed; diff --git a/src/features/ark.js b/src/features/ark.js index 8913dc3..9e0902a 100644 --- a/src/features/ark.js +++ b/src/features/ark.js @@ -808,7 +808,13 @@ export function arkFeature(ctx) { : h('div', { class: `ico ${incoming ? 'in' : 'out'}`, html: ARK_MARK(15) }), h('div', { class: 'grow' }, h('div', { class: 'row gap6', style: 'align-items:center' }, - label, + (() => { + const g = giftForMovement(m); + if (!g) return label; + return h('span', {}, '🎁 ' + t('giftHistoryTitle'), ' ', + h('span', { class: g.claimed ? 'tag conf' : 'tag' }, + g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag'))); + })(), (() => { const pk = zapNoteFor(m); return pk ? ctx.hook('profileChip', pk) : null; })(), m.status !== 'complete' ? h('span', { class: 'tag pending' }, m.status) : null), h('div', { class: 'small faint' }, timeAgo(m.ts / 1000))), @@ -847,6 +853,30 @@ export function arkFeature(ctx) { m.to ? row(t('arkPayTo'), shortAddr(m.to, 16, 12)) : null, m.vtxoId ? row(t('arkVtxoId'), shortTxid(m.vtxoId)) : null, m.detail ? row(t('detailsLabel'), m.detail) : null, + // A send that funded a bearer gift: show its fate, and while unclaimed + // offer the link again plus the sweep-back — same powers the gift card + // has, where the sender will actually go looking for them: history. + (() => { + const g = giftForMovement(m); + if (!g) return null; + refreshArkGiftRecords(); + const open = !g.claimed && !g.revoked; + const code = encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex)); + return h('div', { class: 'col', style: 'gap:8px;border-top:1px solid var(--border,rgba(128,128,128,.2));padding-top:10px' }, + h('div', { class: 'row gap6', style: 'align-items:center' }, + h('span', {}, '🎁 ' + t('giftHistoryTitle')), + h('span', { class: g.claimed ? 'tag conf' : 'tag' }, + g.claimed ? t('giftClaimedTag') : g.revoked ? t('giftRevokedTag') : t('giftUnclaimedTag'))), + open ? copyBtn(`${location.origin}/g/${code}`, t('giftCopyLinkAgain')) : null, + open ? (ui.busy && ui.revokeId === g.id + ? h('button', { class: 'btn-block', disabled: true }, h('span', { class: 'spinner sm' })) + : h('button', { class: 'btn-block', onClick: async () => { + ui.revokeId = g.id; ui.busy = true; render(); + try { await doArkGiftRevoke(g.id); toast(t('giftArkRevoked')); } + catch (e) { toast(e.message); } + ui.busy = false; ui.revokeId = null; render(); + } }, t('giftRevoke'))) : null); + })(), m.txid ? h('div', { class: 'col', style: 'gap:6px' }, h('div', { class: 'small muted' }, t('transactionId')), @@ -1601,6 +1631,11 @@ export function arkFeature(ctx) { return (ark.state.gifts = ark.state.gifts || []); } + // A gift's record id IS the destination vtxo of the send that funded it, so + // the history row and detail view can recognise their own gift sends. + const giftForMovement = (m) => (m && m.type === 'send' && m.vtxoId + ? arkGiftRecords().find((g) => g.id === m.vtxoId) : null); + // Lazily mark records whose vtxo the server reports spent (claimed — or our // own revoke). Throttled: this is called from the gift card's render path. let arkGiftsCheckedAt = 0; @@ -2236,24 +2271,26 @@ export function arkFeature(ctx) { return arkGiftRecords().filter((g) => !g.revoked && !g.claimed) .map((g) => ({ id: g.id, amountSat: g.amountSat, created: g.created })); }, - async arkGiftRevoke(id) { - const g = arkGiftRecords().find((x) => x.id === id); - if (!g) throw new Error('unknown gift'); - const mine = await connectArk(); - const code = encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex)); - let amount; - try { - amount = await sweepArkGift(code, mine.address()); - } catch (e) { - // beaten by the claimer — record it so the row self-heals immediately - if (e && e.giftTaken) { g.claimed = true; mine._save(); } - throw e; - } - g.revoked = true; - mine._save(); - await mine.sync().catch(() => {}); - mine.ackReceives(); // our own sweep-back isn't a "payment received" - return amount; - }, + async arkGiftRevoke(id) { return doArkGiftRevoke(id); }, }; + + async function doArkGiftRevoke(id) { + const g = arkGiftRecords().find((x) => x.id === id); + if (!g) throw new Error('unknown gift'); + const mine = await connectArk(); + const code = encodeArkGiftCode(getNetwork(), g.amountSat, hex.decode(g.secretHex)); + let amount; + try { + amount = await sweepArkGift(code, mine.address()); + } catch (e) { + // beaten by the claimer — record it so the row self-heals immediately + if (e && e.giftTaken) { g.claimed = true; mine._save(); } + throw e; + } + g.revoked = true; + mine._save(); + await mine.sync().catch(() => {}); + mine.ackReceives(); // our own sweep-back isn't a "payment received" + return amount; + } } diff --git a/src/i18n.js b/src/i18n.js index 26bf1fb..d21ee36 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -372,6 +372,8 @@ const STR = { giftRevoke: 'Revoke now', giftRevokeConfirm: 'Revoke this gift now? This spends the coin back into your wallet and disables the link.', giftRevoked: 'Gift revoked', + giftRevokedTag: 'revoked', + giftCopyLinkAgain: 'Copy gift link', copyLink: 'Copy link', claimBody: 'Claim it into a brand-new wallet that only you control.', claimBodyExisting: 'Claim it into your wallet \u201c{name}\u201d.', |
