fix(app): WAVE names track their on-chain target, not their mint-time one - #7
Merged
Merged
Conversation
… 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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A stored glyph's
attrsare decoded from the MINT reveal payload only (saveGlyph). Nothing ever read themodpayloads that actually carry a mutable NFT's state, so a WAVE name re-pointed on-chain kept reporting the target it was registered with.reconcileRefTrackedNftsalready resolved the singleton's live location by ref and fetched that tx — it just discarded the state, updating onlylastTxoId/spent/height.Consequences:
needsTargetUpdatefired 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 (advbump, a rescan, a wallet rebuild) reset a corrected target back to the registrant's, so the flag returned even after the user fixed it.resolveWaveNameserved the same stale value, since it answers fromdb.glyphand only falls through towave.resolvewhen 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 ondecodeGlyphWithPayloadHash, which hashes the raw payload push — re-encoding the decoded object would not reproduce the commitment.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, andsaveGlyphpreserves stamped attrs over a re-decode.needsTargetUpdaterequires 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:fetchGlyphre-seedsattrsfrom 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
reconcileRefTrackedNftsjudges 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 torecoverSwapsandcovenant.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
syncSwapsresolves a swap row the momentfindSwapsstops 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.recoverSwapscouldn't repair it either: it bailed onif (tracked > 0) continuebefore looking at the row.It now heals, mirroring
syncCovenants' RESOLVED→ACTIVE self-heal: restore PENDING on positive evidence, backfill thefromGlyph/vout/swapAddressthat 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 atxlit 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 devserved the production policy, whosescript-src 'self'blocks the React Fast Refresh preamble the plugin injects inline — over HTTPS too, which the existingHTTP_DEVescape 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 previewstill serves the production one verbatim, and the canonical policy insrc/config/csp.tsis untouched.Testing
@photonic/lib— 953 passing / 3 skipped, including 14 new tests acrossmutableModAttrs.test.tsandwaveModPayload.test.ts(hash binding, wrong-ref mods, forged state, plain transfers, the mainnet fixture)@photonic/app— 551 passing, including 4 newrecoverSwapstests (status restore, bookkeeping backfill, sibling-vout guard, intact row + healed visibility)check-typesclean in both packages;csp:checkpasses