Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 69 additions & 4 deletions packages/app/src/__tests__/recoverSwaps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,9 @@ it("is idempotent — a second pass does not duplicate the record", async () =>
expect(await db.swap.where({ txid: SWAP_TXID }).count()).toBe(1);
});

it("skips a UTXO already tracked by an existing db.swap row", async () => {
findSwaps.mockResolvedValue([nftReserve(1)]);
await db.swap.put({
/** A tracked (non-recovery) row for the reserve, with overrides. */
function trackedRow(overrides: Record<string, unknown> = {}) {
return {
txid: SWAP_TXID,
vout: 1,
tx: "deadbeef",
Expand All @@ -157,14 +157,79 @@ it("skips a UTXO already tracked by an existing db.swap row", async () => {
toValue: 0,
status: SwapStatus.PENDING,
date: 1,
} as any);
...overrides,
} as any;
}

it("leaves an existing db.swap row intact, but still heals the glyph's visibility", async () => {
// The record must not be overwritten — but a reserved NFT's glyph gets flipped
// to spent:1 by any sync that judges ownership by the MAIN address (its
// singleton pays the swap address, which reads as a transfer away). Nothing
// else flips it back, so without healing here the token vanishes from the
// wallet permanently while the reserve is live on-chain.
await db.glyph.put({ ref: REF_BE, name: "Stuck NFT", tokenType: SmartTokenType.NFT, spent: 1 } as any);
findSwaps.mockResolvedValue([nftReserve(1)]);
await db.swap.put(trackedRow());

await recoverSwaps();

const rows = await db.swap.where({ txid: SWAP_TXID }).toArray();
expect(rows.length).toBe(1);
expect(rows[0].tx).toBe("deadbeef"); // original untouched, not overwritten
expect(rows[0].recovered).toBeUndefined();

const glyph = await db.glyph.where({ ref: REF_BE }).first();
expect(glyph!.spent).toBe(0);
expect(glyph!.swapPending).toBe(true);
const txo = await db.txo.where({ txid: SWAP_TXID, vout: 1 }).first();
expect(glyph!.lastTxoId).toBe(txo!.id);
});

it("restores a live reserve the reaper wrongly resolved, so Cancel can find it", async () => {
// syncSwaps resolves a row as soon as findSwaps stops returning its UTXO and
// cannot tell "bought" from "cancelled", so one transient empty lookup marks a
// live reserve COMPLETE for good. Cancel searches PENDING rows only, so the
// asset is then stranded at the swap address with no way back. findSwaps
// returning the UTXO is positive proof the reserve is still there.
findSwaps.mockResolvedValue([nftReserve(1)]);
await db.swap.put(trackedRow({ status: SwapStatus.COMPLETE }));

await recoverSwaps();

const rows = await db.swap.where({ txid: SWAP_TXID }).toArray();
expect(rows.length).toBe(1);
expect(rows[0].status).toBe(SwapStatus.PENDING);
});

it("backfills the bookkeeping Cancel needs on an incomplete tracked row", async () => {
// cancelSwap needs the glyph ref, the vout and the holding address; a row
// written before those were recorded is otherwise uncancellable.
findSwaps.mockResolvedValue([nftReserve(1)]);
await db.swap.put(
trackedRow({ vout: undefined, fromGlyph: null, fromValue: 0, swapAddress: undefined })
);

await recoverSwaps();

const rows = await db.swap.where({ txid: SWAP_TXID }).toArray();
expect(rows.length).toBe(1);
expect(rows[0].fromGlyph).toBe(REF_BE);
expect(rows[0].vout).toBe(1);
expect(rows[0].fromValue).toBe(1);
expect(rows[0].swapAddress).toBe(SWAP_ADDR);
});

it("does not resurrect a sibling reserve at a different vout of the same tx", async () => {
// Dedup is by txid, but one tx can carry several reserves — evidence that
// vout 1 is live says nothing about vout 5.
findSwaps.mockResolvedValue([nftReserve(1)]);
await db.swap.put(trackedRow({ vout: 5, status: SwapStatus.COMPLETE }));

await recoverSwaps();

const rows = await db.swap.where({ txid: SWAP_TXID }).toArray();
expect(rows.length).toBe(1);
expect(rows[0].status).toBe(SwapStatus.COMPLETE);
});

it("scans BOTH coin-type swap addresses (unlocked) and records the holding address", async () => {
Expand Down
71 changes: 67 additions & 4 deletions packages/app/src/electrum/worker/NFT.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import db from "@app/db";
import Outpoint, { reverseRef } from "@lib/Outpoint";
import { verifyTransactionHash, hexToBytes } from "@lib/crypto";
import {
extractMutableModAttrs,
extractRevealPayload,
filterAttrs,
isImmutableToken,
Expand Down Expand Up @@ -360,15 +361,30 @@ export class NFTWorker implements Subscription {
// height check below, such a glyph would be treated as "done" and latch at
// height:Infinity forever, showing PENDING even after the tx confirms.
// Keep re-resolving until we've recorded a real (non-Infinity) height.
//
// A healthy row still re-resolves while its attrs have never been read
// off-chain (`modLocation` unset, or naming a location the singleton has
// since left). A mutable glyph's `attrs` come from the MINT reveal
// (saveGlyph), so a WAVE name re-pointed by a `mod` would otherwise show
// its registration-time target forever.
//
// Health is tracked rather than just short-circuiting, because it also
// gates the hide path below: a healthy row must NEVER be hidden by this
// pass. Its singleton can legitimately rest in a covenant (listed for
// sale / soulbound, materialized by covenant.ts), which from here is
// indistinguishable from a transfer away.
let healthy = false;
if (g.spent === 0 && g.lastTxoId !== undefined) {
const cur = await db.txo.get(g.lastTxoId);
if (
cur &&
cur.spent === 0 &&
cur.byRef === 1 &&
cur.height !== Infinity
)
continue;
) {
healthy = true;
if (g.modLocation === cur.txid) continue;
}
}

// Resolve the live location of this singleton ref.
Expand Down Expand Up @@ -448,9 +464,30 @@ export class NFTWorker implements Subscription {
// address (sold/transferred). If no singleton for this ref was found at
// the current location (transient fetch/parse miss, or an in-flight
// update), leave the row visible — a target update must never hide it.
if (foundForRefElsewhere && g.spent !== 1) {
//
// `!healthy && !g.swapPending` keeps a RESERVED token safe. `ourTail` is
// this wallet's main address, so anything held elsewhere on our behalf
// reads as `foundForRefElsewhere` every single time: a plain swap
// listing parks the token at the SWAP address (Swap.tsx), and a royalty
// listing / soulbound mint parks it in a covenant script (covenant.ts).
// Those rows are owned by the swap + covenant sync, which restore them
// with a working Cancel; hiding them here makes the token vanish from
// the wallet instead. `swapPending` also covers the window the health
// check can't — a listing still in the mempool has no confirmed height.
if (
foundForRefElsewhere &&
!healthy &&
!g.swapPending &&
g.spent !== 1
) {
await db.glyph.update(g.id, { spent: 1 });
}
// Still record that this location was inspected, so a healthy row
// (covenant-held, or otherwise not paying to us right now) doesn't
// re-resolve on every single sync.
if (healthy && g.modLocation !== loc) {
await db.glyph.update(g.id, { modLocation: loc });
}
continue;
}
const found = foundOurs;
Expand Down Expand Up @@ -503,11 +540,26 @@ export class NFTWorker implements Subscription {
})) as number;
}

if (g.lastTxoId !== txoId || g.spent !== 0 || g.height !== finalHeight) {
// Re-read the mutable state at this location so `attrs` track the chain
// rather than the mint. Stamp `modLocation` either way: an inspected
// location with no mod payload (e.g. a never-modified singleton) still
// means the reveal-derived attrs ARE current, and the stamp is what lets
// the healthy-skip above go quiet again next pass.
const attrs = extractMutableModAttrs(tx, g.ref);

if (
g.lastTxoId !== txoId ||
g.spent !== 0 ||
g.height !== finalHeight ||
g.modLocation !== loc ||
attrs
) {
await db.glyph.update(g.id, {
lastTxoId: txoId,
spent: 0,
height: finalHeight,
modLocation: loc,
...(attrs ? { attrs: { ...g.attrs, ...attrs } } : {}),
});
}
}
Expand Down Expand Up @@ -1060,6 +1112,17 @@ export class NFTWorker implements Subscription {
record.id = prior.id;
if (prior.swapPending !== undefined)
record.swapPending = prior.swapPending;
// Never regress chain-derived mutable state to the mint's. `record.attrs`
// above comes from the REVEAL payload, so re-decoding a WAVE name (a dv
// bump, a rescan, a restore) would otherwise reset its target to the
// registrant's address — making the wallet believe a correctly-pointed
// name still "needs a target update", and auto-repointing it at a fee.
// A `modLocation` marks attrs that a `mod` payload produced; those win,
// with the reveal's as the base so newly-decoded keys still backfill.
if (prior.modLocation) {
record.attrs = { ...record.attrs, ...prior.attrs };
record.modLocation = prior.modLocation;
}
record.fresh = prior.fresh; // don't re-flash the "fresh mint" state
if (!receivedTxo) {
record.spent = prior.spent;
Expand Down
32 changes: 27 additions & 5 deletions packages/app/src/pages/WaveNames.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,17 @@ export default function WaveNames() {
}
}

// Check if target needs update (transferred from another owner)
// Check if target needs update (transferred from another owner).
//
// Only when `attrs` are chain-derived (`modLocation` stamped by the ref
// reconcile or by this wallet's own update). Reveal-derived attrs are
// the REGISTRATION-time target: acting on those flags a name that was
// already re-pointed on-chain, and the auto-repoint below would spend a
// fee setting the target to what it already is. The reconcile stamps
// the row on the next sync pass, so this resolves itself in seconds.
const target = attrs.target || "";
const needsTargetUpdate = !!(
token.modLocation &&
target &&
target !== wallet.value.address &&
!target.startsWith("ref:") &&
Expand Down Expand Up @@ -921,6 +929,11 @@ function WaveNameCard({
lastTxoId: newTxoId,
spent: 0,
height: Infinity,
// These attrs mirror the `mod` payload just broadcast, so mark them
// chain-derived at that location: the sync's ref reconcile then treats
// the row as verified (no redundant re-read) and a later re-decode
// won't clobber the new target back to the mint's.
modLocation: txid,
});

if (!opts?.silent) {
Expand Down Expand Up @@ -1020,6 +1033,7 @@ function WaveNameCard({
lastTxoId: newTxoId,
spent: 0,
height: Infinity,
modLocation: txid,
});

toast({
Expand Down Expand Up @@ -1278,9 +1292,16 @@ function WaveNameCard({

{/* Target update alert for transferred names */}
{record.needsTargetUpdate && (
<Alert status="warning" size="sm" borderRadius="md" py={2}>
<AlertIcon boxSize={4} />
<VStack align="start" spacing={1} flex={1}>
<Alert
status="warning"
size="sm"
borderRadius="md"
py={2}
w="100%"
minW={0}
>
<AlertIcon boxSize={4} flexShrink={0} />
<VStack align="start" spacing={1} flex={1} minW={0}>
<Text fontSize="sm" fontWeight="bold">
{"⚠️ Target Update Required"}
</Text>
Expand Down Expand Up @@ -1338,7 +1359,8 @@ function WaveNameCard({
flexWrap="wrap"
rowGap={2}
justify={{ base: "flex-start", xl: "flex-end" }}
flexShrink={0}
minW={0}
maxW={{ base: "100%", xl: "60%" }}
>
{/* Primary badge or Set Primary button */}
{isPrimary ? (
Expand Down
83 changes: 74 additions & 9 deletions packages/app/src/swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ElectrumStatus,
SwapError,
SwapStatus,
TokenSwap,
} from "./types";
import db from "./db";
import { ftScript, nftScript, p2pkhScript } from "@lib/script";
Expand Down Expand Up @@ -253,6 +254,47 @@ export const syncSwaps = async () => {
* module-level guard prevents two overlapping runs (connect sweep + Resync) from
* racing the non-atomic check-then-insert and double-inserting a record.
*/
/**
* Bring a tracked swap record back in line with the chain.
*
* `findSwaps` only returns UTXOs that are STILL sitting at the swap address, so
* a row it matches is demonstrably still reserved. `syncSwaps` cannot tell a
* completed swap from a cancelled one (there's no cheap way to fetch the
* spending tx), so it resolves a row the moment the UTXO stops showing up — and
* a single transient/empty lookup permanently marks a live reserve COMPLETE.
* That row then fails every "find the pending listing" lookup (Cancel included)
* while the asset is still locked away at the swap address, with no way back.
*
* Restoring it on positive evidence mirrors syncCovenants' RESOLVED→ACTIVE
* self-heal. Missing bookkeeping that Cancel needs (`fromGlyph`, `vout`,
* `swapAddress`) is backfilled at the same time — an early recovery row, or one
* written before those fields existed, is otherwise uncancellable.
*
* Scoped to the matching output: dedup is by txid, but one tx can carry several
* reserves, and a sibling's row must not be resurrected by this one's evidence.
*/
const healTrackedReserve = async (
tracked: TokenSwap[],
utxo: ElectrumUtxo,
refBE: string | undefined,
swapAddress: string
) => {
for (const row of tracked) {
if (row.id === undefined) continue;
if (row.vout !== undefined && row.vout !== utxo.tx_pos) continue;

const patch: Partial<TokenSwap> = {};
if (row.status !== SwapStatus.PENDING) patch.status = SwapStatus.PENDING;
if (refBE && !row.fromGlyph) patch.fromGlyph = refBE;
if (row.vout === undefined) patch.vout = utxo.tx_pos;
if (!row.fromValue) patch.fromValue = utxo.value;
if (!row.swapAddress) patch.swapAddress = swapAddress;
if (Object.keys(patch).length === 0) continue;

await db.swap.update(row.id, patch).catch(() => undefined);
}
};

let recovering = false;
export const recoverSwaps = async () => {
if (electrumStatus.value !== ElectrumStatus.CONNECTED) return;
Expand All @@ -273,15 +315,6 @@ export const recoverSwaps = async () => {
}

for (const { contractType, utxo } of found) {
// Skip anything already tracked (incl. a prior recovery). Dedup by txid
// to match SwapMissing / the existing reaper. On a query error, assume
// tracked so we never create a duplicate.
const tracked = await db.swap
.where({ txid: utxo.tx_hash })
.count()
.catch(() => 1);
if (tracked > 0) continue;

const refShort = utxo.refs?.[0]?.ref;
let refBE: string | undefined;
if (refShort) {
Expand All @@ -292,6 +325,38 @@ export const recoverSwaps = async () => {
}
}

// Already tracked (incl. a prior recovery)? Don't create a second
// record — but DO heal the existing one, the way discoverCovenants /
// syncCovenants heal covenant-held tokens. Dedup by txid to match
// SwapMissing / the existing reaper; a null read means the query failed,
// so assume tracked and never risk a duplicate.
const tracked = await db.swap
.where({ txid: utxo.tx_hash })
.toArray()
.catch(() => null);
if (tracked === null || tracked.length) {
if (tracked)
await healTrackedReserve(tracked, utxo, refBE, swapAddress);
// Repoint the glyph at the live reserve. A reserved NFT's glyph can be
// flipped to `spent:1` by any sync that judges ownership by the MAIN
// address (its singleton pays the swap address, which reads as a
// transfer away), and nothing else ever flips it back: this scan used
// to `continue` here, and syncSwaps only reaps. The token then
// vanishes from the wallet permanently even though the reserve is
// live on-chain. Re-materialising is idempotent.
if (refBE && contractType === ContractType.NFT) {
await materializeCovenantUtxo({
ref: refBE,
txid: utxo.tx_hash,
vout: utxo.tx_pos,
script: nftScript(swapAddress, reverseRef(refBE)),
value: utxo.value,
height: utxo.height,
});
}
continue;
}

// Recreate a minimal PENDING record — enough to surface in My Swaps and
// Cancel. No PSRT (`tx: ""`); want side unknown so it defaults to RXD.
// `swapAddress` records WHICH (possibly alternate coin-type) address holds
Expand Down
Loading
Loading