diff --git a/packages/app/src/__tests__/recoverSwaps.test.ts b/packages/app/src/__tests__/recoverSwaps.test.ts index ad21b51..80741fe 100644 --- a/packages/app/src/__tests__/recoverSwaps.test.ts +++ b/packages/app/src/__tests__/recoverSwaps.test.ts @@ -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 = {}) { + return { txid: SWAP_TXID, vout: 1, tx: "deadbeef", @@ -157,7 +157,19 @@ 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(); @@ -165,6 +177,59 @@ it("skips a UTXO already tracked by an existing db.swap row", async () => { 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 () => { diff --git a/packages/app/src/electrum/worker/NFT.ts b/packages/app/src/electrum/worker/NFT.ts index b3c37fe..34cd95a 100644 --- a/packages/app/src/electrum/worker/NFT.ts +++ b/packages/app/src/electrum/worker/NFT.ts @@ -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, @@ -360,6 +361,19 @@ 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 ( @@ -367,8 +381,10 @@ export class NFTWorker implements Subscription { 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. @@ -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; @@ -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 } } : {}), }); } } @@ -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; diff --git a/packages/app/src/pages/WaveNames.tsx b/packages/app/src/pages/WaveNames.tsx index 148c526..91d47b9 100644 --- a/packages/app/src/pages/WaveNames.tsx +++ b/packages/app/src/pages/WaveNames.tsx @@ -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:") && @@ -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) { @@ -1020,6 +1033,7 @@ function WaveNameCard({ lastTxoId: newTxoId, spent: 0, height: Infinity, + modLocation: txid, }); toast({ @@ -1278,9 +1292,16 @@ function WaveNameCard({ {/* Target update alert for transferred names */} {record.needsTargetUpdate && ( - - - + + + {"⚠️ Target Update Required"} @@ -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 ? ( diff --git a/packages/app/src/swap.ts b/packages/app/src/swap.ts index a2a36a2..e5f3fc8 100644 --- a/packages/app/src/swap.ts +++ b/packages/app/src/swap.ts @@ -7,6 +7,7 @@ import { ElectrumStatus, SwapError, SwapStatus, + TokenSwap, } from "./types"; import db from "./db"; import { ftScript, nftScript, p2pkhScript } from "@lib/script"; @@ -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 = {}; + 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; @@ -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) { @@ -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 diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index eba721d..ef64bd0 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -221,6 +221,22 @@ export interface SmartToken { // Bump GLYPH_DECODE_VERSION (electrum/worker/NFT.ts) whenever the decode adds // a persisted field that existing rows need backfilled. dv?: number; + // Location (txid) whose `mod` payload produced the CURRENT `attrs` of a + // mutable glyph (GLYPH_MUT / WAVE names). + // + // `attrs` is otherwise decoded from the MINT reveal only, so a WAVE name + // re-pointed on-chain would keep showing its registration-time target + // forever. `reconcileRefTrackedNfts` re-reads the mod payload at the + // singleton's live location and stamps that location here; the wallet's own + // update/renew flows stamp their broadcast txid. Two invariants follow: + // + // - `modLocation === ` means `attrs` are + // chain-derived and current, so the reconcile can skip the network round + // trip (and `needsTargetUpdate` is safe to act on). + // - undefined means `attrs` are reveal-derived and UNVERIFIED — e.g. a fresh + // row, or one `saveGlyph` just re-decoded. Never spend a fee (auto-repoint) + // on those; re-resolve first. + modLocation?: string; } export interface Subscription { diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 85fe260..e5b6790 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -19,7 +19,11 @@ import path from "path"; * These headers are only active during `vite dev` and `vite preview`. * They MUST also be set in the production web server config (Nginx/Caddy/etc.). */ -import { SECURITY_HEADERS, CAPACITOR_CSP } from "./src/config/csp"; +import { + SECURITY_HEADERS, + CAPACITOR_CSP, + CONTENT_SECURITY_POLICY, +} from "./src/config/csp"; // Capacitor native build (set by the `build:mobile` script). When true we: // 1. Drop the PWA service worker — it caches stale assets and misbehaves @@ -47,25 +51,53 @@ function capacitorCspPlugin() { }; } -// When driving the dev server over HTTP (HTTP_DEV=1), drop the Content- -// Security-Policy header entirely. The production CSP (canonical in -// src/config/csp.ts) is unchanged — this only affects the local dev/preview -// servers. Two reasons we drop it rather than soften it: -// 1. `upgrade-insecure-requests` forces every asset to https://127.0.0.1, -// which fails (no cert) and silently white-screens the app. -// 2. `script-src 'self'` blocks Vite's React Fast Refresh inline preamble, -// which the React plugin requires — without it you get -// "@vitejs/plugin-react can't detect preamble" and the app aborts. +const isHttpDev = process.env.HTTP_DEV === "1"; + +/** + * Rewrite the canonical CSP for a local server. Production is untouched — the + * policy in src/config/csp.ts (and its tauri/_headers twins) is what ships; + * this only softens the headers Vite itself serves. + * + * `relaxForVite` is for `vite dev`, which CANNOT run under the production + * policy: + * - `script-src 'self'` blocks the React Fast Refresh preamble the React + * plugin injects inline into index.html — you get "@vitejs/plugin-react + * can't detect preamble", the entry module aborts, and boot-recovery.js + * shows the "Photonic Wallet failed to load" screen. This bites over + * HTTPS too, so it can't be conditioned on HTTP_DEV. + * - HMR talks over a ws:/wss: socket that `connect-src` doesn't list. + * `vite preview` serves the real build (no preamble, no HMR) and so keeps the + * production policy verbatim — which is the point of previewing. + * + * Dropping `upgrade-insecure-requests` is the HTTP_DEV case for both servers: + * it forces every asset to https://127.0.0.1, which fails with no cert and + * silently white-screens the app. + */ +function localCsp({ relaxForVite }: { relaxForVite: boolean }): string { + return CONTENT_SECURITY_POLICY.split("; ") + .filter((directive) => !(isHttpDev && directive === "upgrade-insecure-requests")) + .map((directive) => { + if (!relaxForVite) return directive; + // The preamble is inline; react-refresh + dep-optimizer sourcemaps eval. + if (directive.startsWith("script-src ")) + return `${directive} 'unsafe-inline' 'unsafe-eval'`; + // HMR socket. Unscoped ws:/wss: because the dev server is routinely + // reached by LAN IP or hostname, not just localhost. + if (directive.startsWith("connect-src ")) return `${directive} ws: wss:`; + return directive; + }) + .join("; "); +} + +const localHeaders = (relaxForVite: boolean): Record => ({ + ...SECURITY_HEADERS, + "Content-Security-Policy": localCsp({ relaxForVite }), +}); + // The other security headers (X-Frame-Options etc.) stay on for parity with // production. -const DEV_SERVER_HEADERS: Record = - process.env.HTTP_DEV === "1" - ? Object.fromEntries( - Object.entries(SECURITY_HEADERS).filter( - ([k]) => k !== "Content-Security-Policy", - ), - ) - : SECURITY_HEADERS; +const DEV_SERVER_HEADERS = localHeaders(true); +const PREVIEW_SERVER_HEADERS = localHeaders(false); export default defineConfig({ base: "./", @@ -73,7 +105,7 @@ export default defineConfig({ headers: DEV_SERVER_HEADERS, }, preview: { - headers: DEV_SERVER_HEADERS, + headers: PREVIEW_SERVER_HEADERS, }, build: { chunkSizeWarningLimit: 2000, diff --git a/packages/lib/src/__tests__/mutableModAttrs.test.ts b/packages/lib/src/__tests__/mutableModAttrs.test.ts new file mode 100644 index 0000000..7f71698 --- /dev/null +++ b/packages/lib/src/__tests__/mutableModAttrs.test.ts @@ -0,0 +1,161 @@ +/** + * `extractMutableModAttrs` — reading a mutable glyph's CURRENT attrs off the + * `mod` transaction that holds its singleton. + * + * Regression cover for the stale-target bug: a stored glyph's `attrs` are + * decoded from the MINT reveal only, so a WAVE name re-pointed on-chain kept + * reporting its registration-time target, and the wallet believed it still + * "needed a target update" — auto-repointing it, at a fee, to the address it + * already had. + */ +import { describe, it, expect } from "vitest"; + +import rjs from "@radiant-core/radiantjs"; +import { encodeGlyphMutable, extractMutableModAttrs } from "../token"; +import { mutableNftScript, p2pkhScript, parseNftScript } from "../script"; +import Outpoint from "../Outpoint"; + +const { Script, Transaction } = rjs; + +const OWNER = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; +const NEW_TARGET = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"; + +// Token ref (BE) and the mutable contract ref, which is always token ref + 1. +const MINT_TXID = "11".repeat(32); +const NFT_REF_BE = Outpoint.fromUTXO(MINT_TXID, 0).toString(); +const MUT_REF_LE = Outpoint.fromUTXO(MINT_TXID, 1).reverse().toString(); + +/** A tx shaped like `updateWaveTarget`'s: mod scriptSig in, contract state out. */ +function modTx( + attrs: Record, + opts: { contractRef?: string; committedHash?: string } = {} +) { + const glyph = encodeGlyphMutable("mod", { attrs }, 1, 1, 0, 0); + const tx = new Transaction(); + tx.addInput( + new Transaction.Input({ + prevTxId: Buffer.from("22".repeat(32), "hex"), + outputIndex: 1, + script: glyph.scriptSig, + output: new Transaction.Output({ + script: Script.fromHex(p2pkhScript(OWNER)), + satoshis: 1, + }), + }) + ); + // Output 0 is the re-created singleton, output 1 the contract state — the + // helper must find the contract by ref, not by position. + tx.addOutput( + new Transaction.Output({ + script: Script.fromHex(p2pkhScript(OWNER)), + satoshis: 1, + }) + ); + tx.addOutput( + new Transaction.Output({ + script: Script.fromHex( + mutableNftScript( + opts.contractRef ?? MUT_REF_LE, + opts.committedHash ?? glyph.payloadHash + ) + ), + satoshis: 1, + }) + ); + return tx; +} + +// A real mainnet `mod`: the target update for first-of-the-free.rxd +// (34763132ab16bcd95e978fe60bd3dcf218a32dd4481939bf8655e0d215b16089). Kept as a +// fixture because it is the exact shape the wallet must read back — a wallet +// rebuilt from seed re-decodes the MINT payload, whose target is the address the +// name was registered to; only this mod carries where it actually points now. +const REAL_MOD_TX = + "01000000032269149d87e3092ea4e7875e16a9bd020225add5af8a4472598aaed020aa36cb000000006a47304402205f182c77d97d0f19e205a47006c458fa885128e463674efca4322b4c6dff052e02204031d931bd942cb841e797cc806b2388009336efbcbe439d1ac4cdde4ffb73f44121037f7ff8ddbf505673408c9a610750743fb17b1ddf0fff6af4abfd604f29cf7097ffffffff434a711abd50afdbc14c504fad29b3d61016802c499990aa04576d128afe9ab6010000008803676c794c7ab90001656174747273b90005646e616d657166697273742d6f662d7468652d6672656566646f6d61696e63727864667461726765747822314a426a3372514e43555934426b4d65597255524d6d663456396e72573347386b716b7461726765745f74797065676164647265737367657870697265731a6e504c84036d6f6451510000ffffffff2269149d87e3092ea4e7875e16a9bd020225add5af8a4472598aaed020aa36cb010000006b483045022100cdec64e65903d1c77948c51e4a2cf4695fd015f545ee25952ce997756ea01bf9022051de9d7a14373271da63301507c75f4013ce8e486ed2274cd871f27d511bdb764121037f7ff8ddbf505673408c9a610750743fb17b1ddf0fff6af4abfd604f29cf7097ffffffff03010000000000000087d1a789eaf43e250eca09c2a4c3322d5ce1634b7f1063996305e26691cdc84ade8901000000202ad40924c5bb9cfec43e1dfa3167136659700f48635aab692569b7a239a065ac6dbdd8a789eaf43e250eca09c2a4c3322d5ce1634b7f1063996305e26691cdc84ade89000000007576a914bc81661916890412ff3401383102d78439da4aeb88ac0100000000000000ae20bf6c9853788961a06a0865082aaaaf9dd0deee07ac7867dc6711cc5384ff7b6475bdd8a789eaf43e250eca09c2a4c3322d5ce1634b7f1063996305e26691cdc84ade89010000007601207f818c54807e5279e2547a0124957f7701247f75887cec7b7f7701457f757801207ec0caa87e885279036d6f64876378eac0e98878ec01205579aa7e01757e8867527902736c8878cd01d852797e016a7e8778da009c9b6968547a03676c79886d6d511c5951b5e80000001976a914bc81661916890412ff3401383102d78439da4aeb88ac00000000"; +// Its token ref, and the two addresses involved. +const REAL_REF = + "89de4ac8cd9166e205639963107f4b63e15c2d32c3a4c209ca0e253ef4ea89a700000000"; +const REAL_OWNER = "1JBj3rQNCUY4BkMeYrURMmf4V9nrW3G8kq"; +const REGISTERED_TO = "1CPfirXZahPrTb93QouwBfKDoz1ykfcBb7"; + +describe("extractMutableModAttrs", () => { + it("reads the live target off a real mainnet WAVE-name mod", () => { + const tx = new Transaction(REAL_MOD_TX); + const attrs = extractMutableModAttrs(tx, REAL_REF); + + // The mint payload says REGISTERED_TO; the chain says REAL_OWNER. A wallet + // that only decodes the reveal reports the stale one, flags the name as + // needing a target update, and re-points it at a fee to the address it + // already has. + expect(attrs?.target).toBe(REAL_OWNER); + expect(attrs?.target).not.toBe(REGISTERED_TO); + expect(attrs?.name).toBe("first-of-the-free"); + expect(attrs?.domain).toBe("rxd"); + expect(attrs?.expires).toBe(1850756228); + + // The singleton in the same tx rests under the auth-covenant form a target + // update is forced to produce, still paying the owner. + const singleton = tx.outputs + .map((o: { script: { toHex(): string } }) => o.script.toHex()) + .find((h: string) => parseNftScript(h).ref); + expect(singleton?.endsWith(p2pkhScript(REAL_OWNER))).toBe(true); + }); + + it("returns the attrs a mod payload committed for this ref", () => { + const attrs = extractMutableModAttrs( + modTx({ + name: "alice", + domain: "rxd", + target: NEW_TARGET, + target_type: "address", + expires: 1850000000, + }), + NFT_REF_BE + ); + + expect(attrs?.target).toBe(NEW_TARGET); + expect(attrs?.name).toBe("alice"); + expect(attrs?.expires).toBe(1850000000); + }); + + it("rejects a payload that does not hash to the committed state", () => { + // Any input can push glyph-shaped bytes. Only the payload the contract + // output commits to is the real state — otherwise a crafted tx could + // rewrite a name's target in the local db. + const tx = modTx( + { name: "alice", domain: "rxd", target: NEW_TARGET }, + { committedHash: "ff".repeat(32) } + ); + expect(extractMutableModAttrs(tx, NFT_REF_BE)).toBeUndefined(); + }); + + it("ignores a mod belonging to a DIFFERENT ref", () => { + const other = Outpoint.fromUTXO("33".repeat(32), 1).reverse().toString(); + const tx = modTx( + { name: "bob", domain: "rxd", target: NEW_TARGET }, + { contractRef: other } + ); + expect(extractMutableModAttrs(tx, NFT_REF_BE)).toBeUndefined(); + }); + + it("returns undefined for a tx carrying no mutable state (a plain transfer)", () => { + const tx = new Transaction(); + tx.addOutput( + new Transaction.Output({ + script: Script.fromHex(p2pkhScript(OWNER)), + satoshis: 1, + }) + ); + expect(extractMutableModAttrs(tx, NFT_REF_BE)).toBeUndefined(); + }); + + it("returns undefined rather than empty attrs, so callers keep what they have", () => { + expect(extractMutableModAttrs(modTx({}), NFT_REF_BE)).toBeUndefined(); + }); + + it("returns undefined for a malformed ref instead of throwing", () => { + expect( + extractMutableModAttrs(modTx({ name: "alice" }), "nope") + ).toBeUndefined(); + }); +}); diff --git a/packages/lib/src/__tests__/waveModPayload.test.ts b/packages/lib/src/__tests__/waveModPayload.test.ts new file mode 100644 index 0000000..28a4d90 --- /dev/null +++ b/packages/lib/src/__tests__/waveModPayload.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect } from "vitest"; + +import rjs from "@radiant-core/radiantjs"; +import { + encodeGlyph, + encodeGlyphMutable, + decodeGlyphWithPayloadHash, +} from "../token"; +import { mutableNftScript, parseMutableScript } from "../script"; + +const { Script } = rjs; + +const MOD_ATTRS = { + name: "alice", + domain: "rxd", + target: "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", + target_type: "address", + expires: 1850000000, +}; + +const MUT_REF_LE = "ab".repeat(36); + +describe("decodeGlyphWithPayloadHash", () => { + it("recovers the payload and the hash a mutable contract commits to", () => { + // The state hash a `mod` scriptSig produces must equal the one the + // contract output carries — that binding is what makes a mod payload + // trustworthy when it's read back off-chain. + const glyph = encodeGlyphMutable("mod", { attrs: MOD_ATTRS }, 1, 1, 0, 0); + + const decoded = decodeGlyphWithPayloadHash(glyph.scriptSig); + expect(decoded).toBeDefined(); + expect(decoded?.payloadHash).toBe(glyph.payloadHash); + expect(decoded?.payload.attrs).toEqual(MOD_ATTRS); + + const contractOutput = mutableNftScript(MUT_REF_LE, glyph.payloadHash); + expect(parseMutableScript(contractOutput)).toEqual({ + hash: glyph.payloadHash, + ref: MUT_REF_LE, + }); + }); + + it("hashes the RAW payload bytes, not a re-encode of the decoded object", () => { + // decodeGlyph re-parses the payload (files split out, `p` normalized), so + // re-encoding it would not reproduce the committed hash. Guard that the + // hash comes from the original push. + const glyph = encodeGlyphMutable( + "mod", + { attrs: MOD_ATTRS, extra: "field decodeGlyph moves into meta" }, + 1, + 1, + 0, + 0 + ); + expect(decodeGlyphWithPayloadHash(glyph.scriptSig)?.payloadHash).toBe( + glyph.payloadHash + ); + }); + + it("reads a plain (non-mutable) reveal payload too", () => { + const { revealScriptSig, payloadHash } = encodeGlyph({ attrs: MOD_ATTRS }); + const decoded = decodeGlyphWithPayloadHash(Script.fromHex(revealScriptSig)); + expect(decoded?.payloadHash).toBe(payloadHash); + expect(decoded?.payload.attrs).toEqual(MOD_ATTRS); + }); + + it("returns undefined for a script with no glyph payload", () => { + expect( + decodeGlyphWithPayloadHash( + Script.fromASM("OP_DUP OP_HASH160 " + "11".repeat(20) + " OP_EQUAL") + ) + ).toBeUndefined(); + }); +}); diff --git a/packages/lib/src/token.ts b/packages/lib/src/token.ts index acd9d35..039e9b3 100644 --- a/packages/lib/src/token.ts +++ b/packages/lib/src/token.ts @@ -11,8 +11,9 @@ import { SmartTokenRemoteFile, } from "./types"; import { bytesToHex } from "@noble/hashes/utils"; -import { pushMinimalAsm } from "./script"; +import { parseMutableScript, pushMinimalAsm } from "./script"; import { GLYPH_MUT, GLYPH_NFT } from "./protocols"; +import Outpoint from "./Outpoint"; // ESM compatibility const { Script } = rjs; @@ -209,3 +210,111 @@ export function extractRevealPayload( return { revealIndex, glyph: decodeGlyph(script) }; } + +/** + * Decode a glyph payload from a script AND return the hash the mutable + * covenant commits to in its state script — `sha256d()`, the + * same value `encodeGlyphMutable` puts in `payloadHash`. + * + * `decodeGlyph` re-parses the payload into a structured object, so it can't be + * re-encoded and hashed to check it against a covenant (CBOR encoding isn't + * canonical here, and files are split out of the root object). This walks the + * script for the raw payload push and hashes THOSE bytes, letting a caller + * verify that a `mod` scriptSig really produced the state a mutable contract + * output commits to before trusting its attrs. + * + * Returns undefined when the script carries no glyph payload. + */ +export function decodeGlyphWithPayloadHash( + script: Script +): (DecodedGlyph & { payloadHash: string }) | undefined { + const chunks = script.chunks as { opcodenum: number; buf?: Uint8Array }[]; + let raw: Uint8Array | undefined; + chunks.some(({ opcodenum, buf }, index) => { + if ( + !buf || + opcodenum !== 3 || + Buffer.from(buf).toString("hex") !== glyphMagicBytesHex || + chunks.length <= index + 1 + ) { + return false; + } + raw = chunks[index + 1].buf; + return !!raw; + }); + + if (!raw) return undefined; + + const decoded = decodeGlyph(script); + if (!decoded) return undefined; + + return { + ...decoded, + payloadHash: bytesToHex(sha256(sha256(Buffer.from(raw)))), + }; +} + +/** + * Pull the CURRENT attrs of a mutable glyph out of the `mod` transaction that + * holds its singleton. + * + * A mutable NFT's state lives in the CBOR payload pushed by the scriptSig that + * unlocks its mutable-contract UTXO (token ref + 1) — see `encodeGlyphMutable`. + * Nothing re-derives that into a wallet's stored glyph row, whose `attrs` come + * from the MINT reveal, so a WAVE name re-pointed on-chain otherwise keeps + * reporting its registration-time target forever. + * + * The contract output re-created in the same tx commits to `sha256d(payload)` + * in its state script, and the payload is checked against that commitment + * before its attrs are returned: any input can push glyph-shaped bytes, but + * only the real mod payload hashes to the state this ref's covenant carries + * forward. + * + * Returns undefined when `tx` carries no mod state for `refBE` (the mint, a + * plain transfer, another token's mod) or when the payload has no attrs, so a + * caller can leave whatever it already holds untouched. + */ +export function extractMutableModAttrs( + tx: rjs.Transaction, + refBE: string +): { [key: string]: string } | undefined { + // The mutable contract ref is always the token ref + 1. + let mutRefLE: string; + try { + const { txid, vout } = Outpoint.fromString(refBE).toObject(); + mutRefLE = Outpoint.fromUTXO(txid, vout + 1) + .reverse() + .toString(); + } catch { + return undefined; + } + + // The state hash this tx's contract output commits to. + let stateHash: string | undefined; + for (const o of tx.outputs) { + const { hash, ref } = parseMutableScript(o.script.toHex() as string); + if (hash && ref === mutRefLE) { + stateHash = hash; + break; + } + } + if (!stateHash) return undefined; + + // The input whose glyph payload hashes to it. + for (const input of tx.inputs) { + if (!input.script) continue; + let decoded; + try { + decoded = decodeGlyphWithPayloadHash(input.script); + } catch { + continue; // unparseable scriptSig — not the payload we're after + } + if (!decoded || decoded.payloadHash !== stateHash) continue; + const { attrs } = decoded.payload; + if (!attrs || typeof attrs !== "object") return undefined; + const filtered = filterAttrs(attrs) as { [key: string]: string }; + return Object.keys(filtered).length ? filtered : undefined; + } + + return undefined; +}