aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-14 15:24:17 -0700
committerAdam Soltys <[email protected]>2026-08-14 15:24:17 -0700
commit4bd22c0c45ae35388b2a24e673bedcde5df77961 (patch)
tree74eed71c3b477dd14be848f5f4d9d72814a04f47 /src
parent7a7b2c61fe8394844fa306debb84917454ade7b0 (diff)
downloadcoinosv3-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.js28
-rw-r--r--src/features/ark.js77
-rw-r--r--src/i18n.js2
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.',