feat(connect): PSBT signing, NFT mint/swap flows, and error callbacks - #5
Conversation
Extends the photonic-connect deep-link protocol with five new request types built on top of the existing sign-request handshake: - psbt-sign-request: sign a Radiant PSBT (new BIP-174-profile module in packages/lib/src/psbt, byte-compatible with Radiant Core), returning the signed PSBT or broadcasting when requested. - mint-request: mint an NFT from dApp-supplied metadata/content, funded from the wallet's own UTXOs, with a broadcast:false dry-run mode. - swap-offer-request / swap-accept-request / swap-cancel-request: list, buy, and cancel NFT-for-RXD offers built on the existing PSRT swap primitive, including an optional marketplace platform fee alongside creator royalty. Also adds a generic error callback (distinct from success and explicit reject) so a dApp can tell a genuine wallet-side failure (locked, insufficient funds, not found, already spent) apart from the user declining, across every request type. Includes an approval-screen preview for swap-accept-request that resolves and displays the actual NFT being purchased (not just price) by looking up the PSRT's reserved prevout, and hardens the PSBT signer to cryptographically verify existing partial signatures rather than trusting a pubkey-hash match alone.
Code reviewVerdict: high-quality, security-conscious work — LGTM with a few non-blocking suggestions. I found no exploitable flaw in the trust boundary. Everything below is hardening/UX, not a bug that loses funds. Verification (run against this PR's head in an isolated worktree)
Security analysisThe trust boundary is handled well:
Suggestions (non-blocking, roughly in priority order)
Code qualityConsistently strong. The PSBT module is defensive in the ways that matter: canonical varint enforcement, duplicate-key rejection, trailing-data checks, bigint value plumbing past 2^53, and unknown-field preservation for byte-identical round-trips. Commentary explains why at every non-obvious decision, the error taxonomy is typed rather than string-matched, and test coverage is dense and adversarial (123 protocol tests, fabricated-signature and non-canonical-varint cases, a Reviewed with Claude Code |
…red callback builder Closes the remaining review items from #5. Mint preview showed the RAW dApp-supplied bytes while `buildMintPayload` embedded the sanitized ones, so for an SVG the user approved a rendering of markup that never reached the chain. The sanitize-or-passthrough decision now lives once, in `embeddableContentBytes`, which both the payload builder and the preview call — they cannot drift apart. `feeAddress` was charset-validated only, so a corrupted address reached `p2pkhScript` and threw mid-flow, after approval. It is now base58-decoded at parse (`cleanPayoutAddress`) for a clear early rejection. That check is deliberately network-agnostic — radiantjs infers the network from the version byte and transport code has no business knowing which chain the wallet is on — so the network comparison lives in `assertFeeAddressNetwork`, in swapFlow, measured against the wallet's own address rather than a signal so the two can never disagree. The eight `build*CallbackUrl` functions repeated the same fragment assembly and size cap; they now share `composeCallbackUrl`. `buildCallbackUrl` keeps its long-standing UNCAPPED behaviour via an explicit opt-out rather than silently gaining a cap: a challenge is bounded only by MAX_MESSAGE_LENGTH (4096) and the nonce is a segment of it, so capping could turn a working sign callback into a manual fallback. `buildErrorCallbackUrl` also gains the privacy contract it was missing — it forwards raw error text to a third party, so throw sites must keep messages free of balances, addresses and UTXO detail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
Extends the
photonic-connectdeep-link protocol with five new request types built on top of the existingsign-requesthandshake, so a dApp (e.g. realm.rxd) can drive real wallet actions, not just message signing:psbt-sign-request— sign a Radiant PSBT. New BIP-174-profile module (packages/lib/src/psbt), byte-compatible with Radiant Core (bareCTxOutunder key0x00, legacy-serialized unsigned tx, FORKID sighash viaradiantjs'sSighash.sign). Returns the (possibly partial) signed PSBT, or finalizes/broadcasts and returns a txid when the request opts in withbroadcast: true.mint-request— mint an NFT from dApp-supplied metadata + content (embedded or remote), self-funded from the wallet's own UTXOs (never dApp-specified inputs), with abroadcast: falsedry-run mode to inspect the built commit/reveal hex before anything is sent.swap-offer-request/swap-accept-request/swap-cancel-request— list, buy, and cancel NFT-for-RXD offers on top of the existing raw-tx-hex "PSRT" swap primitive, including an optional marketplace platform fee (feeRxd/feeAddress) that sits alongside — but distinct from — creator royalty.#error=<code>&message=...), distinct from both success and explicit user-reject, fired from every request type's failure path so a dApp can tell "wallet-side failure" (locked, insufficient funds, not found, already spent) apart from "user declined" instead of just hanging until its own timeout.Also:
signPsbt/finalizePsbtnow cryptographically verify an existing partial signature (viaSighash.verify) before trusting it, rather than a pubkey-hash match alone — closes a gap where a malformed/malicious PSBT could get finalized as "complete" without a valid signature.See
docs/psbt.md,docs/mint-request.md, anddocs/swap-request.mdfor the full wire-format writeups, including the naming disambiguation between the new BIP-174 PSBT container, the pre-existing "PSRT" swap convention, and the unrelatedDeployMethod: "psbt"mint-reveal helper.Test plan
pnpm --filter @photonic/lib test— 942 passed / 22 intentionally-skipped regtestpnpm --filter @photonic/app test— 504 passedtsc --noEmitclean in bothpackages/libandpackages/app