Skip to content

fix(app): WAVE names track their on-chain target, not their mint-time one - #7

Merged
TheArtofSatoshi merged 1 commit into
mainfrom
fix/wave-target-onchain-sync
Aug 28, 2026
Merged

fix(app): WAVE names track their on-chain target, not their mint-time one#7
TheArtofSatoshi merged 1 commit into
mainfrom
fix/wave-target-onchain-sync

Conversation

@cdonnachie

Copy link
Copy Markdown

A stored glyph's attrs are decoded from the MINT reveal payload only (saveGlyph). Nothing ever read the mod payloads that actually carry a mutable NFT's state, so a WAVE name re-pointed on-chain kept reporting the target it was registered with. reconcileRefTrackedNfts already resolved the singleton's live location by ref and fetched that tx — it just discarded the state, updating only lastTxoId / spent / height.

Consequences: needsTargetUpdate fired on names that were already pointed correctly, and the silent auto-repoint spent a fee setting the target to the address it already had. Any re-decode (a dv bump, a rescan, a wallet rebuild) reset a corrected target back to the registrant's, so the flag returned even after the user fixed it. resolveWaveName served the same stale value, since it answers from db.glyph and only falls through to wave.resolve when no local row matches.

Reading the live target

  • extractMutableModAttrs (@lib/token) pulls the current attrs out of the mod tx, verified against the state hash the contract output commits to. An unrelated input can push glyph-shaped bytes, but only the real payload hashes to the state the covenant carries forward. It's built on decodeGlyphWithPayloadHash, which hashes the raw payload push — re-encoding the decoded object would not reproduce the commitment.
  • The reconcile merges those attrs and stamps modLocation. That stamp is the "attrs are chain-derived" marker: the healthy-skip requires it to match the tracked singleton's txid, so steady state still makes no network calls, and saveGlyph preserves stamped attrs over a re-decode.
  • needsTargetUpdate requires the stamp, so the wallet never spends a fee acting on unverified (reveal-derived) data.

Covered by a real mainnet fixture — the target update for first-of-the-free.rxd (34763132…), whose mint payload and live state disagree. That is exactly the case a wallet rebuilt from seed hits: fetchGlyph re-seeds attrs from the reveal, and without this the name reappears pointing at the wrong address and immediately burns a fee "correcting" itself.

Never hide a token another sync owns

reconcileRefTrackedNfts judges ownership by the MAIN address, so anything held elsewhere on our behalf reads as "transferred away" every pass: a plain swap listing parks the token at the SWAP address, a royalty listing / soulbound mint parks it in a covenant. Those rows belong to recoverSwaps and covenant.ts.

The hide now requires !healthy && !swapPending. That also closes a pre-existing hole — a listing still in the mempool has no confirmed height, so it failed the health check and would have been hidden even before this change.

Heal a reserved token instead of stranding it

syncSwaps resolves a swap row the moment 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, so it can never find it again while the asset sits at the swap address with no way back. recoverSwaps couldn't repair it either: it bailed on if (tracked > 0) continue before looking at the row.

It now heals, mirroring syncCovenants' RESOLVED→ACTIVE self-heal: restore PENDING on positive evidence, backfill the fromGlyph / vout / swapAddress that Cancel needs, and re-materialise the glyph so a token hidden by a main-address ownership check comes back. Scoped to the matching output, so one reserve's evidence can't resurrect a sibling's row.

Card layout

The action column had flexShrink={0}, so at xl it starved the info column and the target-update alert clipped its own heading and button. It is now capped at 60% and wraps, and the alert fills its column.

Dev-server CSP

vite dev served the production policy, whose script-src 'self' blocks the React Fast Refresh preamble the plugin injects inline — over HTTPS too, which the existing HTTP_DEV escape hatch didn't cover — leaving the app dead on "can't detect preamble" behind the boot-recovery screen. Dev now gets a derived policy; vite preview still serves the production one verbatim, and the canonical policy in src/config/csp.ts is untouched.

Testing

  • @photonic/lib — 953 passing / 3 skipped, including 14 new tests across mutableModAttrs.test.ts and waveModPayload.test.ts (hash binding, wrong-ref mods, forged state, plain transfers, the mainnet fixture)
  • @photonic/app — 551 passing, including 4 new recoverSwaps tests (status restore, bookkeeping backfill, sibling-vout guard, intact row + healed visibility)
  • check-types clean in both packages; csp:check passes
  • Verified end to end against mainnet: a name whose singleton had moved to an auth-covenant outpoint recovered into a rebuilt wallet showing the correct live target, with no spurious update prompt

… one

A stored glyph's `attrs` are decoded from the MINT reveal payload only
(`saveGlyph`). Nothing ever read the `mod` payloads that actually carry a
mutable NFT's state, so a WAVE name re-pointed on-chain kept reporting the
target it was registered with. `reconcileRefTrackedNfts` already resolved the
singleton's live location by ref and fetched that tx — it just discarded the
state, updating only `lastTxoId`/`spent`/`height`.

Consequences: `needsTargetUpdate` fired on names that were already pointed
correctly, and the silent auto-repoint spent a fee setting the target to the
address it already had. Any re-decode (a `dv` bump, a rescan, a wallet rebuild)
reset a corrected target back to the registrant's, so the flag returned even
after the user fixed it. `resolveWaveName` served the same stale value, since it
answers from `db.glyph` and only falls through to `wave.resolve` when no local
row matches.

- `extractMutableModAttrs` (@lib/token) pulls the current attrs out of the mod
  tx, verified against the state hash the contract output commits to — an
  unrelated input can push glyph-shaped bytes, but only the real payload hashes
  to the state the covenant carries forward.
- The reconcile merges those attrs and stamps `modLocation`. That stamp is the
  "attrs are chain-derived" marker: the healthy-skip requires it to match the
  tracked singleton's txid, so steady state still makes no network calls, and
  `saveGlyph` preserves stamped attrs over a re-decode.
- `needsTargetUpdate` requires the stamp, so the wallet never spends a fee on
  unverified (reveal-derived) data.

Covered by a real mainnet fixture: the target update for first-of-the-free.rxd
(34763132…), whose mint payload and live state disagree — exactly the case a
wallet rebuilt from seed hits.

Never hide a token another sync owns
------------------------------------
`reconcileRefTrackedNfts` judges ownership by the MAIN address, so anything held
elsewhere on our behalf reads as "transferred away" every pass: a plain swap
listing parks the token at the SWAP address, a royalty listing / soulbound mint
parks it in a covenant. Those rows belong to recoverSwaps and covenant.ts. The
hide now requires `!healthy && !swapPending`, which also closes a PRE-EXISTING
hole — a listing still in the mempool has no confirmed height, so it failed the
health check and would have been hidden even before this change.

Heal a reserved token instead of stranding it
---------------------------------------------
`syncSwaps` resolves a swap row the moment `findSwaps` stops returning its UTXO
and cannot tell "bought" from "cancelled", so one transient empty lookup marks a
LIVE reserve COMPLETE for good — after which Cancel (which searches PENDING
rows) can never find it, while the asset sits at the swap address with no way
back. `recoverSwaps` couldn't repair it either: it bailed on `if (tracked > 0)
continue` before looking at the row. It now heals, mirroring syncCovenants'
RESOLVED→ACTIVE self-heal — restoring PENDING on positive evidence, backfilling
the `fromGlyph`/`vout`/`swapAddress` Cancel needs, and re-materialising the
glyph so a token hidden by a main-address ownership check comes back. Scoped to
the matching output, so one reserve's evidence can't resurrect a sibling's row.

Card layout
-----------
The action column had `flexShrink={0}`, so at xl it starved the info column and
the target-update alert clipped its own text and button. It is now capped at 60%
and wraps, and the alert fills its column.

Dev-server CSP
--------------
`vite dev` served the production policy, whose `script-src 'self'` blocks the
React Fast Refresh preamble the plugin injects inline — over HTTPS too, which
the existing HTTP_DEV escape hatch didn't cover — leaving the app dead on "can't
detect preamble" behind the boot-recovery screen. Dev now gets a derived policy;
`vite preview` still serves the production one verbatim, and the canonical
policy in src/config/csp.ts is untouched (csp:check passes).
@TheArtofSatoshi
TheArtofSatoshi merged commit becf41a into main Aug 28, 2026
1 check failed
@cdonnachie
cdonnachie deleted the fix/wave-target-onchain-sync branch August 28, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants