From 2de9f940544b5c78a7b8e34b4cfbfabef6593fef Mon Sep 17 00:00:00 2001 From: cdonnachie Date: Sun, 26 Jul 2026 06:56:24 -0400 Subject: [PATCH] feat(connect): PSBT signing, NFT mint/swap flows, and error callbacks 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. --- docs/mint-request.md | 133 ++ docs/psbt.md | 255 ++++ docs/swap-request.md | 296 ++++ packages/app/src/components/Unlock.tsx | 8 +- .../components/connect/MintRequestPanel.tsx | 220 +++ .../components/connect/MintResultPanel.tsx | 109 ++ .../components/connect/PsbtRequestPanel.tsx | 328 +++++ .../components/connect/PsbtResultPanel.tsx | 127 ++ .../connect/SwapAcceptRequestPanel.tsx | 194 +++ .../connect/SwapAcceptResultPanel.tsx | 73 + .../connect/SwapCancelRequestPanel.tsx | 158 +++ .../connect/SwapCancelResultPanel.tsx | 73 + .../connect/SwapOfferRequestPanel.tsx | 150 ++ .../connect/SwapOfferResultPanel.tsx | 74 + .../src/connect/__tests__/mintFlow.test.ts | 133 ++ .../src/connect/__tests__/protocol.test.ts | 1230 +++++++++++++++++ packages/app/src/connect/mintFlow.ts | 239 ++++ packages/app/src/connect/protocol.ts | 1061 +++++++++++++- packages/app/src/connect/psbtFlow.ts | 225 +++ packages/app/src/connect/swapFlow.ts | 508 +++++++ packages/app/src/opfs.ts | 1 - packages/app/src/pages/Connect.tsx | 537 ++++++- packages/app/src/swap.ts | 3 +- packages/lib/package.json | 1 + .../lib/src/__tests__/psbt.regtest.test.ts | 247 ++++ packages/lib/src/__tests__/psbt.test.ts | 701 ++++++++++ .../__tests__/swap-load-output-order.test.ts | 47 + packages/lib/src/index.ts | 3 + packages/lib/src/psbt/analyze.ts | 182 +++ packages/lib/src/psbt/errors.ts | 40 + packages/lib/src/psbt/index.ts | 45 + packages/lib/src/psbt/keyvalue.ts | 188 +++ packages/lib/src/psbt/psbt.ts | 334 +++++ packages/lib/src/psbt/sign.ts | 265 ++++ packages/lib/src/swapOutputs.ts | 13 +- packages/lib/src/types/radiantjs.d.ts | 25 + 36 files changed, 8167 insertions(+), 59 deletions(-) create mode 100644 docs/mint-request.md create mode 100644 docs/psbt.md create mode 100644 docs/swap-request.md create mode 100644 packages/app/src/components/connect/MintRequestPanel.tsx create mode 100644 packages/app/src/components/connect/MintResultPanel.tsx create mode 100644 packages/app/src/components/connect/PsbtRequestPanel.tsx create mode 100644 packages/app/src/components/connect/PsbtResultPanel.tsx create mode 100644 packages/app/src/components/connect/SwapAcceptRequestPanel.tsx create mode 100644 packages/app/src/components/connect/SwapAcceptResultPanel.tsx create mode 100644 packages/app/src/components/connect/SwapCancelRequestPanel.tsx create mode 100644 packages/app/src/components/connect/SwapCancelResultPanel.tsx create mode 100644 packages/app/src/components/connect/SwapOfferRequestPanel.tsx create mode 100644 packages/app/src/components/connect/SwapOfferResultPanel.tsx create mode 100644 packages/app/src/connect/__tests__/mintFlow.test.ts create mode 100644 packages/app/src/connect/mintFlow.ts create mode 100644 packages/app/src/connect/psbtFlow.ts create mode 100644 packages/app/src/connect/swapFlow.ts create mode 100644 packages/lib/src/__tests__/psbt.regtest.test.ts create mode 100644 packages/lib/src/__tests__/psbt.test.ts create mode 100644 packages/lib/src/psbt/analyze.ts create mode 100644 packages/lib/src/psbt/errors.ts create mode 100644 packages/lib/src/psbt/index.ts create mode 100644 packages/lib/src/psbt/keyvalue.ts create mode 100644 packages/lib/src/psbt/psbt.ts create mode 100644 packages/lib/src/psbt/sign.ts diff --git a/docs/mint-request.md b/docs/mint-request.md new file mode 100644 index 0000000..bf05f0c --- /dev/null +++ b/docs/mint-request.md @@ -0,0 +1,133 @@ +# Connect: minting an NFT (`mint-request`) + +Status: **Shipped — v1 scope.** NFTs only (no fungible/dMint/container mints, +no author/container references, no royalty/policy/encryption/timelock +options). Immutable Glyph NFTs only. + +This extends `photonic-connect` (`packages/app/src/connect/protocol.ts`) with +a third request type, alongside `sign-request` and `psbt-sign-request` +(`docs/psbt.md`), so a dApp — e.g. a game minting item NFTs to a player's +wallet — can ask Photonic to mint on the user's behalf. + +## 1. Why this isn't a PSBT request + +Radiant NFT minting is a **commit + reveal** pair of transactions where the +reveal input spends a custom covenant script (`OP_HASH256` hash-lock + ref +opcodes), not a plain P2PKH output. Photonic's PSBT signer (`@lib/psbt`) only +recognizes plain P2PKH inputs — by design, matching why Radiant Core itself +doesn't use its own PSBT machinery to mint either (`rpcglyph.cpp` signs +directly with wallet keys; see the design note in this repo's history). So +minting gets its own request type built on `@lib/mint`'s existing +`mintToken`, not on the PSBT signer. + +## 2. Funding model: the wallet self-funds + +The dApp does **not** specify which UTXOs to spend. Photonic funds the mint +from its own RXD balance using the same coin selection (`fundTx`, +`packages/lib/src/coinSelect.ts`) the local Mint page already uses — the +dApp only supplies *what* to mint, never *which coins*. This sidesteps the +UTXO-discovery problem entirely (the dApp would otherwise need to query a +public ElectrumX server for the user's UTXOs, as it would for a +`psbt-sign-request`). + +## 3. Request / result shape + +```ts +type MintRequest = { + protocol: "photonic-connect"; v: 1; t: "mint-request"; + name: string; + description?: string; + license?: string; + attrs?: Record; + main: { mime: string; data: string } // embedded, base64/base64url + | { mime: string; url: string }; // remote pointer + feeRate?: number; // photons/byte override + id?: string; origin?: string; app?: string; callback?: string; +}; + +type MintResult = { + protocol: "photonic-connect"; v: 1; t: "mint-result"; + id?: string; + commitTxid: string; + revealTxid: string; + ref: string; // canonical NFT ref (BE txid ‖ BE vout hex) — use this to look the token up later +}; +``` + +Minting **always broadcasts** — unlike `psbt-sign-request`, there is no +"return unsigned" option, matching the local Mint page's own behavior. The +approval screen is the only checkpoint; once approved, both transactions are +signed and sent immediately. + +## 4. Content validation + +`main` content is validated more strictly than the local Mint page's own +uploader, because it's dApp/attacker-controlled input, not the user's own +file picker: + +- **MIME allow-list** (`MINT_ALLOWED_MIME_TYPES`): `image/png`, `image/jpeg`, + `image/gif`, `image/webp`, `image/svg+xml`, `text/plain`, + `application/json`. Anything else is rejected at the protocol layer. +- **Size**: embedded content is capped at the same 512 KB on-chain limit the + local Mint page enforces (`mintEmbedMaxBytes`, + `packages/app/src/config.json`) — checked twice: a coarse base64 + char-length pre-check in `protocol.ts` (`MAX_MINT_DATA_LEN`), then the + exact decoded byte length in `mintFlow.ts`. +- **SVG**: sanitized through the existing DOMPurify pipeline + (`packages/app/src/svgSanitize.ts`) before being embedded on-chain — same + treatment the local Mint page gives user-uploaded SVGs. +- **Attrs**: capped to 32 keys, each ≤64 chars, and passed through + `@lib/token`'s `filterAttrs` (string/number/boolean values only, <100 + chars) — anything else is silently dropped, never rejected outright. +- **Remote (`url`) content**: only the pointer is embedded on-chain, not the + bytes; still must be an absolute http(s) URL. + +## 5. Flow + +1. Deep link: `#/connect?req=` (same route as the other + two request types; `Connect.tsx` dispatches on `t`). +2. `MintRequestPanel` shows a content preview (rendered `` for image + MIME types, a file-type label otherwise), name/description/license/attrs, + requesting origin, and a warning that approval broadcasts immediately. +3. On approve: `mintFlow.ts`'s `mintFromRequest` — + - builds the Glyph v2 payload (`buildMintPayload`: decode, size-check, + sanitize), + - fetches the wallet's own RXD UTXOs (`db.txo`, `ContractType.RXD`, + unspent), + - calls `mintToken("nft", {method:"direct", ...}, wif, coins, payload, [], feeRate)`, + - broadcasts the commit tx, then the reveal tx (with the same + "missing inputs → resync → retry once" resilience the local Mint page + uses for the same race condition), + - triggers `manualSync()` so the new NFT shows up in the wallet's own UI + promptly. +4. Result returns to the dApp the same way as the other request types: QR / + copy by default, or automatically via the origin-bound `callback` + fragment (`buildMintCallbackUrl`) when the request arrived via deep link: + ``` + #id=&commitTxid=<..>&revealTxid=<..>&ref=<..> + ``` + +## 6. Out of scope for v1 + +- Fungible tokens, dMint, containers, authority/soulbound covenants. +- Author/container references (`in`/`by`) — these require the user to already + own and co-spend a specific existing glyph, which the dApp has no way to + discover without its own UTXO/indexer query; left for a future iteration. +- Royalty/policy metadata, encryption, timelock. +- Mutable NFTs. + +## 7. Minimal dApp example + +```ts +const req = { + protocol: "photonic-connect", v: 1, t: "mint-request", + name: "Realm Sword", + description: "A legendary blade forged in the Realm", + attrs: { rarity: "legendary", power: 42 }, + main: { mime: "image/png", data: base64PngBytes }, + origin: "https://realm.rxd", + callback: "https://realm.rxd/mint-callback", +}; +location.href = `https://wallet.example/#/connect?req=${encodeReqParam(req)}`; +// On return: https://realm.rxd/mint-callback#id=...&commitTxid=...&revealTxid=...&ref=... +``` diff --git a/docs/psbt.md b/docs/psbt.md new file mode 100644 index 0000000..2121a98 --- /dev/null +++ b/docs/psbt.md @@ -0,0 +1,255 @@ +# Radiant PSBT support + +Status: **Shipped — v1 scope.** Signing is limited to plain P2PKH inputs the +wallet's main address owns; token-bearing (FT/NFT/vault) inputs are always +refused. See §6 for what's explicitly out of scope. + +This document specifies the wire format Photonic Wallet's PSBT module +(`packages/lib/src/psbt/`) implements, and the `psbt-sign-request` extension +to the `photonic-connect` deep-link protocol +(`packages/app/src/connect/protocol.ts`) that lets a dApp hand the wallet a +PSBT to sign the same way it already requests a message signature. + +> **Don't confuse this with PSRT or the `"psbt"` `DeployMethod`.** This +> codebase has three differently-shaped things with confusingly similar +> names: +> +> 1. **This module** — an actual BIP-174-style structured container (magic +> bytes, key-value maps, explicit per-input prevout data), general-purpose, +> interoperable with Radiant Core's own PSBT RPCs. +> 2. **PSRT** ("Partially Signed Radiant Transaction", `docs/swap-request.md`) +> — the pre-existing swap-offer mechanism. Despite the acronym, it is +> **not** a container format: it's raw network-serialized transaction hex +> with one input signed `SIGHASH_SINGLE|ANYONECANPAY|FORKID` +> (`@lib/transfer`'s `partiallySigned`). "PSRT" borrowed PSBT's name, not +> its format. +> 3. **`DeployMethod: "psbt"` / `revealPsbt()`** (`packages/lib/src/mint.ts`, +> `packages/lib/src/types.ts`) — a pre-existing bundle/presale NFT-reveal +> helper. Same raw-tx-hex, `SIGHASH_SINGLE|ANYONECANPAY` technique as +> PSRT, just applied to a mint reveal instead of a swap. Also not a +> container format, also unrelated to this module. +> +> None of the three overlap in code (no shared functions or types), but the +> vocabulary collision is real — when you see "PSBT" or "psrt" anywhere in +> this codebase, check which of the three it actually means before assuming +> BIP-174 semantics apply. + +## 1. Why not plain BIP-174 + +Radiant's reference node, Radiant Core, implements PSBT — but not stock +BIP-174. It's the **Bitcoin ABC-lineage, segwit-stripped variant** (forked +from Bitcoin Core ~0.17), and it diverges from mainline Bitcoin PSBT in ways +that matter for wire compatibility. Photonic's module targets that variant +specifically, verified against Radiant Core's `src/psbt.h` / `src/psbt.cpp` +and `src/wallet/psbtwallet.cpp`, so a PSBT built by either wallet is usable +by the other via `walletcreatefundedpsbt` / `walletprocesspsbt` / +`finalizepsbt`. + +## 2. Wire format + +Container framing follows BIP-174: magic bytes, then a sequence of +key-value maps (global, one per input, one per output), each map terminated +by a zero-length key. Keys are `varint keylen ‖ keytype(varint) ‖ keydata`; +values are `varint vallen ‖ value`. A repeated full key within one map is a +hard parse error. Varints are Bitcoin CompactSize and must be minimally +encoded — a non-canonical encoding (e.g. `0xfd 0x05 0x00` for the value 5) +is rejected, matching Radiant Core's `ReadCompactSize`. + +``` +magic: 70 73 62 74 ff ("psbt\xff") +``` + +| Scope | Key | Name | Value | +| --- | --- | --- | --- | +| Global | `0x00` | `PSBT_GLOBAL_UNSIGNED_TX` | Legacy-serialized unsigned tx, every scriptSig empty | +| Input | `0x00` | `PSBT_IN_UTXO` | **Bare `CTxOut`**: int64-LE value ‖ varint-len scriptPubKey | +| Input | `0x02` | `PSBT_IN_PARTIAL_SIG` | keydata = 33/65-byte pubkey; value = DER sig ‖ sighash byte | +| Input | `0x03` | `PSBT_IN_SIGHASH` | 4-byte LE `uint32` | +| Input | `0x04` | `PSBT_IN_REDEEMSCRIPT` | hex script (parsed & preserved, not consumed by the P2PKH signer) | +| Input | `0x06` | `PSBT_IN_BIP32_DERIVATION` | preserved verbatim | +| Input | `0x07` | `PSBT_IN_FINAL_SCRIPTSIG` | hex scriptSig | +| Output | `0x00` / `0x02` | redeem script / bip32 derivation | preserved verbatim, not interpreted | + +Transport is **standard base64** (matching Radiant Core's `EncodeBase64`); +the connect protocol also accepts base64url on parse, for convenience inside +a URL. Unknown key-value pairs anywhere in the container are preserved and +re-emitted byte-identically — required for combiner semantics and forward +compatibility. + +### 2.1 The `PSBT_IN_UTXO` divergence + +Mainline BIP-174 has two prevout fields: `non_witness_utxo` (0x00, a full +previous transaction) for legacy inputs, and `witness_utxo` (0x01, a bare +`CTxOut`) for segwit inputs. **Radiant has no segwit** — there is no witness +marker, no witness stack, nothing under key `0x01`. Instead, key `0x00` +itself carries a bare `CTxOut`: 8-byte LE value followed by the varint-length +scriptPubKey. This is safe because Radiant's sighash (§2.2) commits to +exactly that output's script and value — attaching a wrong one just produces +a signature that fails to verify, it can't be used to trick the signer into +overpaying or misdirecting funds. + +Key types `0x01` (witness_utxo), `0x05` (witness_script), and `0x08` +(final_scriptwitness) don't exist in this profile; a parser must not emit +them, and treats them as unknown data if present (never interpreted). + +### 2.2 Sighash + +Default and required: `SIGHASH_ALL | SIGHASH_FORKID` (`0x41`). Every +signature Photonic produces sets `SIGHASH_FORKID`; a sighash without it is +refused outright (Radiant Core's `walletprocesspsbt` does the same — +`"Signature must use SIGHASH_FORKID"`). `SIGHASH_NONE` is refused by policy +(it would let outputs be swapped after signing). `SIGHASH_SINGLE` and +`SIGHASH_ANYONECANPAY`, alone or combined with `ALL`/`SINGLE`, are accepted +— the swap-offer PSRT convention (`packages/lib/src/transfer.tsx` → +`partiallySigned`) already relies on `SINGLE|ANYONECANPAY|FORKID`. + +Radiant's FORKID sighash preimage is **not** stock BIP-143: it inserts an +extra `hashOutputHashes` field (a push-ref-aware output commitment) between +`nSequence` and `hashOutputs`. `@radiant-core/radiantjs` implements this +already (`Transaction.Sighash.sign` / `GetHashOutputHashes`), which is why +`signPsbt` always signs through that call rather than a hand-rolled +preimage — reimplementing BIP-143 directly here would silently produce +invalid signatures. + +## 3. API — `packages/lib/src/psbt` + +Pure module, no app/React dependency. Values are `bigint` internally +(photon amounts can exceed 2^53); convert at the radiantjs boundary via +`bnFromValue(v.toString())`. + +```ts +parsePsbt(bytes): Psbt; serializePsbt(psbt): Uint8Array; +psbtFromBase64(b64): Psbt; psbtToBase64(psbt): string; + +signPsbt(psbt, wif, opts?): { psbt; signedIndexes; skipped }; +// Signs inputs whose declared utxo script matches p2pkhScript(address-of-wif). +// Throws PsbtError for policy violations: TOKEN_BEARING_INPUT (any input +// spending a token-bearing output — overridable via allowTokenBearingInputs, +// off by default), MISSING_FORKID, DISALLOWED_SIGHASH. + +finalizePsbt(psbt): { psbt; complete }; // complete once every input has a final scriptSig +extractTx(psbt): string; // raw hex; throws NOT_FINALIZED otherwise + +analyzePsbt(psbt, { ownScripts?, net? }): PsbtAnalysis; +// Pure UI-facing summary: per-input/-output rows, totals, fee (undefined if +// any prevout is unknown), and typed warnings (TOKEN_BEARING_INPUT, +// FEE_UNKNOWN, HIGH_FEE, SIGHASH_*, ALREADY_SIGNED, …). +``` + +`packages/app/src/connect/psbtFlow.ts` layers the wallet's own state on top: +`enrichPsbt` cross-checks each input against `db.txo` (ownership, spent +status, script/value agreement) and, for external inputs the PSBT didn't +attach a utxo for, best-effort resolves one from the wallet's Electrum +connection purely for fee display — never for signing. + +## 4. Connect protocol integration + +Extends `photonic-connect` (`packages/app/src/connect/protocol.ts`) with a +second request type alongside the existing `sign-request`: + +```ts +type PsbtSignRequest = { + protocol: "photonic-connect"; v: 1; t: "psbt-sign-request"; + psbt: string; // base64 or base64url + broadcast?: boolean; // only the literal `true` opts in — see below + id?: string; origin?: string; app?: string; callback?: string; +}; + +type PsbtSignResult = { + protocol: "photonic-connect"; v: 1; t: "psbt-sign-result"; + id?: string; + psbt?: string; // present unless a broadcast succeeded + txid?: string; // present once a broadcast is accepted + complete: boolean; +}; +``` + +Deep link: `#/connect?req=` (same route as `sign-request` +— `packages/app/src/pages/Connect.tsx` dispatches on `t`). A raw PSBT blob is +**never** auto-accepted as a bare string the way a challenge is; an explicit +envelope with `t: "psbt-sign-request"` is required, so intent is always +unambiguous. + +**Return vs. broadcast** is the requester's choice, not the wallet's: +- `broadcast` omitted or anything other than the literal `true` → the wallet + always returns the (possibly still partial) signed PSBT. +- `broadcast: true` **and** every input ends up signed → the wallet + finalizes, extracts, and broadcasts, returning a `txid` instead. +- `broadcast: true` but the PSBT is still incomplete after the wallet's own + signature(s) → no error; the partial PSBT is returned as usual, so + multi-party flows keep working. +- Broadcast attempted but rejected by the network → the signed PSBT is + still returned, plus an error surfaced in the UI, so nothing is lost. + +The approval screen (`PsbtRequestPanel`) always shows a broadcast-vs-return +badge before the user approves anything. + +**Callback return** reuses the existing origin-binding rules +(`cleanCallback`): a `callback` is only ever honored when its origin exactly +matches the envelope's declared `origin`. The result rides back in the URL +**fragment**, never the query, so it never reaches a server's access or +proxy logs: + +``` +#id=&txid=&complete=true +#id=&psbt=&complete= +``` + +If the composed callback URL would exceed 8 KB (`MAX_CALLBACK_URL_LEN`), the +wallet returns `undefined` rather than risk a silently truncated result — +the user gets the manual copy/paste return instead. The `psbt` envelope +field itself is capped at 64 KB (`MAX_PSBT_LEN`); larger PSBTs need a +transport this protocol doesn't police. + +## 5. Safety checks (v1) + +| Check | Where | Outcome | +| --- | --- | --- | +| Any input (owned or not) spends a token-bearing output | `signPsbt` + UI | Hard refuse — see rationale in §2.1's sibling: co-signing risks destroying someone's token | +| Wallet's own record disagrees with the PSBT's declared utxo for an owned input | `enrichPsbt` | Hard refuse | +| Owned input already marked spent locally | `enrichPsbt` | Warning only (may be racing the mempool) | +| Sighash missing `SIGHASH_FORKID`, or `SIGHASH_NONE` | `signPsbt` | Hard refuse | +| `SIGHASH_SINGLE` / `ANYONECANPAY` | `analyzePsbt` | Warning shown in the approval UI | +| Fee rate above `MAX_REASONABLE_FEE_RATE` (`packages/lib/src/feePolicy.ts`) | `analyzePsbt` + UI | Warning; broadcast path additionally treats it as a hard stop | +| Fee can't be computed (an input's value is unresolved) | `analyzePsbt` | Shown as "fee unknown", never guessed | + +## 6. Out of scope for v1 + +- Signing token-bearing (FT/NFT/vault covenant) inputs — refused outright. +- A PSBT combiner UI (multi-party signing works by handing the base64 + PSBT to each signer in turn; no merge-of-independently-signed-copies step + exists yet). +- Signing with the swap subaccount key. +- A `packages/cli` command (the lib module is ready for one; natural + follow-up). +- OS-level deep-link registration — `#/connect?req=` is a web hash route, + not a custom URL scheme; auto-return is web-only (`canAutoReturn`). + +## 7. Minimal dApp example + +```ts +import rjs from "@radiant-core/radiantjs"; +const { Transaction, Script } = rjs; + +// Build the unsigned tx exactly as you would for a normal send — every +// scriptSig left empty. +const tx = new Transaction(); +tx.addInput(new Transaction.Input({ prevTxId, outputIndex, script: new Script() })); +tx.addOutput(new Transaction.Output({ script: destScript, satoshis: value })); + +// Wrap it as a PSBT: global unsigned tx + one CTxOut utxo field per input. +const psbt = { + unsignedTxHex: tx.toString(), + inputs: [{ utxo: { script: prevScript, value: prevValueBigint }, partialSigs: new Map(), bip32: [], unknown: [] }], + outputs: [{ entries: [] }], + unknownGlobals: [], +}; + +const req = { + protocol: "photonic-connect", v: 1, t: "psbt-sign-request", + psbt: psbtToBase64(psbt), broadcast: true, + origin: "https://your.app", callback: "https://your.app/psbt-callback", +}; +location.href = `https://wallet.example/#/connect?req=${encodeReqParam(req)}`; +// On return: https://your.app/psbt-callback#id=...&txid=...&complete=true +``` diff --git a/docs/swap-request.md b/docs/swap-request.md new file mode 100644 index 0000000..a180cd4 --- /dev/null +++ b/docs/swap-request.md @@ -0,0 +1,296 @@ +# Connect: listing, buying, and cancelling NFTs +# (`swap-offer-request` / `swap-accept-request` / `swap-cancel-request`) + +Status: **Shipped — v1 scope.** NFT-for-RXD only, private mode only (no +on-chain advertisement). Built for a marketplace dApp that runs its own +listing index (e.g. realm.rxd) and only needs the wallet to produce/consume +signed offers. + +Extends `photonic-connect` with three request types wrapping the wallet's +existing private-swap primitive (`packages/app/src/pages/Swap.tsx` / +`SwapLoad.tsx`): one for the maker (list an item), one for the taker +(complete a purchase), one for the maker again (cancel a listing). + +## 1. This is not `@lib/psbt`'s PSBT + +The `psrt` field in the offer/accept request types is **raw +partially-signed transaction hex** — the pre-existing "Partially Signed +Radiant Transaction" convention the Swap page already uses (a maker signs a +single input with `SIGHASH_SINGLE|ANYONECANPAY|FORKID` via `@lib/transfer`'s +`partiallySigned`, committing to a single output). It has nothing to do with +the BIP-174 container `docs/psbt.md` describes — different wire format, +different module, don't cross the streams. + +This codebase has a third look-alike too: `DeployMethod: "psbt"` / +`revealPsbt()` (`packages/lib/src/mint.ts`, `packages/lib/src/types.ts`), a +bundle/presale NFT-reveal helper that uses the *exact same* raw-tx-hex, +`SIGHASH_SINGLE|ANYONECANPAY` technique as PSRT — just for a mint reveal +instead of a swap. Three names (PSRT, `"psbt"` DeployMethod, and the real +BIP-174 module), no shared code between them — see the naming note at the +top of `docs/psbt.md` for the full rundown before assuming any two of them +mean the same thing. + +## 2. Maker: `swap-offer-request` + +```ts +type SwapOfferRequest = { + protocol: "photonic-connect"; v: 1; t: "swap-offer-request"; + ref: string; // the NFT's canonical ref (BE txid ‖ BE vout hex), owned by this wallet + priceRxd: number; // asking price in RXD + mode: "private"; // the only supported value — "broadcast" is rejected + id?: string; origin?: string; app?: string; callback?: string; +}; + +type SwapOfferResult = { + protocol: "photonic-connect"; v: 1; t: "swap-offer-result"; + id?: string; + psrt: string; // raw tx hex — hand this to your own indexer + reserveTxid: string; // the swap-subaccount outpoint the PSRT's input spends + reserveVout: number; + swapAddress: string; // the swap subaccount address the NFT was reserved into + ref: string; // echoes the request's ref + payoutAddress: string; // the maker's own main address — sale proceeds and reclaims land here + priceRxd: number; // echoes the request's priceRxd — the reservation's actual signed price +}; +``` + +**Approving this broadcasts a real transaction.** `mode: "private"` only +means no on-chain *advertisement* gets published (unlike the Swap page's +broadcast mode, which additionally publishes an RSWP advertisement to a +public swap index) — it does not mean nothing moves. The wallet still: + +1. Looks up `ref` in its own `db.glyph` — the token must exist, be an NFT + (v1 scope excludes fungible tokens), and not already have a pending swap. +2. Moves it into the swap subaccount via `@lib/transfer`'s + `transferNonFungible` (a real broadcast, `nft_swap_prepare` in the + wallet's activity log). +3. Builds and signs the PSRT (`partiallySigned`, swap-subaccount key) over + that reserved output, committing to a single RXD output back to the + maker's own address for `priceRxd`. +4. Persists a `PENDING` row to `db.swap` with the real negotiated price — + the same record the local Swap page writes for a hand-made offer. Without + this the offer would be invisible in the wallet's own "Pending Swaps" list + until the next on-chain discovery sweep, and even then would show up as a + degraded stub that lost the price. +5. Returns the PSRT plus the **reservation outpoint** (`reserveTxid`/ + `reserveVout`) — realm.rxd indexes all of it in its own `POST + /market/listings/prepare`-fed backend; Photonic never publishes anywhere. + +**Why the reservation outpoint matters**: the PSRT itself is opaque raw tx +hex — realm.rxd never parses it. `reserveTxid`/`reserveVout` is a direct +on-chain handle to the offer with no parsing needed: check whether that +specific outpoint is still unspent (the same `isUtxoUnspent` check +`swap-accept-request` itself performs before completing a purchase) to know +whether the offer is still live. Unspent = live; spent = completed or +cancelled — see §4's stale-listing reconciliation guidance. + +**Why `payoutAddress` and `priceRxd` are echoed back**: realm.rxd binds a +listing's seller to whatever address the player was logged in with at +listing time, which is a session concept, not an on-chain one. `payoutAddress` +is the maker's actual signing address — sale proceeds and reclaims are +provably tied to it (compare it against `get_by_ref`'s post-reclaim resolved +address in §4), independent of which session created the listing. +`priceRxd` is just the request's own field echoed back, so a caller can +assert the reservation matches the price it's about to advertise without +writing a PSRT parser. + +**No on-chain expiry field is included** — the RSWP v3 timelocked-refund +covenant that would give a reservation an automatic reclaim time exists in +`@lib/swapRefundCovenant` but reserving into it is gated off wallet-wide +(`SWAP_RESERVE_INTO_REFUND_COVENANT` in `Swap.tsx`, `docs/swap-offer-expiry-cancellation.md`). +Until that ships, realm.rxd's own soft listing TTL is the only expiry there is. + +## 3. Cancel: `swap-cancel-request` + +```ts +type SwapCancelRequest = { + protocol: "photonic-connect"; v: 1; t: "swap-cancel-request"; + ref: string; // the same ref the original swap-offer-request carried + id?: string; origin?: string; app?: string; callback?: string; +}; + +type SwapCancelResult = { + protocol: "photonic-connect"; v: 1; t: "swap-cancel-result"; + id?: string; + txid: string; // the reclaim transaction's txid +}; +``` + +Keyed by **`ref`, not an outpoint** — the wallet already tracks one pending +swap per glyph (`swapPending` on the `db.glyph` row, `fromGlyph` on the +`db.swap` row), so the ref alone identifies which offer to cancel. realm.rxd +already has the ref on every listing; no need to track or pass a reservation +outpoint just to cancel. + +Approving looks the offer up in `db.swap` (must be `PENDING`), then calls the +exact same `cancelSwap()` the local Swap page's own "Cancel" button uses — +self-spends the reserved UTXO back to the wallet's main address, flips the +token's `swapPending` flag off, and marks the `db.swap` row `CANCEL`. This +**always broadcasts** on approval, same as accept — there is no +"return unsigned" option. + +Rejecting a cancel request returns `#id=...&rejected=true` via the generic +reject-callback mechanism every request type shares (`buildRejectCallbackUrl`). + +## 4. How realm.rxd detects a cancellation (or sale) made outside itself + +Two ways, in order of preference: + +1. **Check the reservation outpoint directly** (needs `reserveTxid`/ + `reserveVout` from the offer result, §2): query whether that specific + `txid:vout` is still unspent — unambiguous, no lag, and by inspecting the + spending transaction's outputs you can tell a reclaim (pays back to the + maker's own address) from a completed sale (pays the maker's price to + output 0 per `buildSwapCompletionOutputs`, asset to the buyer at output 1). +2. **Fall back to `get_by_ref`** if you don't have the outpoint on hand for + an older listing: for an active listing the NFT sits at the swap + subaccount; after a reclaim it's back at the seller's own address (which + realm.rxd already stores as the listing's seller address), and after a + sale it resolves to the buyer. **Caveat**: right after a listing is + created there can be brief indexer lag before the reservation transaction + itself is indexed, during which the NFT can still transiently resolve to + the seller's main address — don't treat a freshly-created listing as + voided without a short grace window. + +Either way, run this reconciliation lazily on market reads and — most +importantly — **inside `preparePurchase`, before handing a buyer a PSRT**, +so a buyer never attempts to complete an offer the seller already reclaimed. +Detected-void listings should be marked cancelled/expired automatically. + +## 5. Taker: `swap-accept-request` + +```ts +type SwapAcceptRequest = { + protocol: "photonic-connect"; v: 1; t: "swap-accept-request"; + psrt: string; // the maker's PSRT, raw tx hex + feeRxd?: number; // marketplace fee amount; requires feeAddress + feeAddress?: string; // marketplace fee recipient; requires feeRxd + id?: string; origin?: string; app?: string; callback?: string; +}; + +type SwapAcceptResult = { + protocol: "photonic-connect"; v: 1; t: "swap-accept-result"; + id?: string; + txid: string; +}; +``` + +`feeRxd`/`feeAddress` are realm.rxd's own platform commission (the "fee X RXD +(2.5%)" shown next to each listing) — distinct from any creator royalty the +token itself carries. Both fields must be present together or both absent; +one without the other is rejected as a caller mistake, not silently dropped. +`feeAddress` must be an actual Radiant address — a config placeholder that +never got resolved to a real value (e.g. a literal `"MY_ENV_VAR_NAME"` +string) fails validation with "feeAddress is not a valid address" before +anything is built. + +Completing a purchase (`mintFromRequest`'s sibling, `acceptSwapOffer` in +`packages/app/src/connect/swapFlow.ts`) mirrors `SwapLoad.tsx`'s +`ViewSwap.signTransaction` exactly: + +1. Parses the PSRT, re-fetches its reserved prevout from Electrum, and + confirms it's still unspent (`isUtxoUnspent`) — a stale/already-filled + offer is rejected before anything is built. +2. Resolves the offered token's metadata and checks for enforced creator + royalty (`getTokenRoyalty` / `@lib/royaltyTerms`) — best-effort, same + caveat as the local Swap page: a PSRT-based swap can't make royalty + *unstrippable* (that needs the separate royalty covenant listing flow); + this only adds the payout when the maker's own token metadata says it's + enforced. +3. Assembles outputs via `@lib/swapOutputs`'s `buildSwapCompletionOutputs`: + `[makerPayment(0), assetToTaker(1), ...royalty, ...platformFee, ...funding]`. + The **platform fee** (`feeRxd`/`feeAddress`) got its own slot in that + function (`platformFeeOutputs`) alongside — but distinct from — creator + royalty, since they're different concepts that happen to share a + position (index 2+, after the asset, never displacing the maker's + `SIGHASH_SINGLE`-committed output[0]). +4. Funds, signs (reusing the maker's scriptSig verbatim at input 0), and + broadcasts via `broadcastSwapCompletion`. + +This **always broadcasts** on approval — there is no "return unsigned" +option, matching `mint-request`'s behavior. The approval screen shows the +price and fee terms parsed directly from the PSRT (no network round-trip +needed for that part); full validation happens at approve-time and any +failure (stale offer, insufficient funds, disallowed token type) surfaces as +a toast rather than blocking the initial preview. + +## 6. Wallet-side failures: the error callback + +Every request type in this document — offer, accept, cancel — shares one +more generic callback beyond success (`buildXCallbackUrl`) and explicit +reject (`buildRejectCallbackUrl`): an **error callback** +(`buildErrorCallbackUrl` in `packages/app/src/connect/protocol.ts`), fired +from the same `try/catch` in `Connect.tsx` that today only shows an in-app +toast ("Unable to list", "Unable to complete purchase", "Unable to cancel"). + +Without it, a genuine wallet-side failure — the NFT isn't in the wallet, +insufficient funds, the wallet is locked, or (on accept) the offer was +already completed or cancelled — left a deep-linked caller with no signal at +all: not a success, not a decline, just silence until its own timeout. A +human watching the screen sees the toast; a dApp waiting on the callback +does not. + +```ts +type ConnectErrorCode = + | "locked" | "not_found" | "insufficient_funds" + | "already_spent" | "invalid_request" | "unknown"; +``` + +Fired as `#id=...&error=&message=` (fragment only, same as every +other result). `code` is a best-effort classification +(`classifyConnectError`) matched against the underlying error's message — +treat it as a coarse hint for fast branching, and always show `message` too +since the classifier can fall back to `"unknown"` for a message it doesn't +recognize. This fires for the wallet-locked case on every handler (`sign`, +`psbt-sign`, `mint`, `swap-offer`, `swap-accept`, `swap-cancel`) and from +each handler's catch block — it is not specific to the swap flows, just +documented here because the swap flows are where "offer already spent" and +"not found" concretely show up. + +## 7. Out of scope for v1 + +- `mode: "broadcast"` (on-chain advertisement) — rejected outright with a + message pointing at the local Swap page. +- Fungible-token or RXD-for-token offers — only NFT-for-RXD. +- Token-for-token swaps, container/link tokens. +- The RSWP v3 timelocked-refund covenant reservation path + (`SWAP_RESERVE_INTO_REFUND_COVENANT`, still gated off in the local Swap + page too) — no on-chain expiry field exists in `swap-offer-result` yet. + +## 8. Minimal dApp example + +```ts +// Maker: list an owned NFT privately. +const offerReq = { + protocol: "photonic-connect", v: 1, t: "swap-offer-request", + ref: itemRef, priceRxd: 10, mode: "private", + origin: "https://realm.rxd", callback: "https://realm.rxd/list-callback", +}; +location.href = `https://wallet.example/#/connect?req=${encodeReqParam(offerReq)}`; +// On success: https://realm.rxd/list-callback#psrt=...&reserveTxid=...&reserveVout=0 +// &swapAddress=...&ref=...&payoutAddress=...&priceRxd=10 +// realm.rxd stores all of this in its own listings index. +// On a wallet-side failure instead: https://realm.rxd/list-callback#error=insufficient_funds&message=... + +// Taker: buy a listed NFT, adding a 2.5% platform fee. +const acceptReq = { + protocol: "photonic-connect", v: 1, t: "swap-accept-request", + psrt: listingPsrt, feeRxd: 0.25, feeAddress: "1PlatformFeeAddress...", + origin: "https://realm.rxd", callback: "https://realm.rxd/buy-callback", +}; +location.href = `https://wallet.example/#/connect?req=${encodeReqParam(acceptReq)}`; +// On success: https://realm.rxd/buy-callback#txid=... +// If the offer was already bought or reclaimed: +// https://realm.rxd/buy-callback#error=already_spent&message=... + +// Maker: cancel a listing, keyed by ref. +const cancelReq = { + protocol: "photonic-connect", v: 1, t: "swap-cancel-request", + ref: itemRef, + origin: "https://realm.rxd", callback: "https://realm.rxd/cancel-callback", +}; +location.href = `https://wallet.example/#/connect?req=${encodeReqParam(cancelReq)}`; +// On success: https://realm.rxd/cancel-callback#txid=... +// Reject instead: https://realm.rxd/cancel-callback#rejected=true +// If there's no such pending offer: https://realm.rxd/cancel-callback#error=not_found&message=... +``` diff --git a/packages/app/src/components/Unlock.tsx b/packages/app/src/components/Unlock.tsx index 7c8b00c..7578561 100644 --- a/packages/app/src/components/Unlock.tsx +++ b/packages/app/src/components/Unlock.tsx @@ -39,14 +39,18 @@ export default function Unlock() { disclosure.onClose(); if (onCloseCallback.current) { - onCloseCallback.current(true); + const cb = onCloseCallback.current; + onCloseCallback.current = undefined; + cb(true); } }; const onClose = () => { disclosure.onClose(); if (onCloseCallback.current) { - onCloseCallback.current(false); + const cb = onCloseCallback.current; + onCloseCallback.current = undefined; + cb(false); } }; diff --git a/packages/app/src/components/connect/MintRequestPanel.tsx b/packages/app/src/components/connect/MintRequestPanel.tsx new file mode 100644 index 0000000..bb4020c --- /dev/null +++ b/packages/app/src/components/connect/MintRequestPanel.tsx @@ -0,0 +1,220 @@ +/** + * Approval screen for an incoming `mint-request`. Shows exactly what NFT is + * about to be minted — name, description, attributes, and a preview of the + * content — before `Connect.tsx` calls `mintFromRequest`. Minting always + * broadcasts (there is no "return unsigned" option, unlike PSBT requests), + * so this is the only checkpoint before funds actually move. + */ +import { + Alert, + AlertDescription, + AlertIcon, + Badge, + Box, + Button, + Code, + HStack, + Stack, + Text, + Wrap, + WrapItem, +} from "@chakra-ui/react"; +import { MdImage, MdInsertDriveFile } from "react-icons/md"; +import Card from "@app/components/Card"; +import type { MintRequest } from "@app/connect/protocol"; + +const IMAGE_MIME_TYPES = new Set([ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", +]); + +function ContentPreview({ main }: { main: MintRequest["main"] }) { + if ("url" in main) { + return ( + + + Remote content + + + {main.url} + + + ); + } + if (IMAGE_MIME_TYPES.has(main.mime)) { + return ( + + NFT content preview + + ); + } + return ( + + + {main.mime} + + ); +} + +export default function MintRequestPanel({ + request, + signerAddress, + locked, + autoReturn, + busy, + onApprove, + onReject, +}: { + request: MintRequest; + signerAddress: string; + locked: boolean; + autoReturn: boolean; + busy?: boolean; + onApprove: () => void; + onReject: () => void; +}) { + const attrEntries = request.attrs ? Object.entries(request.attrs) : []; + + return ( + + + + + Mint an NFT + + + {request.origin || request.app ? ( + + + Requested by + + + {request.app ? `${request.app} — ` : ""} + {request.origin ?? "(no origin provided)"} + + + ) : ( + + + + No origin was provided. Only continue if you trust where this + request came from. + + + )} + + + + + Name + + + {request.name} + + + {request.description && ( + <> + + Description + + + {request.description} + + + )} + + {request.license && ( + <> + + License + + {request.license} + + )} + + {attrEntries.length > 0 && ( + <> + + Attributes + + + {attrEntries.map(([k, v]) => ( + + + {k}: {String(v)} + + + ))} + + + )} + + {request.broadcast === false ? ( + + + + Dry run: this will build and sign the mint transactions but{" "} + not broadcast them. Nothing is sent or spent — you'll get + the raw hex back to inspect. + + + ) : ( + + + + Minting broadcasts two transactions immediately and cannot be + undone. Network fees are paid from your wallet's RXD balance. + + + )} + + + Minting to {signerAddress || "(no wallet address)"} + + + {autoReturn && ( + + After approving you will be sent back to{" "} + {request.app || "the app"} at {request.origin}, which + receives the result automatically. + + )} + + {locked && ( + + You will be asked to unlock your wallet to mint. + + )} + + + + + + + + ); +} diff --git a/packages/app/src/components/connect/MintResultPanel.tsx b/packages/app/src/components/connect/MintResultPanel.tsx new file mode 100644 index 0000000..fa36f76 --- /dev/null +++ b/packages/app/src/components/connect/MintResultPanel.tsx @@ -0,0 +1,109 @@ +/** + * Result screen after a `mint-request` completes: the commit + reveal txids + * (or, for a dry run — `broadcast: false` — the raw unsent hex instead) and + * the NFT's canonical ref, for the app to look up afterward. Mirrors + * `PsbtResultPanel`'s shape. + */ +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Button, + Code, + Divider, + Stack, + Text, + useClipboard, +} from "@chakra-ui/react"; +import { MdCheck, MdContentCopy } from "react-icons/md"; +import Card from "@app/components/Card"; +import type { MintResult } from "@app/connect/protocol"; + +function CopyField({ label, value }: { label: string; value: string }) { + const { onCopy, hasCopied } = useClipboard(value); + return ( + + + {label} + + + {value} + + + + ); +} + +export default function MintResultPanel({ + result, + onDone, +}: { + result: MintResult; + onDone: () => void; +}) { + return ( + + {result.broadcast ? ( + + + + Minted + + The NFT was broadcast to the network. + + + + ) : ( + + + + Built & signed — not broadcast + + Nothing was sent. Decode the hex below (e.g.{" "} + decoderawtransaction) to verify + before using this in production. + + + + )} + + + {result.broadcast ? ( + <> + + + + ) : ( + <> + + + + )} + + + + + + + ); +} diff --git a/packages/app/src/components/connect/PsbtRequestPanel.tsx b/packages/app/src/components/connect/PsbtRequestPanel.tsx new file mode 100644 index 0000000..59ca645 --- /dev/null +++ b/packages/app/src/components/connect/PsbtRequestPanel.tsx @@ -0,0 +1,328 @@ +/** + * Approval screen for an incoming `psbt-sign-request`. Shows exactly what the + * user is about to sign — which inputs are theirs vs. external, where the + * money goes, the fee (or an honest "unknown" when it can't be computed), + * and whether the wallet will hand back a PSBT or broadcast a finished + * transaction — before `Connect.tsx` calls `signAndMaybeBroadcast`. + * + * Every field here is either the wallet's own signal state or the output of + * `psbtFlow.enrichPsbt`; nothing is trusted from the request except by way + * of that enrichment (which cross-checks against `db.txo`). + */ +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Badge, + Box, + Code, + Divider, + Flex, + HStack, + Stack, + Text, + Button, +} from "@chakra-ui/react"; +import { MdCloudUpload, MdUndo, MdWarning } from "react-icons/md"; +import Card from "@app/components/Card"; +import { photonsToRXD } from "@lib/format"; +import type { PsbtSignRequest } from "@app/connect/protocol"; +import type { EnrichedPsbt } from "@app/connect/psbtFlow"; +import type { PsbtInputSummary, PsbtOutputSummary } from "@lib/psbt"; + +const SIGHASH_LABELS: Record = { + SIGHASH_SINGLE: "commits to only one output", + SIGHASH_ANYONECANPAY: "other inputs may still be added", +}; + +function amount(value?: bigint) { + return value !== undefined ? `${photonsToRXD(Number(value))} RXD` : "—"; +} + +function InputRow({ + input, + mismatch, + alreadySpent, +}: { + input: PsbtInputSummary; + mismatch: boolean; + alreadySpent: boolean; +}) { + return ( + + + + {input.address ?? `${input.txid.slice(0, 12)}…:${input.vout}`} + + + + {input.mine ? "Your wallet" : "External"} + + {input.tokenBearing && ( + + Token + + )} + {alreadySpent && ( + + Already spent + + )} + {mismatch && ( + + Mismatch + + )} + {input.finalized && ( + + Finalized + + )} + + + + {amount(input.value)} + + + ); +} + +function OutputRow({ output }: { output: PsbtOutputSummary }) { + return ( + + + + {output.address ?? "(non-standard output)"} + + {output.mine && ( + + To your wallet + + )} + {output.tokenBearing && ( + + Token + + )} + + + {amount(output.value)} + + + ); +} + +export default function PsbtRequestPanel({ + request, + enriched, + signerAddress, + locked, + autoReturn, + busy, + onApprove, + onReject, +}: { + request: PsbtSignRequest; + enriched: EnrichedPsbt; + signerAddress: string; + locked: boolean; + autoReturn: boolean; + busy?: boolean; + onApprove: () => void; + onReject: () => void; +}) { + const { analysis, inputs: enrichment, blockers, signableCount } = enriched; + const canApprove = blockers.length === 0; + const sighashWarnings = analysis.warnings.filter((w) => + w.startsWith("SIGHASH_") + ); + + return ( + + + + Transaction to sign + {request.broadcast ? ( + + Will broadcast + + ) : ( + + Returns to app + + )} + + + {request.origin || request.app ? ( + + + Requested by + + + {request.app ? `${request.app} — ` : ""} + {request.origin ?? "(no origin provided)"} + + + ) : ( + + + + No origin was provided. Only continue if you trust where this + request came from. + + + )} + + {request.broadcast && ( + + + + If your signature completes this transaction, Photonic will + broadcast it immediately — this cannot be undone. + + + )} + + {blockers.map((reason, i) => ( + + + {reason} + + ))} + + + Inputs ({analysis.inputs.length}) + + + {analysis.inputs.map((input, i) => ( + + ))} + + + + Outputs ({analysis.outputs.length}) + + + {analysis.outputs.map((output, i) => ( + + ))} + + + + + + Network fee + + {analysis.fee !== undefined ? amount(analysis.fee) : "Unknown"} + + + {analysis.warnings.includes("FEE_UNKNOWN") && ( + + One or more inputs' amounts couldn't be resolved, so the fee can't + be verified. + + )} + {analysis.warnings.includes("HIGH_FEE") && ( + + + + This fee is unusually high for the transaction size. Double + check before approving. + + + )} + + {sighashWarnings.length > 0 && ( + + + + Non-standard signing terms + + {sighashWarnings + .map((w) => SIGHASH_LABELS[w] ?? w) + .filter(Boolean) + .join("; ")} + . Other parties may still change parts of this transaction + after you sign. + + + + )} + + + Signing as {signerAddress || "(no wallet address)"} —{" "} + {signableCount} of your input{signableCount === 1 ? "" : "s"} will + be signed. + + + {autoReturn && ( + + After approving you will be sent back to{" "} + {request.app || "the app"} at {request.origin}, which + receives the result automatically. + + )} + + {locked && ( + + You will be asked to unlock your wallet to sign. + + )} + + + + + + Photonic only signs plain inputs your wallet owns. It never signs + token-bearing inputs, and it never reveals your seed phrase. + + + + + + + + + ); +} diff --git a/packages/app/src/components/connect/PsbtResultPanel.tsx b/packages/app/src/components/connect/PsbtResultPanel.tsx new file mode 100644 index 0000000..33d3bac --- /dev/null +++ b/packages/app/src/components/connect/PsbtResultPanel.tsx @@ -0,0 +1,127 @@ +/** + * Result screen after a `psbt-sign-request` is signed: either a txid (the + * wallet broadcast a completed transaction) or a signed PSBT to hand back to + * the app — by QR when it's small enough, always by copy. Mirrors + * `Connect.tsx`'s `ResultPanel` for the plain sign-request flow. + */ +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Badge, + Box, + Button, + Code, + Divider, + Stack, + Text, + VStack, + useClipboard, +} from "@chakra-ui/react"; +import { MdCheck, MdContentCopy } from "react-icons/md"; +import { QRCodeSVG } from "qrcode.react"; +import Card from "@app/components/Card"; +import { encodePsbtResult, type PsbtSignResult } from "@app/connect/protocol"; + +// Beyond this a QR code becomes dense enough to be unreliable to scan; fall +// back to copy-only rather than render something unscannable. +const MAX_QR_LEN = 2_500; + +export default function PsbtResultPanel({ + result, + onDone, +}: { + result: PsbtSignResult; + onDone: () => void; +}) { + const value = result.txid ?? result.psbt ?? ""; + const { onCopy, hasCopied } = useClipboard(value); + const envelope = encodePsbtResult(result); + const showQr = envelope.length <= MAX_QR_LEN; + + return ( + + {result.txid ? ( + + + + Broadcast + + The transaction was sent to the network. + + + + ) : ( + + + + + {result.complete ? "Signed" : "Partially signed"} + + + {result.complete + ? "Send this signed transaction back to the app." + : "Other signatures are still needed before this transaction can be broadcast — send it back to the app to continue."} + + + + )} + + + {showQr ? ( + + + + + + Scan to return the full response, or copy it below. + + + ) : ( + + This result is too large for a QR code — copy it below. + + )} + + + + + {result.txid ? "Transaction id" : "Signed PSBT"} + + + {value} + + + + {!result.txid && ( + + {result.complete ? "Fully signed" : "Awaiting more signatures"} + + )} + + + + + ); +} diff --git a/packages/app/src/components/connect/SwapAcceptRequestPanel.tsx b/packages/app/src/components/connect/SwapAcceptRequestPanel.tsx new file mode 100644 index 0000000..3fb48cb --- /dev/null +++ b/packages/app/src/components/connect/SwapAcceptRequestPanel.tsx @@ -0,0 +1,194 @@ +/** + * Approval screen for an incoming `swap-accept-request` (taker side): the + * dApp asks the wallet to complete and broadcast a purchase against a + * maker's PSRT, optionally adding a marketplace fee output. This always + * broadcasts on approval — there is no "return unsigned" option. + */ +import { useEffect, useState } from "react"; +import { + Alert, + AlertDescription, + AlertIcon, + Box, + Button, + HStack, + Stack, + Text, +} from "@chakra-ui/react"; +import { MdShoppingCart } from "react-icons/md"; +import Card from "@app/components/Card"; +import TokenContent from "@app/components/TokenContent"; +import { previewSwapAccept, type SwapAcceptPreview } from "@app/connect/swapFlow"; +import { electrumStatus } from "@app/signals"; +import { ElectrumStatus } from "@app/types"; +import type { SwapAcceptRequest } from "@app/connect/protocol"; + +export default function SwapAcceptRequestPanel({ + request, + locked, + autoReturn, + busy, + onApprove, + onReject, +}: { + request: SwapAcceptRequest; + locked: boolean; + autoReturn: boolean; + busy?: boolean; + onApprove: () => void; + onReject: () => void; +}) { + // undefined while resolving; null when the PSRT itself is malformed + // (distinct from a resolved preview that just couldn't find the token). + const [parsed, setParsed] = useState( + undefined + ); + + // Wait for the Electrum connection before attempting the lookup — on a + // fresh page load this effect can otherwise fire before the wallet has + // finished connecting, so the on-chain prevout lookup `previewSwapAccept` + // needs has no server to ask yet and degrades straight to price-only. That + // previously required reloading the page (by which point the connection + // from the prior load was already up) to see the item resolve; watching + // `electrumStatus` here means it resolves on its own once connected. + useEffect(() => { + if (electrumStatus.value !== ElectrumStatus.CONNECTED) { + setParsed(undefined); + return; + } + let cancelled = false; + previewSwapAccept(request).then((result) => { + if (!cancelled) setParsed(result); + }); + return () => { + cancelled = true; + }; + }, [request, electrumStatus.value]); + + return ( + + + + + Complete a purchase + + + {request.origin || request.app ? ( + + + Requested by + + + {request.app ? `${request.app} — ` : ""} + {request.origin ?? "(no origin provided)"} + + + ) : ( + + + + No origin was provided. Only continue if you trust where this + request came from. + + + )} + + {parsed === undefined ? ( + + Looking up item… + + ) : parsed === null ? ( + + + + This doesn't look like a valid offer. + + + ) : ( + <> + {parsed.glyph ? ( + + + + + {parsed.glyph.name} + + ) : ( + + + + Couldn't identify which item this offer is for — only the + price could be confirmed. Only continue if you trust the + requesting app. + + + )} + + + Price + + + {parsed.priceRxd} RXD + + + )} + + {request.feeRxd !== undefined && request.feeAddress && ( + <> + + Marketplace fee + + + {request.feeRxd} RXD to {request.feeAddress} + + + )} + + + + + Approving completes and broadcasts this purchase immediately — + it cannot be undone. Network fees are paid from your wallet's + RXD balance. + + + + {autoReturn && ( + + After approving you will be sent back to{" "} + {request.app || "the app"} at {request.origin}, which + receives the result automatically. + + )} + + {locked && ( + + You will be asked to unlock your wallet to continue. + + )} + + + + + + + + ); +} diff --git a/packages/app/src/components/connect/SwapAcceptResultPanel.tsx b/packages/app/src/components/connect/SwapAcceptResultPanel.tsx new file mode 100644 index 0000000..7a43039 --- /dev/null +++ b/packages/app/src/components/connect/SwapAcceptResultPanel.tsx @@ -0,0 +1,73 @@ +/** + * Result screen after a `swap-accept-request` completes: the broadcast + * txid. + */ +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Button, + Code, + Stack, + Text, + useClipboard, +} from "@chakra-ui/react"; +import { MdCheck, MdContentCopy } from "react-icons/md"; +import Card from "@app/components/Card"; +import type { SwapAcceptResult } from "@app/connect/protocol"; + +export default function SwapAcceptResultPanel({ + result, + onDone, +}: { + result: SwapAcceptResult; + onDone: () => void; +}) { + const { onCopy, hasCopied } = useClipboard(result.txid); + + return ( + + + + + Purchase complete + + The transaction was broadcast to the network. + + + + + + + Transaction id + + + {result.txid} + + + + + + + ); +} diff --git a/packages/app/src/components/connect/SwapCancelRequestPanel.tsx b/packages/app/src/components/connect/SwapCancelRequestPanel.tsx new file mode 100644 index 0000000..c09a241 --- /dev/null +++ b/packages/app/src/components/connect/SwapCancelRequestPanel.tsx @@ -0,0 +1,158 @@ +/** + * Approval screen for an incoming `swap-cancel-request`: the dApp asks the + * wallet to cancel one of its own pending listings, identified by `ref`. + * Approving broadcasts a REAL reclaim transaction moving the NFT back to + * the wallet's main address and voids the PSRT the buyer would otherwise + * complete against. + */ +import { + Alert, + AlertDescription, + AlertIcon, + Box, + Button, + Code, + HStack, + Stack, + Text, +} from "@chakra-ui/react"; +import { useLiveQuery } from "dexie-react-hooks"; +import { MdCancel } from "react-icons/md"; +import Card from "@app/components/Card"; +import TokenContent from "@app/components/TokenContent"; +import db from "@app/db"; +import { SwapStatus } from "@app/types"; +import { photonsToRXD } from "@lib/format"; +import type { SwapCancelRequest } from "@app/connect/protocol"; + +export default function SwapCancelRequestPanel({ + request, + locked, + autoReturn, + busy, + onApprove, + onReject, +}: { + request: SwapCancelRequest; + locked: boolean; + autoReturn: boolean; + busy?: boolean; + onApprove: () => void; + onReject: () => void; +}) { + const glyph = useLiveQuery( + () => db.glyph.where({ ref: request.ref }).first(), + [request.ref] + ); + const pendingSwap = useLiveQuery( + () => db.swap.where({ status: SwapStatus.PENDING }).toArray(), + [] + )?.find((s) => s.fromGlyph === request.ref); + const noOffer = pendingSwap === undefined; + + return ( + + + + + Cancel a listing + + + {request.origin || request.app ? ( + + + Requested by + + + {request.app ? `${request.app} — ` : ""} + {request.origin ?? "(no origin provided)"} + + + ) : ( + + + + No origin was provided. Only continue if you trust where this + request came from. + + + )} + + {glyph && ( + + + + + {glyph.name} + + )} + + {noOffer && ( + + + + No pending listing was found for this item — it may already be + cancelled, sold, or never listed by this wallet. + + + )} + + {pendingSwap && ( + <> + + Asking price + + + {photonsToRXD(pendingSwap.toValue)} RXD + + + )} + + + + + Approving reclaims this item back to your wallet immediately (a + real transaction, network fees apply) and voids the signed offer + — a buyer can no longer complete it. + + + + {autoReturn && ( + + After approving you will be sent back to{" "} + {request.app || "the app"} at {request.origin}, which + receives the result automatically. + + )} + + {locked && ( + + You will be asked to unlock your wallet to continue. + + )} + + + + + + + + ); +} diff --git a/packages/app/src/components/connect/SwapCancelResultPanel.tsx b/packages/app/src/components/connect/SwapCancelResultPanel.tsx new file mode 100644 index 0000000..1deab68 --- /dev/null +++ b/packages/app/src/components/connect/SwapCancelResultPanel.tsx @@ -0,0 +1,73 @@ +/** + * Result screen after a `swap-cancel-request` completes: the reclaim + * transaction's txid. + */ +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Button, + Code, + Stack, + Text, + useClipboard, +} from "@chakra-ui/react"; +import { MdCheck, MdContentCopy } from "react-icons/md"; +import Card from "@app/components/Card"; +import type { SwapCancelResult } from "@app/connect/protocol"; + +export default function SwapCancelResultPanel({ + result, + onDone, +}: { + result: SwapCancelResult; + onDone: () => void; +}) { + const { onCopy, hasCopied } = useClipboard(result.txid); + + return ( + + + + + Listing cancelled + + The item was reclaimed and the offer is void. + + + + + + + Transaction id + + + {result.txid} + + + + + + + ); +} diff --git a/packages/app/src/components/connect/SwapOfferRequestPanel.tsx b/packages/app/src/components/connect/SwapOfferRequestPanel.tsx new file mode 100644 index 0000000..dd42ba1 --- /dev/null +++ b/packages/app/src/components/connect/SwapOfferRequestPanel.tsx @@ -0,0 +1,150 @@ +/** + * Approval screen for an incoming `swap-offer-request` (maker side): the + * dApp asks the wallet to list one of its own NFTs for a price. Approving + * broadcasts a REAL transaction reserving the NFT into the swap subaccount + * — `mode: "private"` only means no on-chain advertisement is published, not + * that nothing moves — before the wallet builds and returns the PSRT. + */ +import { + Alert, + AlertDescription, + AlertIcon, + Box, + Button, + Code, + HStack, + Stack, + Text, +} from "@chakra-ui/react"; +import { useLiveQuery } from "dexie-react-hooks"; +import { MdSell } from "react-icons/md"; +import Card from "@app/components/Card"; +import TokenContent from "@app/components/TokenContent"; +import db from "@app/db"; +import type { SwapOfferRequest } from "@app/connect/protocol"; + +export default function SwapOfferRequestPanel({ + request, + locked, + autoReturn, + busy, + onApprove, + onReject, +}: { + request: SwapOfferRequest; + locked: boolean; + autoReturn: boolean; + busy?: boolean; + onApprove: () => void; + onReject: () => void; +}) { + const glyph = useLiveQuery( + () => db.glyph.where({ ref: request.ref }).first(), + [request.ref] + ); + const notOwned = glyph === undefined ? undefined : glyph === null; + + return ( + + + + + List an item for sale + + + {request.origin || request.app ? ( + + + Requested by + + + {request.app ? `${request.app} — ` : ""} + {request.origin ?? "(no origin provided)"} + + + ) : ( + + + + No origin was provided. Only continue if you trust where this + request came from. + + + )} + + {glyph ? ( + + + + + {glyph.name} + + ) : notOwned ? ( + + + + This item wasn't found in your wallet — it may already be + listed, transferred, or the ref is wrong. + + + ) : ( + + Looking up item… + + )} + + + Asking price + + + {request.priceRxd} RXD + + + + + + Approving reserves this item into a listing address on-chain + (a real transaction, network fees apply) and returns a signed + offer to the app — it does not publish a public advertisement. + + + + {autoReturn && ( + + After approving you will be sent back to{" "} + {request.app || "the app"} at {request.origin}, which + receives the offer automatically. + + )} + + {locked && ( + + You will be asked to unlock your wallet to continue. + + )} + + + + + + + + ); +} diff --git a/packages/app/src/components/connect/SwapOfferResultPanel.tsx b/packages/app/src/components/connect/SwapOfferResultPanel.tsx new file mode 100644 index 0000000..3db2f1b --- /dev/null +++ b/packages/app/src/components/connect/SwapOfferResultPanel.tsx @@ -0,0 +1,74 @@ +/** + * Result screen after a `swap-offer-request` completes: the raw PSRT for + * the dApp to index/distribute itself (private mode has no on-chain + * advertisement to point at). + */ +import { + Alert, + AlertDescription, + AlertIcon, + AlertTitle, + Box, + Button, + Code, + Stack, + Text, + useClipboard, +} from "@chakra-ui/react"; +import { MdCheck, MdContentCopy } from "react-icons/md"; +import Card from "@app/components/Card"; +import type { SwapOfferResult } from "@app/connect/protocol"; + +export default function SwapOfferResultPanel({ + result, + onDone, +}: { + result: SwapOfferResult; + onDone: () => void; +}) { + const { onCopy, hasCopied } = useClipboard(result.psrt); + + return ( + + + + + Listed + + The item was reserved and a signed offer was created. + + + + + + + Signed offer (PSRT) + + + {result.psrt} + + + + + + + ); +} diff --git a/packages/app/src/connect/__tests__/mintFlow.test.ts b/packages/app/src/connect/__tests__/mintFlow.test.ts new file mode 100644 index 0000000..a69ac1f --- /dev/null +++ b/packages/app/src/connect/__tests__/mintFlow.test.ts @@ -0,0 +1,133 @@ +/** + * Unit tests for `../mintFlow`'s pure payload-building logic + * (`buildMintPayload`). The broadcasting half (`mintFromRequest`) touches + * `db`/`electrumWorker`/wallet signals and is exercised end-to-end via the + * connect UI rather than mocked here — this file covers the one piece that + * is pure and security-relevant: turning dApp-controlled content into a safe + * on-chain payload (size enforcement, SVG sanitization). + */ +import { describe, expect, it, vi } from "vitest"; +import { Buffer } from "buffer"; +import { GLYPH_NFT } from "@lib/protocols"; +import { mintEmbedMaxBytes } from "@app/config.json"; +import type { MintRequest } from "@app/connect/protocol"; + +// `mintFlow.ts` also exports the async `mintFromRequest`, which imports +// `@app/electrum/Electrum` (constructs a real Worker at module load time — +// unmockable via the default `@app/db` stub in setup.ts, same reason +// `rxdRetry.test.ts` mocks `@app/utxos`) and `@app/utxos`. Neither is +// exercised here — only the pure `buildMintPayload` — so both are stubbed to +// keep this file's import graph side-effect-free. +vi.mock("@app/electrum/Electrum", () => ({ + electrumWorker: { value: {} }, +})); +vi.mock("@app/utxos", () => ({ updateRxdBalances: vi.fn() })); + +import { buildMintPayload, MintRequestError } from "../mintFlow"; + +const CONNECT_PROTOCOL = "photonic-connect" as const; +const CONNECT_VERSION = 1 as const; + +function baseRequest(overrides: Partial = {}): MintRequest { + return { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "mint-request", + name: "My NFT", + main: { mime: "image/png", data: Buffer.from("hello").toString("base64") }, + ...overrides, + }; +} + +describe("buildMintPayload", () => { + it("builds a minimal immutable NFT payload from an embedded file", () => { + const payload = buildMintPayload(baseRequest()); + expect(payload.v).toBe(2); + expect(payload.p).toEqual([GLYPH_NFT]); + expect(payload.name).toBe("My NFT"); + expect(payload.desc).toBeUndefined(); + expect(payload.license).toBeUndefined(); + expect(payload.attrs).toBeUndefined(); + expect((payload.main as { t: string; b: Uint8Array }).t).toBe("image/png"); + expect( + Buffer.from((payload.main as { t: string; b: Uint8Array }).b).toString() + ).toBe("hello"); + }); + + it("folds description, license, and attrs into the payload", () => { + const payload = buildMintPayload( + baseRequest({ + description: "A test item", + license: "CC0", + attrs: { rarity: "rare", power: 7 }, + }) + ); + expect(payload.desc).toBe("A test item"); + expect(payload.license).toBe("CC0"); + expect(payload.attrs).toEqual({ rarity: "rare", power: 7 }); + }); + + it("builds a remote-pointer payload from a url main", () => { + const payload = buildMintPayload( + baseRequest({ main: { mime: "image/png", url: "https://realm.rxd/a.png" } }) + ); + expect(payload.main).toEqual({ t: "image/png", u: "https://realm.rxd/a.png" }); + }); + + it("throws MintRequestError when embedded content exceeds the on-chain limit", () => { + const oversized = Buffer.alloc(mintEmbedMaxBytes + 1, 1).toString("base64"); + expect(() => + buildMintPayload(baseRequest({ main: { mime: "image/png", data: oversized } })) + ).toThrowError(MintRequestError); + }); + + it("accepts content right at the size limit", () => { + const atLimit = Buffer.alloc(mintEmbedMaxBytes, 1).toString("base64"); + expect(() => + buildMintPayload(baseRequest({ main: { mime: "image/png", data: atLimit } })) + ).not.toThrow(); + }); + + it("sanitizes SVG content declared with the svg mime type", () => { + const svg = ''; + const payload = buildMintPayload( + baseRequest({ + main: { + mime: "image/svg+xml", + data: Buffer.from(svg).toString("base64"), + }, + }) + ); + const bytes = (payload.main as { t: string; b: Uint8Array }).b; + const text = Buffer.from(bytes).toString("utf8"); + expect(text).not.toContain(" { + // No mime allow-list entry lets this through mislabeled in practice + // (protocol.ts restricts mime to the allow-list already), but the + // sniff-based sanitization is defense-in-depth if that ever changes. + const svg = ''; + const payload = buildMintPayload( + baseRequest({ + main: { mime: "image/svg+xml", data: Buffer.from(svg).toString("base64") }, + }) + ); + const bytes = (payload.main as { t: string; b: Uint8Array }).b; + expect(Buffer.from(bytes).toString("utf8")).not.toContain(" { + const raw = Buffer.from([0xfb, 0xff, 0xfe]); // encodes with +/ in standard base64 + const b64url = raw + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + const payload = buildMintPayload( + baseRequest({ main: { mime: "image/png", data: b64url } }) + ); + const bytes = (payload.main as { t: string; b: Uint8Array }).b; + expect(Buffer.from(bytes)).toEqual(raw); + }); +}); diff --git a/packages/app/src/connect/__tests__/protocol.test.ts b/packages/app/src/connect/__tests__/protocol.test.ts index 29a399a..1cd492f 100644 --- a/packages/app/src/connect/__tests__/protocol.test.ts +++ b/packages/app/src/connect/__tests__/protocol.test.ts @@ -7,15 +7,43 @@ import { it, expect, describe } from "vitest"; import { parseSignRequest, + parseConnectRequest, isRecognizedConnectChallenge, buildCallbackUrl, buildSignResult, encodeSignResult, encodeReqParam, extractChallengeNonce, + buildPsbtResult, + buildPsbtCallbackUrl, + encodePsbtResult, + buildMintResult, + buildMintCallbackUrl, + encodeMintResult, + buildSwapOfferResult, + buildSwapOfferCallbackUrl, + encodeSwapOfferResult, + buildSwapAcceptResult, + buildSwapAcceptCallbackUrl, + encodeSwapAcceptResult, + buildSwapCancelResult, + buildSwapCancelCallbackUrl, + encodeSwapCancelResult, + buildRejectCallbackUrl, + classifyConnectError, + buildErrorCallbackUrl, + MAX_PSBT_LEN, + MAX_CALLBACK_URL_LEN, + MAX_MINT_DATA_LEN, + MINT_ALLOWED_MIME_TYPES, CONNECT_PROTOCOL, CONNECT_VERSION, type SignRequest, + type PsbtSignRequest, + type MintRequest, + type SwapOfferRequest, + type SwapAcceptRequest, + type SwapCancelRequest, } from "../protocol"; const CHALLENGE = "glyphgalaxy:wallet-connect:v1:sess-abc123:deadbeefdeadbeef"; @@ -334,3 +362,1205 @@ describe("buildSignResult / encodeSignResult", () => { expect("id" in result).toBe(false); }); }); + +// A plausible base64 payload (real PSBT magic bytes) — the protocol layer +// only validates charset/length here, never PSBT structure (that's `@lib/psbt`'s +// job), so any base64-charset string exercises these guards. +const SAMPLE_PSBT_B64 = "cHNidP8BAAoCAAAAAAAAAAAAAAA="; + +describe("parseConnectRequest — psbt-sign-request envelope", () => { + it("accepts a minimal envelope (psbt only)", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "psbt-sign-request", psbt: SAMPLE_PSBT_B64 }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "psbt-sign-request") { + expect(r.request.psbt).toBe(SAMPLE_PSBT_B64); + expect(r.request.broadcast).toBe(false); + expect(r.request.protocol).toBe(CONNECT_PROTOCOL); + expect(r.request.v).toBe(CONNECT_VERSION); + } else { + throw new Error("expected a psbt-sign-request"); + } + }); + + it("accepts a full envelope and sanitizes display fields", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "psbt-sign-request", + psbt: SAMPLE_PSBT_B64, + broadcast: true, + id: "req-1", + origin: "https://app.glyphgalaxy.com", + app: "GlyphGalaxy", + }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "psbt-sign-request") { + expect(r.request.broadcast).toBe(true); + expect(r.request.id).toBe("req-1"); + expect(r.request.origin).toBe("https://app.glyphgalaxy.com"); + expect(r.request.app).toBe("GlyphGalaxy"); + } else { + throw new Error("expected a psbt-sign-request"); + } + }); + + it.each([undefined, "true", 1, "yes"])( + "only the literal boolean true opts into broadcast (got %j)", + (broadcast) => { + const r = parseConnectRequest( + JSON.stringify({ t: "psbt-sign-request", psbt: SAMPLE_PSBT_B64, broadcast }) + ); + expect(r.ok && r.request.t === "psbt-sign-request" && r.request.broadcast).toBe( + false + ); + } + ); + + it("rejects a request missing psbt", () => { + const r = parseConnectRequest(JSON.stringify({ t: "psbt-sign-request" })); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/missing a psbt/); + }); + + it("rejects a psbt field that isn't base64", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "psbt-sign-request", psbt: "not base64!! spaces" }) + ); + expect(r.ok).toBe(false); + }); + + it("rejects a psbt field over MAX_PSBT_LEN", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "psbt-sign-request", psbt: "A".repeat(MAX_PSBT_LEN + 1) }) + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/too long/); + }); + + it("rejects an unsupported version / protocol, same as sign-request", () => { + const badVersion = parseConnectRequest( + JSON.stringify({ t: "psbt-sign-request", v: 2, psbt: SAMPLE_PSBT_B64 }) + ); + expect(badVersion.ok).toBe(false); + if (!badVersion.ok) expect(badVersion.error).toMatch(/version/); + + const badProtocol = parseConnectRequest( + JSON.stringify({ + t: "psbt-sign-request", + protocol: "evil-wallet", + psbt: SAMPLE_PSBT_B64, + }) + ); + expect(badProtocol.ok).toBe(false); + if (!badProtocol.ok) expect(badProtocol.error).toMatch(/protocol/); + }); + + it("still rejects unrelated unsupported request types", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "sign-tx", psbt: SAMPLE_PSBT_B64 }) + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/request type/); + }); + + it("binds the callback to the declared origin, same rule as sign-request", () => { + const kept = parseConnectRequest( + JSON.stringify({ + t: "psbt-sign-request", + psbt: SAMPLE_PSBT_B64, + origin: "https://surf.rxd.zone", + callback: "https://surf.rxd.zone/psbt-callback", + }) + ); + expect(kept.ok && kept.request.t === "psbt-sign-request" && kept.request.callback).toBe( + "https://surf.rxd.zone/psbt-callback" + ); + + const dropped = parseConnectRequest( + JSON.stringify({ + t: "psbt-sign-request", + psbt: SAMPLE_PSBT_B64, + origin: "https://surf.rxd.zone", + callback: "https://evil.example/steal", + }) + ); + expect( + dropped.ok && dropped.request.t === "psbt-sign-request" && dropped.request.callback + ).toBeUndefined(); + }); + + it("round-trips via encodeReqParam / encodePsbtReqParam", () => { + const req: PsbtSignRequest = { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "psbt-sign-request", + psbt: SAMPLE_PSBT_B64, + broadcast: true, + id: "abc", + }; + const param = encodeReqParam(req); + expect(param).not.toMatch(/[+/=]/); + const r = parseConnectRequest(param); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "psbt-sign-request") { + expect(r.request.psbt).toBe(SAMPLE_PSBT_B64); + expect(r.request.broadcast).toBe(true); + expect(r.request.id).toBe("abc"); + } else { + throw new Error("expected a psbt-sign-request"); + } + }); +}); + +describe("parseSignRequest — legacy alias narrows to sign-request only", () => { + it("errors when handed a psbt-sign-request envelope", () => { + const r = parseSignRequest( + JSON.stringify({ t: "psbt-sign-request", psbt: SAMPLE_PSBT_B64 }) + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/request type/); + }); +}); + +describe("buildPsbtResult / encodePsbtResult", () => { + it("builds a psbt-return result, echoing the id", () => { + const result = buildPsbtResult( + { id: "req-1" }, + { psbt: SAMPLE_PSBT_B64, complete: false } + ); + expect(result).toMatchObject({ + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "psbt-sign-result", + id: "req-1", + psbt: SAMPLE_PSBT_B64, + complete: false, + }); + expect("txid" in result).toBe(false); + expect(JSON.parse(encodePsbtResult(result))).toEqual(result); + }); + + it("builds a txid-return result and omits id when absent", () => { + const result = buildPsbtResult( + {}, + { txid: "ab".repeat(32), complete: true } + ); + expect("id" in result).toBe(false); + expect("psbt" in result).toBe(false); + expect(result.txid).toBe("ab".repeat(32)); + expect(result.complete).toBe(true); + }); +}); + +describe("buildPsbtCallbackUrl", () => { + it("returns a txid fragment when broadcast completed", () => { + const url = buildPsbtCallbackUrl( + { callback: "https://surf.rxd.zone/cb" }, + { id: "req-1", txid: "ab".repeat(32), complete: true } + ); + expect(url).toBe( + `https://surf.rxd.zone/cb#id=req-1&txid=${"ab".repeat(32)}&complete=true` + ); + }); + + it("returns a psbt fragment when returning a signed PSBT", () => { + const url = buildPsbtCallbackUrl( + { callback: "https://surf.rxd.zone/cb" }, + { psbt: SAMPLE_PSBT_B64, complete: false } + ); + expect(url).toBe( + `https://surf.rxd.zone/cb#psbt=${encodeURIComponent(SAMPLE_PSBT_B64)}&complete=false` + ); + }); + + it("puts the result in the fragment, never the query", () => { + const url = buildPsbtCallbackUrl( + { callback: "https://surf.rxd.zone/cb" }, + { txid: "ab".repeat(32), complete: true } + )!; + expect(url.indexOf("#")).toBeGreaterThan(-1); + expect(url.slice(0, url.indexOf("#"))).not.toMatch(/[?&]/); + }); + + it("returns undefined when the request has no callback", () => { + expect( + buildPsbtCallbackUrl({}, { txid: "ab".repeat(32), complete: true }) + ).toBeUndefined(); + }); + + it("returns undefined rather than truncate when the composed URL is too large", () => { + const url = buildPsbtCallbackUrl( + { callback: "https://surf.rxd.zone/cb" }, + { psbt: "A".repeat(MAX_CALLBACK_URL_LEN), complete: false } + ); + expect(url).toBeUndefined(); + }); +}); + +const SAMPLE_IMAGE_B64 = "aGVsbG8gd29ybGQ="; // "hello world" — content is opaque to the protocol layer + +describe("parseConnectRequest — mint-request envelope", () => { + it("accepts a minimal envelope (name + embedded main only)", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "My NFT", + main: { mime: "image/png", data: SAMPLE_IMAGE_B64 }, + }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "mint-request") { + expect(r.request.name).toBe("My NFT"); + expect(r.request.main).toEqual({ mime: "image/png", data: SAMPLE_IMAGE_B64 }); + expect(r.request.description).toBeUndefined(); + expect(r.request.attrs).toBeUndefined(); + } else { + throw new Error("expected a mint-request"); + } + }); + + it("accepts a full envelope with description, license, attrs, feeRate", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "Realm Sword", + description: "A legendary blade", + license: "CC0", + attrs: { rarity: "legendary", power: 42, tradeable: true }, + main: { mime: "image/png", data: SAMPLE_IMAGE_B64 }, + feeRate: 15000, + id: "req-1", + origin: "https://realm.rxd", + app: "Realm", + }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "mint-request") { + expect(r.request.description).toBe("A legendary blade"); + expect(r.request.license).toBe("CC0"); + expect(r.request.attrs).toEqual({ + rarity: "legendary", + power: 42, + tradeable: true, + }); + expect(r.request.feeRate).toBe(15000); + expect(r.request.id).toBe("req-1"); + expect(r.request.origin).toBe("https://realm.rxd"); + expect(r.request.app).toBe("Realm"); + } else { + throw new Error("expected a mint-request"); + } + }); + + it("accepts a remote (url) main instead of embedded data", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "Remote NFT", + main: { mime: "image/png", url: "https://realm.rxd/assets/sword.png" }, + }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "mint-request") { + expect(r.request.main).toEqual({ + mime: "image/png", + url: "https://realm.rxd/assets/sword.png", + }); + } else { + throw new Error("expected a mint-request"); + } + }); + + it("rejects a request missing a name", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "mint-request", main: { mime: "image/png", data: SAMPLE_IMAGE_B64 } }) + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/missing a name/); + }); + + it("rejects a request missing main content", () => { + const r = parseConnectRequest(JSON.stringify({ t: "mint-request", name: "X" })); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/main content/); + }); + + it("rejects a disallowed mime type", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "X", + main: { mime: "application/x-executable", data: SAMPLE_IMAGE_B64 }, + }) + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/unsupported mime type/); + }); + + it("accepts every MIME type in the allow-list", () => { + for (const mime of MINT_ALLOWED_MIME_TYPES) { + const r = parseConnectRequest( + JSON.stringify({ t: "mint-request", name: "X", main: { mime, data: SAMPLE_IMAGE_B64 } }) + ); + expect(r.ok, mime).toBe(true); + } + }); + + it("rejects main.data that isn't valid base64", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "X", + main: { mime: "image/png", data: "not base64!! spaces" }, + }) + ); + expect(r.ok).toBe(false); + }); + + it("rejects main.data over MAX_MINT_DATA_LEN", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "X", + main: { mime: "image/png", data: "A".repeat(MAX_MINT_DATA_LEN + 1) }, + }) + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/too large/); + }); + + it("rejects a non-http(s) main.url", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "X", + // eslint-disable-next-line no-script-url + main: { mime: "image/png", url: "javascript:alert(1)" }, + }) + ); + expect(r.ok).toBe(false); + }); + + it("rejects main with neither data nor url", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "mint-request", name: "X", main: { mime: "image/png" } }) + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/data or a url/); + }); + + it("caps attrs to string/number/boolean values via filterAttrs, dropping the rest", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "X", + main: { mime: "image/png", data: SAMPLE_IMAGE_B64 }, + attrs: { ok: "yes", nested: { a: 1 }, arr: [1, 2], long: "x".repeat(200) }, + }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "mint-request") { + expect(r.request.attrs).toEqual({ ok: "yes" }); + } else { + throw new Error("expected a mint-request"); + } + }); + + it("rejects a feeRate that isn't a positive number", () => { + for (const feeRate of [-1, 0, "1000", NaN]) { + const r = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "X", + main: { mime: "image/png", data: SAMPLE_IMAGE_B64 }, + feeRate, + }) + ); + expect(r.ok, JSON.stringify(feeRate)).toBe(false); + } + }); + + it("rejects an unsupported version / protocol, same as other request types", () => { + const badVersion = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + v: 2, + name: "X", + main: { mime: "image/png", data: SAMPLE_IMAGE_B64 }, + }) + ); + expect(badVersion.ok).toBe(false); + if (!badVersion.ok) expect(badVersion.error).toMatch(/version/); + }); + + it("binds the callback to the declared origin, same rule as other request types", () => { + const kept = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "X", + main: { mime: "image/png", data: SAMPLE_IMAGE_B64 }, + origin: "https://realm.rxd", + callback: "https://realm.rxd/mint-callback", + }) + ); + expect( + kept.ok && kept.request.t === "mint-request" && kept.request.callback + ).toBe("https://realm.rxd/mint-callback"); + + const dropped = parseConnectRequest( + JSON.stringify({ + t: "mint-request", + name: "X", + main: { mime: "image/png", data: SAMPLE_IMAGE_B64 }, + origin: "https://realm.rxd", + callback: "https://evil.example/steal", + }) + ); + expect( + dropped.ok && dropped.request.t === "mint-request" && dropped.request.callback + ).toBeUndefined(); + }); + + it("round-trips via encodeReqParam", () => { + const req: MintRequest = { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "mint-request", + name: "Realm Sword", + main: { mime: "image/png", data: SAMPLE_IMAGE_B64 }, + id: "abc", + }; + const param = encodeReqParam(req); + expect(param).not.toMatch(/[+/=]/); + const r = parseConnectRequest(param); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "mint-request") { + expect(r.request.name).toBe("Realm Sword"); + expect(r.request.id).toBe("abc"); + } else { + throw new Error("expected a mint-request"); + } + }); +}); + +describe("buildMintResult / encodeMintResult", () => { + it("builds a broadcast mint result, echoing the id", () => { + const result = buildMintResult( + { id: "req-1" }, + { + broadcast: true, + commitTxid: "aa".repeat(32), + revealTxid: "bb".repeat(32), + ref: "cc".repeat(36), + } + ); + expect(result).toMatchObject({ + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "mint-result", + id: "req-1", + broadcast: true, + commitTxid: "aa".repeat(32), + revealTxid: "bb".repeat(32), + ref: "cc".repeat(36), + }); + expect("commitHex" in result).toBe(false); + expect(JSON.parse(encodeMintResult(result))).toEqual(result); + }); + + it("builds a dry-run mint result with hex instead of txids", () => { + const result = buildMintResult( + {}, + { + broadcast: false, + commitHex: "aa".repeat(20), + revealHex: "bb".repeat(20), + ref: "cc".repeat(36), + } + ); + expect(result.broadcast).toBe(false); + expect(result.commitHex).toBe("aa".repeat(20)); + expect(result.revealHex).toBe("bb".repeat(20)); + expect("commitTxid" in result).toBe(false); + expect("revealTxid" in result).toBe(false); + expect("id" in result).toBe(false); + }); +}); + +describe("buildMintCallbackUrl", () => { + it("puts broadcast/commitTxid/revealTxid/ref in the fragment, never the query", () => { + const url = buildMintCallbackUrl( + { callback: "https://realm.rxd/cb" }, + { + id: "req-1", + broadcast: true, + commitTxid: "aa".repeat(32), + revealTxid: "bb".repeat(32), + ref: "cc".repeat(36), + } + ); + expect(url).toBe( + `https://realm.rxd/cb#id=req-1&broadcast=true&ref=${"cc".repeat(36)}&commitTxid=${"aa".repeat(32)}&revealTxid=${"bb".repeat(32)}` + ); + }); + + it("puts commitHex/revealHex in the fragment for a dry run", () => { + const url = buildMintCallbackUrl( + { callback: "https://realm.rxd/cb" }, + { broadcast: false, commitHex: "aa".repeat(10), revealHex: "bb".repeat(10), ref: "cc".repeat(36) } + ); + expect(url).toContain("broadcast=false"); + expect(url).toContain(`commitHex=${"aa".repeat(10)}`); + expect(url).toContain(`revealHex=${"bb".repeat(10)}`); + expect(url).not.toContain("commitTxid="); + }); + + it("returns undefined when the request has no callback", () => { + expect( + buildMintCallbackUrl( + {}, + { + broadcast: true, + commitTxid: "aa".repeat(32), + revealTxid: "bb".repeat(32), + ref: "cc".repeat(36), + } + ) + ).toBeUndefined(); + }); + + it("returns undefined rather than truncate when the composed URL is too large", () => { + const url = buildMintCallbackUrl( + { callback: "https://realm.rxd/cb" }, + { + broadcast: true, + commitTxid: "aa".repeat(32), + revealTxid: "bb".repeat(32), + ref: "c".repeat(MAX_CALLBACK_URL_LEN), + } + ); + expect(url).toBeUndefined(); + }); +}); + +const SAMPLE_REF = "ab".repeat(36); // 72 hex chars: 32-byte txid + 4-byte vout +const SAMPLE_PSRT_HEX = "aa".repeat(50); // even-length hex; content is opaque to protocol.ts + +describe("parseConnectRequest — swap-offer-request envelope", () => { + it("accepts a minimal envelope", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "swap-offer-request", + ref: SAMPLE_REF, + priceRxd: 10, + mode: "private", + }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "swap-offer-request") { + expect(r.request.ref).toBe(SAMPLE_REF); + expect(r.request.priceRxd).toBe(10); + expect(r.request.mode).toBe("private"); + } else { + throw new Error("expected a swap-offer-request"); + } + }); + + it("lowercases the ref", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "swap-offer-request", + ref: SAMPLE_REF.toUpperCase(), + priceRxd: 10, + mode: "private", + }) + ); + expect(r.ok && r.request.t === "swap-offer-request" && r.request.ref).toBe( + SAMPLE_REF + ); + }); + + it("rejects a missing or malformed ref", () => { + for (const ref of [undefined, "not-a-ref", "ab".repeat(35), "ab".repeat(37)]) { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-offer-request", ref, priceRxd: 10, mode: "private" }) + ); + expect(r.ok, JSON.stringify(ref)).toBe(false); + } + }); + + it("rejects a non-positive or non-numeric priceRxd", () => { + for (const priceRxd of [-1, 0, "10", NaN]) { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-offer-request", ref: SAMPLE_REF, priceRxd, mode: "private" }) + ); + expect(r.ok, JSON.stringify(priceRxd)).toBe(false); + } + }); + + it('rejects "broadcast" mode with a specific message', () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "swap-offer-request", + ref: SAMPLE_REF, + priceRxd: 10, + mode: "broadcast", + }) + ); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/not yet supported/); + }); + + it("rejects a missing mode", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-offer-request", ref: SAMPLE_REF, priceRxd: 10 }) + ); + expect(r.ok).toBe(false); + }); + + it("binds the callback to the declared origin, same rule as other request types", () => { + const kept = parseConnectRequest( + JSON.stringify({ + t: "swap-offer-request", + ref: SAMPLE_REF, + priceRxd: 10, + mode: "private", + origin: "https://realm.rxd", + callback: "https://realm.rxd/cb", + }) + ); + expect( + kept.ok && kept.request.t === "swap-offer-request" && kept.request.callback + ).toBe("https://realm.rxd/cb"); + + const dropped = parseConnectRequest( + JSON.stringify({ + t: "swap-offer-request", + ref: SAMPLE_REF, + priceRxd: 10, + mode: "private", + origin: "https://realm.rxd", + callback: "https://evil.example/steal", + }) + ); + expect( + dropped.ok && dropped.request.t === "swap-offer-request" && dropped.request.callback + ).toBeUndefined(); + }); + + it("round-trips via encodeReqParam", () => { + const req: SwapOfferRequest = { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-offer-request", + ref: SAMPLE_REF, + priceRxd: 10, + mode: "private", + id: "abc", + }; + const param = encodeReqParam(req); + const r = parseConnectRequest(param); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "swap-offer-request") { + expect(r.request.ref).toBe(SAMPLE_REF); + expect(r.request.id).toBe("abc"); + } else { + throw new Error("expected a swap-offer-request"); + } + }); +}); + +const SAMPLE_RESERVE_TXID = "cc".repeat(32); +const SAMPLE_SWAP_ADDRESS = "16hsngnxdvrBSrAzksiFguCbK5t6gQMxcR"; +const SAMPLE_PAYOUT_ADDRESS = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"; +const SAMPLE_PRICE_RXD = 12.5; + +function sampleOfferOutcome(overrides: Partial<{ + psrt: string; + reserveTxid: string; + reserveVout: number; + swapAddress: string; + ref: string; + payoutAddress: string; + priceRxd: number; +}> = {}) { + return { + psrt: SAMPLE_PSRT_HEX, + reserveTxid: SAMPLE_RESERVE_TXID, + reserveVout: 0, + swapAddress: SAMPLE_SWAP_ADDRESS, + ref: SAMPLE_REF, + payoutAddress: SAMPLE_PAYOUT_ADDRESS, + priceRxd: SAMPLE_PRICE_RXD, + ...overrides, + }; +} + +describe("buildSwapOfferResult / encodeSwapOfferResult / buildSwapOfferCallbackUrl", () => { + it("builds and serializes a result, echoing the id", () => { + const result = buildSwapOfferResult({ id: "req-1" }, sampleOfferOutcome()); + expect(result).toMatchObject({ + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-offer-result", + id: "req-1", + psrt: SAMPLE_PSRT_HEX, + reserveTxid: SAMPLE_RESERVE_TXID, + reserveVout: 0, + swapAddress: SAMPLE_SWAP_ADDRESS, + ref: SAMPLE_REF, + payoutAddress: SAMPLE_PAYOUT_ADDRESS, + priceRxd: SAMPLE_PRICE_RXD, + }); + expect(JSON.parse(encodeSwapOfferResult(result))).toEqual(result); + }); + + it("puts the reserve outpoint, swapAddress, ref, payoutAddress, priceRxd, and psrt in the fragment, never the query", () => { + const url = buildSwapOfferCallbackUrl( + { callback: "https://realm.rxd/cb" }, + sampleOfferOutcome() + )!; + expect(url.indexOf("#")).toBeGreaterThan(-1); + expect(url.slice(0, url.indexOf("#"))).not.toMatch(/[?&]/); + expect(url).toContain(`reserveTxid=${SAMPLE_RESERVE_TXID}`); + expect(url).toContain("reserveVout=0"); + expect(url).toContain(`swapAddress=${SAMPLE_SWAP_ADDRESS}`); + expect(url).toContain(`ref=${SAMPLE_REF}`); + expect(url).toContain(`payoutAddress=${SAMPLE_PAYOUT_ADDRESS}`); + expect(url).toContain(`priceRxd=${SAMPLE_PRICE_RXD}`); + }); + + it("returns undefined rather than truncate when the composed URL is too large", () => { + const url = buildSwapOfferCallbackUrl( + { callback: "https://realm.rxd/cb" }, + sampleOfferOutcome({ psrt: "a".repeat(MAX_CALLBACK_URL_LEN) }) + ); + expect(url).toBeUndefined(); + }); +}); + +describe("parseConnectRequest — swap-accept-request envelope", () => { + it("accepts a minimal envelope (psrt only)", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-accept-request", psrt: SAMPLE_PSRT_HEX }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "swap-accept-request") { + expect(r.request.psrt).toBe(SAMPLE_PSRT_HEX); + expect(r.request.feeRxd).toBeUndefined(); + expect(r.request.feeAddress).toBeUndefined(); + } else { + throw new Error("expected a swap-accept-request"); + } + }); + + it("accepts a full envelope with feeRxd + feeAddress", () => { + const r = parseConnectRequest( + JSON.stringify({ + t: "swap-accept-request", + psrt: SAMPLE_PSRT_HEX, + feeRxd: 0.25, + feeAddress: "16hsngnxdvrBSrAzksiFguCbK5t6gQMxcR", + }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "swap-accept-request") { + expect(r.request.feeRxd).toBe(0.25); + expect(r.request.feeAddress).toBe("16hsngnxdvrBSrAzksiFguCbK5t6gQMxcR"); + } else { + throw new Error("expected a swap-accept-request"); + } + }); + + it("rejects a request missing psrt", () => { + const r = parseConnectRequest(JSON.stringify({ t: "swap-accept-request" })); + expect(r.ok).toBe(false); + }); + + it("rejects psrt that isn't valid hex", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-accept-request", psrt: "not hex zz" }) + ); + expect(r.ok).toBe(false); + }); + + it("rejects odd-length psrt hex", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-accept-request", psrt: "abc" }) + ); + expect(r.ok).toBe(false); + }); + + it("rejects feeRxd without feeAddress, and vice versa", () => { + const r1 = parseConnectRequest( + JSON.stringify({ t: "swap-accept-request", psrt: SAMPLE_PSRT_HEX, feeRxd: 1 }) + ); + expect(r1.ok).toBe(false); + if (!r1.ok) expect(r1.error).toMatch(/together/); + + const r2 = parseConnectRequest( + JSON.stringify({ + t: "swap-accept-request", + psrt: SAMPLE_PSRT_HEX, + feeAddress: "16hsngnxdvrBSrAzksiFguCbK5t6gQMxcR", + }) + ); + expect(r2.ok).toBe(false); + if (!r2.ok) expect(r2.error).toMatch(/together/); + }); + + it("rejects a non-positive feeRxd or malformed feeAddress", () => { + const badFee = parseConnectRequest( + JSON.stringify({ + t: "swap-accept-request", + psrt: SAMPLE_PSRT_HEX, + feeRxd: 0, + feeAddress: "16hsngnxdvrBSrAzksiFguCbK5t6gQMxcR", + }) + ); + expect(badFee.ok).toBe(false); + + const badAddr = parseConnectRequest( + JSON.stringify({ + t: "swap-accept-request", + psrt: SAMPLE_PSRT_HEX, + feeRxd: 1, + feeAddress: "not an address!!", + }) + ); + expect(badAddr.ok).toBe(false); + }); + + it("binds the callback to the declared origin, same rule as other request types", () => { + const kept = parseConnectRequest( + JSON.stringify({ + t: "swap-accept-request", + psrt: SAMPLE_PSRT_HEX, + origin: "https://realm.rxd", + callback: "https://realm.rxd/cb", + }) + ); + expect( + kept.ok && kept.request.t === "swap-accept-request" && kept.request.callback + ).toBe("https://realm.rxd/cb"); + + const dropped = parseConnectRequest( + JSON.stringify({ + t: "swap-accept-request", + psrt: SAMPLE_PSRT_HEX, + origin: "https://realm.rxd", + callback: "https://evil.example/steal", + }) + ); + expect( + dropped.ok && dropped.request.t === "swap-accept-request" && dropped.request.callback + ).toBeUndefined(); + }); + + it("round-trips via encodeReqParam", () => { + const req: SwapAcceptRequest = { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-accept-request", + psrt: SAMPLE_PSRT_HEX, + id: "abc", + }; + const param = encodeReqParam(req); + const r = parseConnectRequest(param); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "swap-accept-request") { + expect(r.request.psrt).toBe(SAMPLE_PSRT_HEX); + expect(r.request.id).toBe("abc"); + } else { + throw new Error("expected a swap-accept-request"); + } + }); +}); + +describe("buildSwapAcceptResult / encodeSwapAcceptResult / buildSwapAcceptCallbackUrl", () => { + it("builds and serializes a result, echoing the id", () => { + const result = buildSwapAcceptResult({ id: "req-1" }, { txid: "aa".repeat(32) }); + expect(result).toMatchObject({ + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-accept-result", + id: "req-1", + txid: "aa".repeat(32), + }); + expect(JSON.parse(encodeSwapAcceptResult(result))).toEqual(result); + }); + + it("omits id when the request had none", () => { + const result = buildSwapAcceptResult({}, { txid: "aa".repeat(32) }); + expect("id" in result).toBe(false); + }); + + it("puts the txid in the fragment, never the query", () => { + const url = buildSwapAcceptCallbackUrl( + { callback: "https://realm.rxd/cb" }, + { txid: "aa".repeat(32) } + )!; + expect(url.indexOf("#")).toBeGreaterThan(-1); + expect(url.slice(0, url.indexOf("#"))).not.toMatch(/[?&]/); + }); + + it("returns undefined when the request has no callback", () => { + expect( + buildSwapAcceptCallbackUrl({}, { txid: "aa".repeat(32) }) + ).toBeUndefined(); + }); +}); + +describe("parseConnectRequest — swap-cancel-request envelope", () => { + it("accepts a minimal envelope (ref only)", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-cancel-request", ref: SAMPLE_REF }) + ); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "swap-cancel-request") { + expect(r.request.ref).toBe(SAMPLE_REF); + } else { + throw new Error("expected a swap-cancel-request"); + } + }); + + it("lowercases the ref", () => { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-cancel-request", ref: SAMPLE_REF.toUpperCase() }) + ); + expect(r.ok && r.request.t === "swap-cancel-request" && r.request.ref).toBe( + SAMPLE_REF + ); + }); + + it("rejects a missing or malformed ref", () => { + for (const ref of [undefined, "not-a-ref", "ab".repeat(35), "ab".repeat(37)]) { + const r = parseConnectRequest( + JSON.stringify({ t: "swap-cancel-request", ref }) + ); + expect(r.ok, JSON.stringify(ref)).toBe(false); + } + }); + + it("rejects an unsupported version / protocol, same as other request types", () => { + const badVersion = parseConnectRequest( + JSON.stringify({ t: "swap-cancel-request", v: 2, ref: SAMPLE_REF }) + ); + expect(badVersion.ok).toBe(false); + if (!badVersion.ok) expect(badVersion.error).toMatch(/version/); + }); + + it("binds the callback to the declared origin, same rule as other request types", () => { + const kept = parseConnectRequest( + JSON.stringify({ + t: "swap-cancel-request", + ref: SAMPLE_REF, + origin: "https://realm.rxd", + callback: "https://realm.rxd/cb", + }) + ); + expect( + kept.ok && kept.request.t === "swap-cancel-request" && kept.request.callback + ).toBe("https://realm.rxd/cb"); + + const dropped = parseConnectRequest( + JSON.stringify({ + t: "swap-cancel-request", + ref: SAMPLE_REF, + origin: "https://realm.rxd", + callback: "https://evil.example/steal", + }) + ); + expect( + dropped.ok && dropped.request.t === "swap-cancel-request" && dropped.request.callback + ).toBeUndefined(); + }); + + it("round-trips via encodeReqParam", () => { + const req: SwapCancelRequest = { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-cancel-request", + ref: SAMPLE_REF, + id: "abc", + }; + const param = encodeReqParam(req); + const r = parseConnectRequest(param); + expect(r.ok).toBe(true); + if (r.ok && r.request.t === "swap-cancel-request") { + expect(r.request.ref).toBe(SAMPLE_REF); + expect(r.request.id).toBe("abc"); + } else { + throw new Error("expected a swap-cancel-request"); + } + }); +}); + +describe("buildSwapCancelResult / encodeSwapCancelResult / buildSwapCancelCallbackUrl", () => { + it("builds and serializes a result, echoing the id", () => { + const result = buildSwapCancelResult({ id: "req-1" }, { txid: "dd".repeat(32) }); + expect(result).toMatchObject({ + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-cancel-result", + id: "req-1", + txid: "dd".repeat(32), + }); + expect(JSON.parse(encodeSwapCancelResult(result))).toEqual(result); + }); + + it("omits id when the request had none", () => { + const result = buildSwapCancelResult({}, { txid: "dd".repeat(32) }); + expect("id" in result).toBe(false); + }); + + it("puts the txid in the fragment, never the query", () => { + const url = buildSwapCancelCallbackUrl( + { callback: "https://realm.rxd/cb" }, + { txid: "dd".repeat(32) } + )!; + expect(url.indexOf("#")).toBeGreaterThan(-1); + expect(url.slice(0, url.indexOf("#"))).not.toMatch(/[?&]/); + }); + + it("returns undefined when the request has no callback", () => { + expect( + buildSwapCancelCallbackUrl({}, { txid: "dd".repeat(32) }) + ).toBeUndefined(); + }); +}); + +describe("buildRejectCallbackUrl", () => { + it("puts rejected=true and the id in the fragment, never the query", () => { + const url = buildRejectCallbackUrl({ + callback: "https://realm.rxd/cb", + id: "req-1", + }); + expect(url).toBe("https://realm.rxd/cb#id=req-1&rejected=true"); + }); + + it("omits id when the request had none", () => { + const url = buildRejectCallbackUrl({ callback: "https://realm.rxd/cb" }); + expect(url).toBe("https://realm.rxd/cb#rejected=true"); + }); + + it("returns undefined when the request has no callback", () => { + expect(buildRejectCallbackUrl({})).toBeUndefined(); + expect(buildRejectCallbackUrl({ id: "req-1" })).toBeUndefined(); + }); + + it("returns undefined rather than truncate when the composed URL is too large", () => { + const url = buildRejectCallbackUrl({ + callback: "https://realm.rxd/cb", + id: "x".repeat(MAX_CALLBACK_URL_LEN), + }); + expect(url).toBeUndefined(); + }); +}); + +describe("classifyConnectError", () => { + it("classifies a locked-wallet message", () => { + expect(classifyConnectError(new Error("Wallet is locked — unable to sign")).code).toBe( + "locked" + ); + expect(classifyConnectError(new Error("wallet unlock required")).code).toBe("locked"); + }); + + it("classifies an insufficient-funds message", () => { + expect(classifyConnectError(new Error("Insufficient funds for this transaction")).code).toBe( + "insufficient_funds" + ); + expect(classifyConnectError(new Error("not enough funds available")).code).toBe( + "insufficient_funds" + ); + }); + + it("classifies an already-spent message", () => { + // Verbatim message from swapFlow.ts's acceptSwapOffer — the "offer + // already spent" case the error callback exists for in the first place. + expect( + classifyConnectError( + new Error("this offer has already been completed or cancelled") + ).code + ).toBe("already_spent"); + expect(classifyConnectError(new Error("swap already cancelled")).code).toBe( + "already_spent" + ); + }); + + it("classifies a not-found message", () => { + // Verbatim message from swapFlow.ts's cancelSwapOffer. + expect( + classifyConnectError( + new Error("could not find a pending offer for that token") + ).code + ).toBe("not_found"); + expect(classifyConnectError(new Error("could not resolve the token")).code).toBe( + "not_found" + ); + }); + + it("classifies an invalid-request message", () => { + expect(classifyConnectError(new Error("only image/png is supported")).code).toBe( + "invalid_request" + ); + // Verbatim message from swapFlow.ts's acceptSwapOffer. + expect( + classifyConnectError( + new Error("psrt must have exactly one input and one output") + ).code + ).toBe("invalid_request"); + }); + + it("falls back to unknown for an unrecognized message", () => { + expect(classifyConnectError(new Error("something went sideways")).code).toBe("unknown"); + }); + + it("stringifies a non-Error throw and still returns a code", () => { + const result = classifyConnectError("wallet is locked"); + expect(result.code).toBe("locked"); + expect(result.message).toBe("wallet is locked"); + }); +}); + +describe("buildErrorCallbackUrl", () => { + it("puts error and message in the fragment, never the query", () => { + const url = buildErrorCallbackUrl( + { callback: "https://realm.rxd/cb", id: "req-1" }, + { code: "insufficient_funds", message: "Not enough RXD to cover the fee" } + ); + expect(url).toBe( + "https://realm.rxd/cb#id=req-1&error=insufficient_funds&message=" + + encodeURIComponent("Not enough RXD to cover the fee") + ); + expect(url!.indexOf("#")).toBeGreaterThan(-1); + expect(url!.slice(0, url!.indexOf("#"))).not.toMatch(/[?&]/); + }); + + it("omits id when the request had none", () => { + const url = buildErrorCallbackUrl( + { callback: "https://realm.rxd/cb" }, + { code: "locked", message: "Wallet is locked" } + ); + expect(url).toBe( + "https://realm.rxd/cb#error=locked&message=" + + encodeURIComponent("Wallet is locked") + ); + }); + + it("returns undefined when the request has no callback", () => { + expect( + buildErrorCallbackUrl({}, { code: "unknown", message: "oops" }) + ).toBeUndefined(); + }); + + it("returns undefined rather than truncate when the composed URL is too large", () => { + const url = buildErrorCallbackUrl( + { callback: "https://realm.rxd/cb" }, + { code: "unknown", message: "x".repeat(MAX_CALLBACK_URL_LEN) } + ); + expect(url).toBeUndefined(); + }); +}); diff --git a/packages/app/src/connect/mintFlow.ts b/packages/app/src/connect/mintFlow.ts new file mode 100644 index 0000000..df22d65 --- /dev/null +++ b/packages/app/src/connect/mintFlow.ts @@ -0,0 +1,239 @@ +/** + * Non-React glue between the connect `mint-request` flow and the wallet's + * own state: turning a validated {@link MintRequest} into a Glyph NFT + * payload, self-funding it from the wallet's own RXD UTXOs (never + * dApp-specified inputs — see `docs/psbt.md`'s design notes on why PSBT + * doesn't fit minting), and broadcasting the resulting commit+reveal pair. + * + * Mirrors `packages/app/src/pages/Mint.tsx`'s own NFT-mint path as closely as + * possible — same `mintToken` call, same commit-then-reveal broadcast order, + * same "missing inputs" retry — so a connect-driven mint behaves identically + * to one the user made by hand. The one deliberate difference: MIME types and + * embedded-content size are enforced here too (`protocol.ts`'s + * `MINT_ALLOWED_MIME_TYPES` / `MAX_MINT_DATA_LEN`), because this content is + * dApp-controlled, not the user's own file picker. + */ +import { Buffer } from "buffer"; +import { mintEmbedMaxBytes } from "@app/config.json"; +import db from "@app/db"; +import { electrumWorker } from "@app/electrum/Electrum"; +import { feeRate as feeRateSignal } from "@app/signals"; +import { ContractType } from "@app/types"; +import { updateRxdBalances } from "@app/utxos"; +import { mintToken } from "@lib/mint"; +import { sanitizeSvgBytes, looksLikeSvg } from "@app/svgSanitize"; +import { GLYPH_NFT } from "@lib/protocols"; +import type { + SmartTokenEmbeddedFile, + SmartTokenPayload, + SmartTokenRemoteFile, +} from "@lib/types"; +import type { MintRequest } from "@app/connect/protocol"; + +export class MintRequestError extends Error { + constructor(message: string) { + super(message); + this.name = "MintRequestError"; + } +} + +/** base64/base64url → bytes, tolerant of the URL-safe alphabet. */ +function base64ToBytes(b64: string): Uint8Array { + const normalized = b64.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + return Uint8Array.from(Buffer.from(padded, "base64")); +} + +/** + * Build the Glyph v2 NFT payload for a validated mint request. Pure aside + * from throwing `MintRequestError` on oversized content — no key access, no + * network, no database. + */ +export function buildMintPayload(req: MintRequest): SmartTokenPayload { + let main: SmartTokenEmbeddedFile | SmartTokenRemoteFile; + + if ("data" in req.main) { + const bytes = base64ToBytes(req.main.data); + if (bytes.length > mintEmbedMaxBytes) { + throw new MintRequestError( + `main content exceeds the ${mintEmbedMaxBytes / 1024}KB on-chain limit` + ); + } + const sanitized = + req.main.mime === "image/svg+xml" || looksLikeSvg(bytes) + ? sanitizeSvgBytes(bytes) + : bytes; + main = { t: req.main.mime, b: sanitized }; + } else { + main = { t: req.main.mime, u: req.main.url }; + } + + return { + v: 2, + p: [GLYPH_NFT], + name: req.name, + ...(req.description ? { desc: req.description } : {}), + ...(req.license ? { license: req.license } : {}), + ...(req.attrs ? { attrs: req.attrs } : {}), + main, + } as SmartTokenPayload; +} + +function isMissingInputsError(error: unknown): boolean { + const message = + error instanceof Error ? error.message : typeof error === "string" ? error : ""; + return message.toLowerCase().includes("missing inputs"); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export type MintOutcome = { + broadcast: boolean; + ref: string; + commitTxid?: string; + revealTxid?: string; + commitHex?: string; + revealHex?: string; +}; + +/** + * Fund, build, and sign an NFT mint for `req`, using the wallet's own RXD + * UTXOs — the dApp never specifies which coins to spend. Broadcasts unless + * `req.broadcast === false` (a dry run), in which case the built-and-signed + * commit/reveal hex is returned for inspection instead — nothing is sent, + * nothing changes on-chain. Throws on insufficient funds (surfaced from + * `mintToken`'s `fundTx` as a plain Error) or a `MintRequestError` for + * oversized content. + */ +export async function mintFromRequest( + req: MintRequest, + wif: string, + address: string +): Promise { + const payload = buildMintPayload(req); + + try { + await electrumWorker.value.manualSync(); + } catch (error) { + console.debug("[mintFlow] pre-mint UTXO refresh failed", error); + } + const coins = await db.txo + .where({ contractType: ContractType.RXD, spent: 0 }) + .toArray(); + + const feeRate = req.feeRate ?? feeRateSignal.value; + const { commitTx, revealTx, ref } = mintToken( + "nft", + { method: "direct", params: { address }, value: 1 }, + wif, + coins, + payload, + [], + feeRate + ); + + if (req.broadcast === false) { + return { + broadcast: false, + ref: ref.toString(), + commitHex: commitTx.toString(), + revealHex: revealTx.toString(), + }; + } + + const commitTxid = await electrumWorker.value.broadcast(commitTx.toString()); + + // The commit is now irreversible and already on-chain. Activity logging + // and balance refreshes are best-effort from here: none of it is needed + // to complete the mint, so a failure here shouldn't surface as a request + // error (the connect error callback would tell the caller the whole mint + // failed, when in fact the commit already succeeded). + try { + await db.broadcast.put({ + txid: commitTxid, + date: Date.now(), + description: "nft_mint", + }); + } catch (error) { + console.error( + "[mintFlow] failed to log commit broadcast activity (commit already succeeded)", + error + ); + } + + try { + await electrumWorker.value.manualSync(); + } catch (error) { + console.debug("[mintFlow] post-commit UTXO refresh failed", error); + } + try { + await updateRxdBalances(address); + } catch (error) { + console.error( + "[mintFlow] post-commit balance refresh failed (commit already succeeded)", + error + ); + } + + let revealTxid: string; + try { + revealTxid = await electrumWorker.value.broadcast(revealTx.toString()); + } catch (error) { + if (!isMissingInputsError(error)) { + // Unlike the bookkeeping above, this one really is unrecoverable here + // — there is no valid MintOutcome without a revealTxid. Mention the + // already-broadcast commit so the caller isn't left with just a + // generic failure for what is actually a stuck commit-without-reveal. + throw new MintRequestError( + `commit broadcast as ${commitTxid}, but the reveal transaction failed to broadcast: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + console.debug( + "[mintFlow] Reveal broadcast returned Missing inputs; refreshing UTXOs and retrying" + ); + await electrumWorker.value.manualSync(); + await wait(1500); + try { + revealTxid = await electrumWorker.value.broadcast(revealTx.toString()); + } catch (retryError) { + throw new MintRequestError( + `commit broadcast as ${commitTxid}, but the reveal transaction failed to broadcast after retrying: ${ + retryError instanceof Error ? retryError.message : String(retryError) + }` + ); + } + } + + try { + await db.broadcast.put({ + txid: revealTxid, + date: Date.now(), + description: "nft_mint", + }); + } catch (error) { + console.error( + "[mintFlow] failed to log reveal broadcast activity (reveal already succeeded)", + error + ); + } + + try { + await electrumWorker.value.manualSync(); + } catch (error) { + console.debug("[mintFlow] post-reveal UTXO refresh failed", error); + } + try { + await updateRxdBalances(address); + } catch (error) { + console.error( + "[mintFlow] post-reveal balance refresh failed (reveal already succeeded)", + error + ); + } + + return { broadcast: true, commitTxid, revealTxid, ref: ref.toString() }; +} diff --git a/packages/app/src/connect/protocol.ts b/packages/app/src/connect/protocol.ts index c9d1939..6428527 100644 --- a/packages/app/src/connect/protocol.ts +++ b/packages/app/src/connect/protocol.ts @@ -1,33 +1,65 @@ /** - * Wire format for the external-wallet "connect" signing handshake (Phase A). + * Wire format for the external-wallet "connect" handshake. * * Transport-agnostic: the same request/result envelopes ride over QR, paste, * or a deep-link `?req=` param. This module is PURE (no React, no key access) * so it is exhaustively unit-testable and can never touch a secret. * - * Flow (see GlyphGalaxy `docs/WALLET_CONNECT_SCOPE.md`): - * 1. The dApp emits a namespaced challenge — e.g. - * `glyphgalaxy:wallet-connect:v1::` — as a bare string - * or wrapped in a {@link SignRequest} envelope (so it can carry the - * requesting origin for display). - * 2. Photonic parses + validates it here, shows it to the user for explicit - * approval, signs via `@lib/sign`, and returns a {@link SignResult}. - * 3. The dApp verifies the signature with radiantjs `Message.verify`. + * Five request types share this envelope: + * + * - `sign-request` (Phase A, see GlyphGalaxy `docs/WALLET_CONNECT_SCOPE.md`): + * the dApp emits a namespaced challenge — e.g. + * `glyphgalaxy:wallet-connect:v1::` — as a bare string or + * wrapped in a {@link SignRequest} envelope. Photonic signs it via + * `@lib/sign` (a message, never a transaction) and returns a + * {@link SignResult}. + * - `psbt-sign-request` (see `docs/psbt.md`): the dApp hands over a Radiant + * PSBT (base64/base64url). Photonic signs whatever P2PKH inputs it owns + * via `@lib/psbt`'s `signPsbt`, then either returns the (possibly still + * partial) signed PSBT or — only when the request opts in with + * `broadcast: true` and every input ends up signed — finalizes, extracts, + * and broadcasts, returning a txid instead. See {@link PsbtSignRequest}. + * - `mint-request` (see {@link MintRequest}): the dApp sends NFT metadata + * plus its primary content (embedded base64 or a remote URL) — no + * transaction at all. Photonic funds the mint from its OWN wallet UTXOs + * (self-funding coin selection, never dApp-specified inputs), builds and + * signs the commit+reveal pair via `@lib/mint`'s `mintToken`, broadcasts + * both, and returns `{commitTxid, revealTxid, ref}`. Unlike the other two + * types this always broadcasts — there is no "return unsigned" option, + * matching the wallet's own local Mint page. + * - `swap-offer-request` / `swap-accept-request` (see `docs/swap-request.md`): + * the maker side (`swap-offer-request`) reserves an owned NFT into the + * swap subaccount and returns a raw PSRT — NOT a `@lib/psbt` PSBT, the + * older "Partially Signed Radiant Transaction" convention already used by + * the wallet's own Swap page — for the dApp to distribute or index + * itself (`mode` must be `"private"`; there is no on-chain advertisement + * over connect). The taker side (`swap-accept-request`) completes and + * broadcasts a pasted-in PSRT, optionally appending a marketplace fee + * output (`feeRxd`/`feeAddress`) alongside any enforced creator royalty, + * and returns `{txid}`. * * The result normally returns to the dApp by hand (copy/paste or QR). A request * may instead opt in to an automatic return by carrying a `callback` URL; the - * result then rides back in that URL's fragment (see {@link buildCallbackUrl}). - * A `callback` is honoured ONLY when its origin matches the envelope's declared - * `origin`, so one site can never route another site's signature elsewhere. + * result then rides back in that URL's fragment (see {@link buildCallbackUrl}, + * {@link buildPsbtCallbackUrl}, {@link buildMintCallbackUrl}). A `callback` is + * honoured ONLY when its origin matches the envelope's declared `origin`, so + * one site can never route another site's result elsewhere. * * SECURITY: parsing NEVER trusts unvalidated fields. The challenge is run * through the same guards the signer enforces (`@lib/sign`: length cap + * no control characters) so the UI can render it verbatim and the service can * never be handed a hidden payload. Display-only fields (origin/app/address) * are sanitized and silently dropped if malformed — they are advisory, never - * load-bearing for the signature. + * load-bearing for the signature. `broadcast` is the one field that changes + * *behavior*, so it is never silently coerced: only the literal `true` opts + * in, anything else means "return the PSBT" (the safer default). A mint + * request's `main` content is restricted to a fixed MIME allow-list + * ({@link MINT_ALLOWED_MIME_TYPES}) — stricter than the local Mint page's + * "any non-empty type", because this content is dApp-controlled, not the + * user's own file picker. */ import { MAX_MESSAGE_LENGTH, hasControlChars } from "@lib/sign"; +import { filterAttrs } from "@lib/token"; export const CONNECT_PROTOCOL = "photonic-connect"; export const CONNECT_VERSION = 1; @@ -65,8 +97,222 @@ export type SignResult = { signature: string; }; +export type PsbtSignRequest = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "psbt-sign-request"; + /** The PSBT to sign, base64 or base64url (see `@lib/psbt`). */ + psbt: string; + /** + * Opt in to the wallet finalizing + broadcasting when its signature(s) + * complete the transaction, returning a txid instead of a PSBT. Only the + * literal `true` opts in — anything else, including omission, means + * "always return the (possibly partial) signed PSBT". + */ + broadcast?: boolean; + /** Opaque correlation id echoed back in the result (optional). */ + id?: string; + /** Requesting site origin, for display + trust decisions (optional). */ + origin?: string; + /** Human-friendly app label, for display (optional). */ + app?: string; + /** + * Where to return the signed result, as a URL fragment (optional). + * + * Only ever populated when its origin matches {@link PsbtSignRequest.origin} + * — see `cleanCallback`. Absent means the classic manual copy/paste return. + */ + callback?: string; +}; + +export type PsbtSignResult = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "psbt-sign-result"; + id?: string; + /** Present when returning a (possibly still partial) signed PSBT. */ + psbt?: string; + /** Present when `broadcast` completed and the tx was accepted. */ + txid?: string; + /** True once every input carries a final scriptSig. */ + complete: boolean; +}; + +/** An embedded file: raw bytes carried inline, base64-encoded on the wire. */ +export type MintEmbeddedFile = { + /** MIME type; must be one of {@link MINT_ALLOWED_MIME_TYPES}. */ + mime: string; + /** Base64 (or base64url) content bytes. */ + data: string; +}; + +/** A remote file: the wallet embeds only a pointer, not the bytes. */ +export type MintRemoteFile = { + mime: string; + /** Must be an absolute http(s) URL. */ + url: string; +}; + +export type MintRequest = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "mint-request"; + /** NFT name (Glyph `name` field). */ + name: string; + description?: string; + license?: string; + /** Sanitized via `@lib/token`'s `filterAttrs`: string/number/boolean only. */ + attrs?: Record; + /** The NFT's primary content. */ + main: MintEmbeddedFile | MintRemoteFile; + /** Override the wallet's current fee rate (photons/byte), if provided. */ + feeRate?: number; + /** + * Build and sign but do NOT broadcast — returns raw transaction hex to + * inspect/decode instead of txids. Defaults to `true` (broadcast); only + * the literal `false` opts out, matching `psbt-sign-request`'s `broadcast` + * field convention (the one field that changes behavior is never silently + * coerced). + */ + broadcast?: boolean; + id?: string; + origin?: string; + app?: string; + callback?: string; +}; + +export type MintResult = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "mint-result"; + id?: string; + /** True once both transactions were actually broadcast. */ + broadcast: boolean; + /** Present when `broadcast` is true (the default). */ + commitTxid?: string; + revealTxid?: string; + /** Present when `broadcast: false` — nothing was sent; decode these to verify. */ + commitHex?: string; + revealHex?: string; + /** The (would-be) minted NFT's canonical ref (BE txid ‖ BE vout hex). */ + ref: string; +}; + +export type SwapOfferRequest = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "swap-offer-request"; + /** The NFT's canonical ref (BE txid ‖ BE vout hex), from the wallet's own vault. */ + ref: string; + /** The RXD price the maker wants, in whole/decimal RXD (not photons). */ + priceRxd: number; + /** + * Only `"private"` is supported in v1: the offer is reserved and a PSRT is + * returned for the dApp to distribute/index itself. There is no on-chain + * advertisement — a request for `"broadcast"` mode is rejected outright + * (see the local Swap page for that flow). + */ + mode: "private"; + id?: string; + origin?: string; + app?: string; + callback?: string; +}; + +export type SwapOfferResult = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "swap-offer-result"; + id?: string; + /** Raw partially-signed transaction hex (NOT a PSBT — see docs/swap-request.md). */ + psrt: string; + /** + * The reserved swap-subaccount outpoint the PSRT's input spends — a + * direct on-chain handle to the offer that needs no PSRT parsing. Check + * whether this outpoint is still unspent to know if the offer is still + * live (spent = completed or cancelled), the same check + * `swap-accept-request` itself does before completing a purchase. + */ + reserveTxid: string; + reserveVout: number; + /** The swap subaccount address the NFT was reserved into. */ + swapAddress: string; + /** Echoes the request's `ref`, for convenience. */ + ref: string; + /** + * The maker's own main address — where sale proceeds land, and where a + * reclaim (cancel) returns the NFT. Bind a listing's seller to this + * on-chain identity rather than to a login-session address; it's also + * exactly what to compare against when detecting a reclaim via + * `get_by_ref` (see docs/swap-request.md §4). + */ + payoutAddress: string; + /** Echoes the request's `priceRxd` — the reservation's actual signed + * price, so a caller can assert a listing matches without parsing the + * PSRT. */ + priceRxd: number; +}; + +export type SwapAcceptRequest = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "swap-accept-request"; + /** The maker's PSRT, as raw transaction hex. */ + psrt: string; + /** Marketplace fee amount in RXD; requires `feeAddress` alongside it. */ + feeRxd?: number; + /** Marketplace fee recipient; requires `feeRxd` alongside it. */ + feeAddress?: string; + id?: string; + origin?: string; + app?: string; + callback?: string; +}; + +export type SwapAcceptResult = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "swap-accept-result"; + id?: string; + txid: string; +}; + +export type SwapCancelRequest = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "swap-cancel-request"; + /** + * The NFT's canonical ref — the same one the original `swap-offer-request` + * carried. The wallet already tracks a pending swap per glyph (`swapPending` + * on the `db.glyph` row, and the `db.swap` row's `fromGlyph`), so the ref + * alone identifies which offer to cancel — no outpoint needed. + */ + ref: string; + id?: string; + origin?: string; + app?: string; + callback?: string; +}; + +export type SwapCancelResult = { + protocol: typeof CONNECT_PROTOCOL; + v: typeof CONNECT_VERSION; + t: "swap-cancel-result"; + id?: string; + /** The reclaim transaction's txid. */ + txid: string; +}; + +export type ConnectRequest = + | SignRequest + | PsbtSignRequest + | MintRequest + | SwapOfferRequest + | SwapAcceptRequest + | SwapCancelRequest; + export type ParsedRequest = - | { ok: true; request: SignRequest } + | { ok: true; request: ConnectRequest } | { ok: false; error: string }; const MAX_ID_LEN = 128; @@ -75,6 +321,63 @@ const MAX_ORIGIN_LEN = 256; const MAX_ADDRESS_LEN = 128; const MAX_CALLBACK_LEN = 512; +const MAX_MINT_NAME_LEN = 128; +const MAX_MINT_DESC_LEN = 2_048; +const MAX_MINT_LICENSE_LEN = 256; +const MAX_MINT_MIME_LEN = 128; +const MAX_MINT_URL_LEN = 2_048; +const MAX_MINT_ATTR_KEYS = 32; +const MAX_MINT_ATTR_KEY_LEN = 64; + +/** + * Cap on the `main.data` envelope field, in base64 characters. This is a + * coarse, char-length pre-check only (base64 padding makes it imprecise) — + * matches `mintEmbedMaxBytes` (512 KB, `packages/app/src/config.json`) with + * headroom for base64 expansion (~4/3). The exact byte-length enforcement + * happens after decoding, in `@app/connect/mintFlow`. + */ +export const MAX_MINT_DATA_LEN = 700_000; + +/** A ref is 36 bytes (32 txid + 4 vout) hex-encoded: exactly 72 hex chars. */ +const REF_RE = /^[0-9a-f]{72}$/i; + +/** Raw transaction hex — even length, hex charset. Generous cap: 50 KB. */ +const MAX_PSRT_HEX_LEN = 100_000; +const PSRT_HEX_RE = /^[0-9a-f]*$/i; + +/** + * MIME types the wallet will embed on-chain. Restricted (vs. the local Mint + * page's "any non-empty type" policy) because this content is dApp/attacker + * — not the user's own file-picker — controlled. + */ +export const MINT_ALLOWED_MIME_TYPES: readonly string[] = [ + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/svg+xml", + "text/plain", + "application/json", +]; + +/** + * Cap on the `psbt` envelope field, in base64 characters (~48 KB decoded). + * Deep-link URLs and QR codes both have practical size ceilings; requests + * carrying a larger PSBT must use a transport this protocol doesn't police + * (e.g. the dApp's own backend) and are rejected here rather than silently + * truncated. + */ +export const MAX_PSBT_LEN = 65_536; + +/** + * Cap on the whole callback URL this module will auto-navigate to. A signed + * PSBT returned as a fragment can be large; if the composed URL would exceed + * this, {@link buildPsbtCallbackUrl} returns undefined rather than risk a + * silently truncated result — the honest fallback is the manual copy/paste + * return. + */ +export const MAX_CALLBACK_URL_LEN = 8_192; + // `:wallet-connect:v:...` — the shape Phase A challenges take. // Used only to badge a request as "recognized" in the UI; non-matching // challenges are still signable (with a warning), never auto-rejected. @@ -228,22 +531,20 @@ function tryBase64ToString(s: string): string | undefined { } } -function normalizeEnvelope(obj: Record): ParsedRequest { - if (obj.t !== undefined && obj.t !== "sign-request") { - return { ok: false, error: `unsupported request type: ${String(obj.t)}` }; - } +/** protocol/version guards shared by every request type. */ +function envelopeBasicsError(obj: Record): string | null { if (obj.protocol !== undefined && obj.protocol !== CONNECT_PROTOCOL) { - return { - ok: false, - error: `unsupported protocol: ${String(obj.protocol)}`, - }; + return `unsupported protocol: ${String(obj.protocol)}`; } if (obj.v !== undefined && obj.v !== CONNECT_VERSION) { - return { - ok: false, - error: `unsupported protocol version: ${String(obj.v)}`, - }; + return `unsupported protocol version: ${String(obj.v)}`; } + return null; +} + +function normalizeSignEnvelope(obj: Record): ParsedRequest { + const basicsErr = envelopeBasicsError(obj); + if (basicsErr) return { ok: false, error: basicsErr }; const err = challengeError(obj.challenge); if (err) return { ok: false, error: err }; const origin = cleanOrigin(obj.origin); @@ -263,13 +564,320 @@ function normalizeEnvelope(obj: Record): ParsedRequest { }; } +/** A trimmed, whitespace-free, base64/base64url-charset string, length-capped. */ +function cleanPsbtField(v: unknown): string | undefined { + const s = cleanString(v, MAX_PSBT_LEN); + if (!s || /\s/.test(s)) return undefined; + if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(s)) return undefined; + return s; +} + +function normalizePsbtEnvelope(obj: Record): ParsedRequest { + const basicsErr = envelopeBasicsError(obj); + if (basicsErr) return { ok: false, error: basicsErr }; + const psbt = cleanPsbtField(obj.psbt); + if (!psbt) { + return { + ok: false, + error: + typeof obj.psbt === "string" && obj.psbt.length > MAX_PSBT_LEN + ? "psbt is too long" + : "request is missing a psbt", + }; + } + const origin = cleanOrigin(obj.origin); + return { + ok: true, + request: { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "psbt-sign-request", + psbt, + // Only the literal `true` opts in — see the module doc comment. + broadcast: obj.broadcast === true, + id: cleanString(obj.id, MAX_ID_LEN), + origin, + app: cleanString(obj.app, MAX_LABEL_LEN), + callback: cleanCallback(obj.callback, origin), + }, + }; +} + +/** A trimmed, whitespace-free, base64/base64url-charset string, length-capped. */ +function cleanMintDataField(v: unknown): string | undefined { + const s = cleanString(v, MAX_MINT_DATA_LEN); + if (!s || /\s/.test(s)) return undefined; + if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(s)) return undefined; + return s; +} + +/** An absolute http(s) URL, length-capped — mirrors `toHttpOrigin`'s guards. */ +function cleanMintUrl(v: unknown): string | undefined { + const s = cleanString(v, MAX_MINT_URL_LEN); + if (!s || /\s/.test(s)) return undefined; + try { + const url = new URL(s); + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + } catch { + return undefined; + } + return s; +} + +/** + * Validate the `main` content field: either an embedded file (`mime`+`data`, + * base64) or a remote pointer (`mime`+`url`). Returns an error string, or the + * cleaned field on success. The MIME allow-list applies to both forms — a + * remote file's `mime` still ends up recorded in the on-chain payload. + */ +function cleanMintMain( + v: unknown +): { ok: true; main: MintEmbeddedFile | MintRemoteFile } | { ok: false; error: string } { + if (!v || typeof v !== "object") return { ok: false, error: "request is missing main content" }; + const obj = v as Record; + const mime = cleanString(obj.mime, MAX_MINT_MIME_LEN); + if (!mime) return { ok: false, error: "main content is missing a mime type" }; + if (!MINT_ALLOWED_MIME_TYPES.includes(mime)) { + return { ok: false, error: `unsupported mime type: ${mime}` }; + } + if (typeof obj.data === "string") { + const data = cleanMintDataField(obj.data); + if (!data) { + return { + ok: false, + error: + obj.data.length > MAX_MINT_DATA_LEN + ? "main content is too large" + : "main content data is not valid base64", + }; + } + return { ok: true, main: { mime, data } }; + } + if (typeof obj.url === "string") { + const url = cleanMintUrl(obj.url); + if (!url) return { ok: false, error: "main content url is not a valid http(s) URL" }; + return { ok: true, main: { mime, url } }; + } + return { ok: false, error: "main content must carry either data or a url" }; +} + +/** Cap attrs to a bounded number of short keys, then sanitize values via + * `@lib/token`'s `filterAttrs` (string/number/boolean, <100 chars each) — the + * same rule the local Mint page's own payload is expected to satisfy. */ +function cleanMintAttrs( + v: unknown +): Record | undefined { + if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; + const entries = Object.entries(v as Record).filter( + ([k]) => k.length > 0 && k.length <= MAX_MINT_ATTR_KEY_LEN && !hasControlChars(k) + ); + if (entries.length === 0) return undefined; + const capped = Object.fromEntries(entries.slice(0, MAX_MINT_ATTR_KEYS)); + const filtered = filterAttrs(capped) as Record; + return Object.keys(filtered).length ? filtered : undefined; +} + +function normalizeMintEnvelope(obj: Record): ParsedRequest { + const basicsErr = envelopeBasicsError(obj); + if (basicsErr) return { ok: false, error: basicsErr }; + + const name = cleanString(obj.name, MAX_MINT_NAME_LEN); + if (!name) return { ok: false, error: "request is missing a name" }; + + const mainResult = cleanMintMain(obj.main); + if (!mainResult.ok) return { ok: false, error: mainResult.error }; + + if (obj.feeRate !== undefined) { + if (typeof obj.feeRate !== "number" || !Number.isFinite(obj.feeRate) || obj.feeRate <= 0) { + return { ok: false, error: "feeRate must be a positive number" }; + } + } + + const origin = cleanOrigin(obj.origin); + return { + ok: true, + request: { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "mint-request", + name, + description: cleanString(obj.description, MAX_MINT_DESC_LEN), + license: cleanString(obj.license, MAX_MINT_LICENSE_LEN), + attrs: cleanMintAttrs(obj.attrs), + main: mainResult.main, + feeRate: typeof obj.feeRate === "number" ? obj.feeRate : undefined, + // Only the literal `false` opts out of the default (broadcast) — see + // the module doc comment. + broadcast: obj.broadcast === false ? false : true, + id: cleanString(obj.id, MAX_ID_LEN), + origin, + app: cleanString(obj.app, MAX_LABEL_LEN), + callback: cleanCallback(obj.callback, origin), + }, + }; +} + +/** A trimmed, whitespace-free, hex-charset string, length- and parity-capped. */ +function cleanPsrtField(v: unknown): string | undefined { + const s = cleanString(v, MAX_PSRT_HEX_LEN); + if (!s || /\s/.test(s)) return undefined; + if (s.length % 2 !== 0 || !PSRT_HEX_RE.test(s)) return undefined; + return s; +} + +function normalizeSwapOfferEnvelope(obj: Record): ParsedRequest { + const basicsErr = envelopeBasicsError(obj); + if (basicsErr) return { ok: false, error: basicsErr }; + + const ref = typeof obj.ref === "string" ? obj.ref.trim().toLowerCase() : undefined; + if (!ref || !REF_RE.test(ref)) { + return { ok: false, error: "request is missing a valid ref" }; + } + + if ( + typeof obj.priceRxd !== "number" || + !Number.isFinite(obj.priceRxd) || + obj.priceRxd <= 0 + ) { + return { ok: false, error: "priceRxd must be a positive number" }; + } + + if (obj.mode !== "private") { + return { + ok: false, + error: + obj.mode === "broadcast" + ? "broadcast mode is not yet supported over connect — use the wallet's Swap page" + : "mode must be \"private\"", + }; + } + + const origin = cleanOrigin(obj.origin); + return { + ok: true, + request: { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-offer-request", + ref, + priceRxd: obj.priceRxd, + mode: "private", + id: cleanString(obj.id, MAX_ID_LEN), + origin, + app: cleanString(obj.app, MAX_LABEL_LEN), + callback: cleanCallback(obj.callback, origin), + }, + }; +} + +function normalizeSwapAcceptEnvelope(obj: Record): ParsedRequest { + const basicsErr = envelopeBasicsError(obj); + if (basicsErr) return { ok: false, error: basicsErr }; + + const psrt = cleanPsrtField(obj.psrt); + if (!psrt) { + return { + ok: false, + error: + typeof obj.psrt === "string" && obj.psrt.length > MAX_PSRT_HEX_LEN + ? "psrt is too long" + : "request is missing a valid psrt", + }; + } + + // feeRxd and feeAddress are a pair: both present or both absent. A fee + // amount with no recipient (or vice versa) is always a caller mistake, so + // it's rejected rather than silently dropped. + const hasFeeRxd = obj.feeRxd !== undefined; + const hasFeeAddress = obj.feeAddress !== undefined; + if (hasFeeRxd !== hasFeeAddress) { + return { ok: false, error: "feeRxd and feeAddress must be provided together" }; + } + let feeRxd: number | undefined; + let feeAddress: string | undefined; + if (hasFeeRxd) { + if (typeof obj.feeRxd !== "number" || !Number.isFinite(obj.feeRxd) || obj.feeRxd <= 0) { + return { ok: false, error: "feeRxd must be a positive number" }; + } + feeAddress = cleanAddress(obj.feeAddress); + if (!feeAddress) return { ok: false, error: "feeAddress is not a valid address" }; + feeRxd = obj.feeRxd; + } + + const origin = cleanOrigin(obj.origin); + return { + ok: true, + request: { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-accept-request", + psrt, + feeRxd, + feeAddress, + id: cleanString(obj.id, MAX_ID_LEN), + origin, + app: cleanString(obj.app, MAX_LABEL_LEN), + callback: cleanCallback(obj.callback, origin), + }, + }; +} + +function normalizeSwapCancelEnvelope(obj: Record): ParsedRequest { + const basicsErr = envelopeBasicsError(obj); + if (basicsErr) return { ok: false, error: basicsErr }; + + const ref = typeof obj.ref === "string" ? obj.ref.trim().toLowerCase() : undefined; + if (!ref || !REF_RE.test(ref)) { + return { ok: false, error: "request is missing a valid ref" }; + } + + const origin = cleanOrigin(obj.origin); + return { + ok: true, + request: { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-cancel-request", + ref, + id: cleanString(obj.id, MAX_ID_LEN), + origin, + app: cleanString(obj.app, MAX_LABEL_LEN), + callback: cleanCallback(obj.callback, origin), + }, + }; +} + +function normalizeEnvelope(obj: Record): ParsedRequest { + const t = obj.t; + if ( + t !== undefined && + t !== "sign-request" && + t !== "psbt-sign-request" && + t !== "mint-request" && + t !== "swap-offer-request" && + t !== "swap-accept-request" && + t !== "swap-cancel-request" + ) { + return { ok: false, error: `unsupported request type: ${String(t)}` }; + } + if (t === "psbt-sign-request") return normalizePsbtEnvelope(obj); + if (t === "mint-request") return normalizeMintEnvelope(obj); + if (t === "swap-offer-request") return normalizeSwapOfferEnvelope(obj); + if (t === "swap-accept-request") return normalizeSwapAcceptEnvelope(obj); + if (t === "swap-cancel-request") return normalizeSwapCancelEnvelope(obj); + return normalizeSignEnvelope(obj); +} + /** * Parse a raw connect request from any transport. Accepts, in order: - * 1. a JSON {@link SignRequest} envelope, + * 1. a JSON envelope ({@link SignRequest} or {@link PsbtSignRequest}, by `t`), * 2. a base64url-encoded JSON envelope (deep-link `?req=` form), - * 3. a bare challenge string (the scope's server emits just the challenge). + * 3. a bare challenge string (the scope's server emits just the challenge; + * always a `sign-request` — a bare PSBT blob is never auto-accepted, an + * explicit `psbt-sign-request` envelope is required so intent is + * unambiguous). */ -export function parseSignRequest(raw: string): ParsedRequest { +export function parseConnectRequest(raw: string): ParsedRequest { if (typeof raw !== "string") return { ok: false, error: "no request" }; const trimmed = raw.trim(); if (!trimmed) return { ok: false, error: "empty request" }; @@ -294,6 +902,26 @@ export function parseSignRequest(raw: string): ParsedRequest { }; } +export type ParsedSignRequest = + | { ok: true; request: SignRequest } + | { ok: false; error: string }; + +/** + * @deprecated use {@link parseConnectRequest} — kept as a narrowly-typed + * alias for existing sign-request-only call sites. Behaves identically to + * `parseConnectRequest`, including correctly erroring if handed a + * `psbt-sign-request` envelope — the narrower return type here just reflects + * the historical "this is always a SignRequest" contract. + */ +export function parseSignRequest(raw: string): ParsedSignRequest { + const r = parseConnectRequest(raw); + if (!r.ok) return r; + if (r.request.t !== "sign-request") { + return { ok: false, error: `unsupported request type: ${r.request.t}` }; + } + return { ok: true, request: r.request }; +} + /** True if the challenge matches the recognized `…:wallet-connect:vN:…` shape. */ export function isRecognizedConnectChallenge(challenge: string): boolean { return typeof challenge === "string" && CONNECT_CHALLENGE_RE.test(challenge); @@ -351,8 +979,13 @@ export function encodeSignResult(result: SignResult): string { return JSON.stringify(result); } +/** Serialize a {@link PsbtSignResult} for the response QR / copy box. */ +export function encodePsbtResult(result: PsbtSignResult): string { + return JSON.stringify(result); +} + /** base64url-encode a request envelope (for generating a deep link / QR). */ -export function encodeReqParam(request: SignRequest): string { +export function encodeReqParam(request: ConnectRequest): string { const json = JSON.stringify(request); const b64 = typeof Buffer !== "undefined" @@ -360,3 +993,371 @@ export function encodeReqParam(request: SignRequest): string { : btoa(json); return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } + +/** @deprecated use {@link encodeReqParam} — kept as a named alias for dApp docs. */ +export const encodePsbtReqParam = encodeReqParam; + +/** Build a {@link PsbtSignResult} from a request + the outcome of signing. */ +export function buildPsbtResult( + req: Pick, + out: { psbt?: string; txid?: string; complete: boolean } +): PsbtSignResult { + return { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "psbt-sign-result", + ...(req.id ? { id: req.id } : {}), + ...(out.psbt !== undefined ? { psbt: out.psbt } : {}), + ...(out.txid !== undefined ? { txid: out.txid } : {}), + complete: out.complete, + }; +} + +/** + * The URL to hand a {@link PsbtSignResult} back to an opt-in `callback`, or + * undefined when the request declared none, or when the composed URL would + * exceed {@link MAX_CALLBACK_URL_LEN} — a signed PSBT can be large, and a + * silently truncated auto-return is worse than falling back to the manual + * copy/paste return. + * + * As with {@link buildCallbackUrl}, the result rides in the URL FRAGMENT, + * never the query, so it never reaches a server's access/proxy logs. + */ +export function buildPsbtCallbackUrl( + req: Pick, + result: Pick +): string | undefined { + if (!req.callback) return undefined; + const params: [string, string][] = [ + ...(result.id ? ([["id", result.id]] as [string, string][]) : []), + ...(result.txid !== undefined + ? ([["txid", result.txid]] as [string, string][]) + : []), + ...(result.psbt !== undefined + ? ([["psbt", result.psbt]] as [string, string][]) + : []), + ["complete", String(result.complete)], + ]; + const fragment = params + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + const url = `${req.callback}#${fragment}`; + return url.length <= MAX_CALLBACK_URL_LEN ? url : undefined; +} + +/** Serialize a {@link SwapOfferResult} for the response QR / copy box. */ +export function encodeSwapOfferResult(result: SwapOfferResult): string { + return JSON.stringify(result); +} + +/** Build a {@link SwapOfferResult} from a request + the maker's raw PSRT. */ +export function buildSwapOfferResult( + req: Pick, + out: { + psrt: string; + reserveTxid: string; + reserveVout: number; + swapAddress: string; + ref: string; + payoutAddress: string; + priceRxd: number; + } +): SwapOfferResult { + return { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-offer-result", + ...(req.id ? { id: req.id } : {}), + psrt: out.psrt, + reserveTxid: out.reserveTxid, + reserveVout: out.reserveVout, + swapAddress: out.swapAddress, + ref: out.ref, + payoutAddress: out.payoutAddress, + priceRxd: out.priceRxd, + }; +} + +/** + * The URL to hand a {@link SwapOfferResult} back to an opt-in `callback`. As + * with the other result types, undefined when the request declared none or + * the composed URL would exceed {@link MAX_CALLBACK_URL_LEN} — a raw PSRT can + * be sizeable, and the honest fallback is the manual copy/paste return. + */ +export function buildSwapOfferCallbackUrl( + req: Pick, + result: Pick< + SwapOfferResult, + | "id" + | "psrt" + | "reserveTxid" + | "reserveVout" + | "swapAddress" + | "ref" + | "payoutAddress" + | "priceRxd" + > +): string | undefined { + if (!req.callback) return undefined; + const params: [string, string][] = [ + ...(result.id ? ([["id", result.id]] as [string, string][]) : []), + ["psrt", result.psrt], + ["reserveTxid", result.reserveTxid], + ["reserveVout", String(result.reserveVout)], + ["swapAddress", result.swapAddress], + ["ref", result.ref], + ["payoutAddress", result.payoutAddress], + ["priceRxd", String(result.priceRxd)], + ]; + const fragment = params + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + const url = `${req.callback}#${fragment}`; + return url.length <= MAX_CALLBACK_URL_LEN ? url : undefined; +} + +/** Serialize a {@link SwapAcceptResult} for the response QR / copy box. */ +export function encodeSwapAcceptResult(result: SwapAcceptResult): string { + return JSON.stringify(result); +} + +/** Build a {@link SwapAcceptResult} from a request + the broadcast txid. */ +export function buildSwapAcceptResult( + req: Pick, + out: { txid: string } +): SwapAcceptResult { + return { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-accept-result", + ...(req.id ? { id: req.id } : {}), + txid: out.txid, + }; +} + +/** The URL to hand a {@link SwapAcceptResult} back to an opt-in `callback`. */ +export function buildSwapAcceptCallbackUrl( + req: Pick, + result: Pick +): string | undefined { + if (!req.callback) return undefined; + const params: [string, string][] = [ + ...(result.id ? ([["id", result.id]] as [string, string][]) : []), + ["txid", result.txid], + ]; + const fragment = params + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + const url = `${req.callback}#${fragment}`; + return url.length <= MAX_CALLBACK_URL_LEN ? url : undefined; +} + +/** Serialize a {@link MintResult} for the response QR / copy box. */ +export function encodeMintResult(result: MintResult): string { + return JSON.stringify(result); +} + +/** Build a {@link MintResult} from a request + the outcome of minting. */ +export function buildMintResult( + req: Pick, + out: { + broadcast: boolean; + ref: string; + commitTxid?: string; + revealTxid?: string; + commitHex?: string; + revealHex?: string; + } +): MintResult { + return { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "mint-result", + ...(req.id ? { id: req.id } : {}), + broadcast: out.broadcast, + ref: out.ref, + ...(out.commitTxid !== undefined ? { commitTxid: out.commitTxid } : {}), + ...(out.revealTxid !== undefined ? { revealTxid: out.revealTxid } : {}), + ...(out.commitHex !== undefined ? { commitHex: out.commitHex } : {}), + ...(out.revealHex !== undefined ? { revealHex: out.revealHex } : {}), + }; +} + +/** + * The URL to hand a {@link MintResult} back to an opt-in `callback`, or + * undefined when the request declared none or the composed URL would exceed + * {@link MAX_CALLBACK_URL_LEN}. As with the other result types, the payload + * rides in the URL FRAGMENT, never the query. + * + * A dry-run (`broadcast: false`) result carries raw hex, which — especially + * with embedded content — can easily blow past any reasonable URL length; + * that's exactly what the length cap is for, falling back to manual + * copy/QR rather than risk a silently truncated hex string. + */ +export function buildMintCallbackUrl( + req: Pick, + result: Pick< + MintResult, + "id" | "broadcast" | "ref" | "commitTxid" | "revealTxid" | "commitHex" | "revealHex" + > +): string | undefined { + if (!req.callback) return undefined; + const params: [string, string][] = [ + ...(result.id ? ([["id", result.id]] as [string, string][]) : []), + ["broadcast", String(result.broadcast)], + ["ref", result.ref], + ...(result.commitTxid !== undefined + ? ([["commitTxid", result.commitTxid]] as [string, string][]) + : []), + ...(result.revealTxid !== undefined + ? ([["revealTxid", result.revealTxid]] as [string, string][]) + : []), + ...(result.commitHex !== undefined + ? ([["commitHex", result.commitHex]] as [string, string][]) + : []), + ...(result.revealHex !== undefined + ? ([["revealHex", result.revealHex]] as [string, string][]) + : []), + ]; + const fragment = params + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + const url = `${req.callback}#${fragment}`; + return url.length <= MAX_CALLBACK_URL_LEN ? url : undefined; +} + +/** Serialize a {@link SwapCancelResult} for the response QR / copy box. */ +export function encodeSwapCancelResult(result: SwapCancelResult): string { + return JSON.stringify(result); +} + +/** Build a {@link SwapCancelResult} from a request + the reclaim txid. */ +export function buildSwapCancelResult( + req: Pick, + out: { txid: string } +): SwapCancelResult { + return { + protocol: CONNECT_PROTOCOL, + v: CONNECT_VERSION, + t: "swap-cancel-result", + ...(req.id ? { id: req.id } : {}), + txid: out.txid, + }; +} + +/** The URL to hand a {@link SwapCancelResult} back to an opt-in `callback`. */ +export function buildSwapCancelCallbackUrl( + req: Pick, + result: Pick +): string | undefined { + if (!req.callback) return undefined; + const params: [string, string][] = [ + ...(result.id ? ([["id", result.id]] as [string, string][]) : []), + ["txid", result.txid], + ]; + const fragment = params + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + const url = `${req.callback}#${fragment}`; + return url.length <= MAX_CALLBACK_URL_LEN ? url : undefined; +} + +/** + * Build a "rejected" callback URL, fired when the user declines to approve + * any request type. Generic across all six request types — they all share + * `callback`/`id` — so callers just pass the request through. Same + * origin-binding (already enforced when `callback` was parsed onto the + * request) and size-cap rules as the success callbacks. + */ +export function buildRejectCallbackUrl(req: { + callback?: string; + id?: string; +}): string | undefined { + if (!req.callback) return undefined; + const params: [string, string][] = [ + ...(req.id ? ([["id", req.id]] as [string, string][]) : []), + ["rejected", "true"], + ]; + const fragment = params + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + const url = `${req.callback}#${fragment}`; + return url.length <= MAX_CALLBACK_URL_LEN ? url : undefined; +} + +/** + * A coarse, stable classification of a wallet-side failure — deliberately + * approximate (matched from error message text, since the underlying flows + * throw a mix of typed errors and plain `Error`s that were never designed as + * a wire-level taxonomy) but specific enough for a dApp to branch on and + * fail fast, rather than only ever seeing "unknown". + */ +export type ConnectErrorCode = + | "locked" + | "not_found" + | "insufficient_funds" + | "already_spent" + | "invalid_request" + | "unknown"; + +/** + * Classify a caught error for the error callback (see + * {@link buildErrorCallbackUrl}). Best-effort substring matching against the + * error message — every throw site across mint/psbt/swap flow modules was + * audited to make sure its message matches one of these patterns, but this + * is not a formal contract those modules are held to, so treat `code` as a + * best-effort hint and always show `message` too. + */ +export function classifyConnectError(err: unknown): { + code: ConnectErrorCode; + message: string; +} { + const message = err instanceof Error ? err.message : String(err); + const lower = message.toLowerCase(); + let code: ConnectErrorCode = "unknown"; + if (/unlock|locked/.test(lower)) { + code = "locked"; + } else if (/insufficient|fund/.test(lower)) { + code = "insufficient_funds"; + } else if (/already (been )?(spent|completed|cancelled|pending)/.test(lower)) { + code = "already_spent"; + } else if ( + /not found|couldn.?t find|could not find|could not locate|could not resolve|could not fetch/.test( + lower + ) + ) { + code = "not_found"; + } else if ( + /only .*(are|is) supported|must (have|be)|missing|invalid|unsupported|exceeds/.test( + lower + ) + ) { + code = "invalid_request"; + } + return { code, message }; +} + +/** + * Build an "error" callback URL, fired when a wallet-side failure prevents + * completing ANY request type — a genuine failure (locked, not found, + * insufficient funds, already spent, ...), distinct from the user + * explicitly declining ({@link buildRejectCallbackUrl}). Without this, a + * dApp waiting on a deep-linked request has no way to distinguish "still + * working" from "failed" and is left hanging until its own timeout. + */ +export function buildErrorCallbackUrl( + req: { callback?: string; id?: string }, + error: { code: ConnectErrorCode; message: string } +): string | undefined { + if (!req.callback) return undefined; + const params: [string, string][] = [ + ...(req.id ? ([["id", req.id]] as [string, string][]) : []), + ["error", error.code], + ["message", error.message], + ]; + const fragment = params + .map(([k, v]) => `${k}=${encodeURIComponent(v)}`) + .join("&"); + const url = `${req.callback}#${fragment}`; + return url.length <= MAX_CALLBACK_URL_LEN ? url : undefined; +} diff --git a/packages/app/src/connect/psbtFlow.ts b/packages/app/src/connect/psbtFlow.ts new file mode 100644 index 0000000..7bb6ece --- /dev/null +++ b/packages/app/src/connect/psbtFlow.ts @@ -0,0 +1,225 @@ +/** + * Non-React glue between the connect PSBT flow and the wallet's own state: + * database-backed ownership/spent checks, best-effort prevout resolution for + * external inputs, signing, and the finalize-or-broadcast decision. Kept out + * of `Connect.tsx` so the approval UI stays declarative. + * + * `@lib/psbt`'s `analyzePsbt` is pure and only knows what the PSBT itself + * declares; this module is where that gets cross-checked against what the + * wallet actually has on record (`db.txo`) and, for informational purposes + * only, against the chain (`electrumWorker.getTransaction`). + */ +import { + DEFAULT_ALLOWED_SIGHASHES, + Psbt, + PsbtAnalysis, + PsbtError, + analyzePsbt, + extractTx, + finalizePsbt, + psbtToBase64, + signPsbt, +} from "@lib/psbt"; +import { p2pkhScript } from "@lib/script"; +import rjs from "@radiant-core/radiantjs"; +import db from "@app/db"; +import { electrumWorker } from "@app/electrum/Electrum"; +import { wallet } from "@app/signals"; +import { ContractType, TxO } from "@app/types"; + +const { Transaction } = rjs; + +export type PsbtInputEnrichment = { + index: number; + /** The wallet's own record for this outpoint, if it has one. */ + dbRecord?: TxO; + /** The PSBT's declared utxo disagrees with what the wallet has on record. */ + scriptMismatch: boolean; + /** The wallet already considers this outpoint spent (racing mempool?). */ + alreadySpent: boolean; +}; + +export type EnrichedPsbt = { + /** Owned inputs missing a utxo field are backfilled from `db.txo`; inputs + * with no wallet or chain data available are left as-is. Pass this, not + * the original, to `signPsbt`. */ + psbt: Psbt; + analysis: PsbtAnalysis; + inputs: PsbtInputEnrichment[]; + /** Non-empty ⇒ approval must be blocked; each entry is a user-facing reason. */ + blockers: string[]; + /** How many of the wallet's own inputs are ready to be (re-)signed. */ + signableCount: number; +}; + +/** + * Look up an outpoint's chain data for display purposes only (fee + * estimation on an external input the PSBT didn't attach a utxo for, and + * that isn't in `db.txo` either). Never used for signing — signPsbt only + * trusts `db.txo`-backed script/value for inputs it recognizes as the + * wallet's own, never a network-fetched value for someone else's input. + * `getTransaction` hash-verifies the response against `txid`, so a + * malicious server can at worst withhold data, not lie about it. + */ +async function tryFetchPrevout( + txid: string, + vout: number +): Promise<{ script: string; value: bigint } | undefined> { + try { + const hex = await electrumWorker.value.getTransaction(txid); + if (!hex) return undefined; + const output = new Transaction(hex).outputs[vout]; + if (!output) return undefined; + return { + script: output.script.toHex(), + value: BigInt(output.satoshisBN.toString()), + }; + } catch { + return undefined; + } +} + +export async function enrichPsbt(psbt: Psbt): Promise { + const address = wallet.value.address; + const ownScript = address ? p2pkhScript(address) : undefined; + const ownScripts = ownScript ? new Set([ownScript]) : new Set(); + + // First pass: txid/vout for every input, regardless of whether the PSBT + // attached a utxo field, so we know what to look up. + const preview = analyzePsbt(psbt); + + const inputs: PsbtInputEnrichment[] = []; + const backfilledInputs = await Promise.all( + psbt.inputs.map(async (input, index) => { + const { txid, vout } = preview.inputs[index]; + const dbRecord = await db.txo + .where("[txid+vout]") + .equals([txid, vout]) + .first() + .catch(() => undefined); + + let scriptMismatch = false; + let backfilled = input; + if (dbRecord) { + if (input.utxo) { + scriptMismatch = + input.utxo.script !== dbRecord.script || + input.utxo.value !== BigInt(dbRecord.value); + } else if (dbRecord.contractType === ContractType.RXD) { + // Updater role: our own unsigned input is missing its utxo field — + // fill it in from what the wallet has on record so signPsbt (which + // requires `utxo` to sign) has something to work with. + backfilled = { + ...input, + utxo: { script: dbRecord.script, value: BigInt(dbRecord.value) }, + }; + } + } else if (!input.utxo) { + // Not ours and the PSBT didn't attach a utxo — best-effort fetch + // purely so the approval screen can show a real fee instead of + // "unknown". signPsbt will still skip this input (`not-mine`). + const fetched = await tryFetchPrevout(txid, vout); + if (fetched) backfilled = { ...input, utxo: fetched }; + } + + inputs.push({ + index, + dbRecord, + scriptMismatch, + alreadySpent: dbRecord?.spent === 1, + }); + + return backfilled; + }) + ); + + const backfilledPsbt: Psbt = { ...psbt, inputs: backfilledInputs }; + const analysis = analyzePsbt(backfilledPsbt, { + ownScripts, + net: wallet.value.net, + }); + + const blockers: string[] = []; + if (analysis.warnings.includes("TOKEN_BEARING_INPUT")) { + blockers.push( + "This transaction spends a token-bearing input. Signing could destroy a token, so Photonic refuses to co-sign it." + ); + } + if (analysis.warnings.includes("MISSING_FORKID")) { + blockers.push( + "This transaction requests a signature type Radiant doesn't allow (missing SIGHASH_FORKID)." + ); + } + const mismatched = inputs.filter((i) => i.scriptMismatch); + if (mismatched.length > 0) { + blockers.push( + "The transaction's declared amount or script for one of your inputs doesn't match what your wallet has on record. Refusing to sign." + ); + } + + const allFinalized = analysis.inputs.every((i) => i.finalized); + const signableCount = analysis.inputs.filter( + (i, idx) => i.mine && !i.finalized && !i.hasPartialSig && !inputs[idx].scriptMismatch + ).length; + if (signableCount === 0 && !allFinalized) { + blockers.push("None of this transaction's inputs belong to your wallet."); + } + + return { psbt: backfilledPsbt, analysis, inputs, blockers, signableCount }; +} + +export type PsbtSignOutcome = { + /** Base64 PSBT — present unless a broadcast succeeded. */ + psbt?: string; + /** Present once a broadcast has been accepted by the network. */ + txid?: string; + complete: boolean; + /** Set when `broadcast` was requested, the tx was complete, but the + * broadcast itself failed — the signed PSBT is still returned so nothing + * is lost. */ + broadcastError?: string; +}; + +/** + * Sign the wallet's own inputs, finalize what can be finalized, and either + * hand back the (possibly still partial) PSBT or — only when `broadcast` is + * requested and every input ends up signed — extract and broadcast, + * returning a txid instead. + * + * Throws `PsbtError` for policy violations (token-bearing input, disallowed + * sighash, etc.) — the caller is expected to catch and display it; nothing + * is signed or sent in that case. + */ +export async function signAndMaybeBroadcast( + psbt: Psbt, + wif: string, + opts: { broadcast: boolean } +): Promise { + const { psbt: signed } = signPsbt(psbt, wif, { + allowedSighashes: DEFAULT_ALLOWED_SIGHASHES, + }); + const { psbt: finalized, complete } = finalizePsbt(signed); + + if (!opts.broadcast || !complete) { + return { psbt: psbtToBase64(finalized), complete }; + } + + try { + const hex = extractTx(finalized); + const txid = (await electrumWorker.value.broadcast(hex)) || undefined; + if (!txid) { + // Server accepted a tx it already had (returns "") — the PSBT's own + // txid is still the right answer to hand back. + return { psbt: psbtToBase64(finalized), complete: true }; + } + return { txid, complete: true }; + } catch (err) { + return { + psbt: psbtToBase64(finalized), + complete: true, + broadcastError: err instanceof Error ? err.message : String(err), + }; + } +} + +export { PsbtError }; diff --git a/packages/app/src/connect/swapFlow.ts b/packages/app/src/connect/swapFlow.ts new file mode 100644 index 0000000..3df2847 --- /dev/null +++ b/packages/app/src/connect/swapFlow.ts @@ -0,0 +1,508 @@ +/** + * Non-React glue between the connect swap requests (`swap-offer-request`, + * `swap-accept-request`) and the wallet's own state. Mirrors + * `packages/app/src/pages/Swap.tsx` (maker: reserve + PSRT) and + * `packages/app/src/pages/SwapLoad.tsx` (taker: complete + broadcast) as + * closely as possible, so a connect-driven swap behaves identically to one + * the user made by hand through those pages — same reservation move, same + * PSRT construction, same output ordering, same royalty handling. + * + * v1 scope: NFT-for-RXD only (matches the private-offer marketplace use + * case this was built for). Reservation ("offer") always broadcasts a real + * transaction moving the NFT to the swap subaccount — `mode: "private"` + * only means no on-chain *advertisement*, not that nothing is broadcast. + */ +import rjs from "@radiant-core/radiantjs"; +import Big from "big.js"; +import { bytesToHex } from "@noble/hashes/utils"; +import db from "@app/db"; +import { electrumWorker } from "@app/electrum/Electrum"; +import { feeRate as feeRateSignal } from "@app/signals"; +import { broadcastSwapCompletion } from "@app/swapActivity"; +import { cancelSwap } from "@app/swap"; +import { updateRxdBalances, updateWalletUtxos } from "@app/utxos"; +import opfs from "@app/opfs"; +import { ContractType, SmartToken, SmartTokenType, SwapMode, SwapStatus } from "@app/types"; +import Outpoint, { reverseRef } from "@lib/Outpoint"; +import { + nftScript, + p2pkhScript, + parseFtScript, + parseNftScript, + parseP2pkhScript, +} from "@lib/script"; +import { fundTx, SelectableInput } from "@lib/coinSelect"; +import { findTokenOutput, buildTx } from "@lib/tx"; +import { partiallySigned, transferNonFungible } from "@lib/transfer"; +import { buildSwapCompletionOutputs } from "@lib/swapOutputs"; +import { + buildRoyaltyOutputs, + parseRoyalty, + RoyaltyTerms, +} from "@lib/royaltyTerms"; +import { decodeGlyph } from "@lib/token"; +import type { UnfinalizedOutput, Utxo } from "@lib/types"; +import type { + SwapAcceptRequest, + SwapCancelRequest, + SwapOfferRequest, +} from "@app/connect/protocol"; + +const { Transaction } = rjs; + +export class SwapFlowError extends Error { + constructor(message: string) { + super(message); + this.name = "SwapFlowError"; + } +} + +// Decimal-safe RXD -> photons conversion (mirrors Swap.tsx's rxdToPhotons): +// plain `rxd * 1e8` on a JS float can yield a non-integer photon value. +function rxdToPhotons(rxd: number): number { + return Number(Big(rxd).times(100000000).round(0, 0).toString()); +} + +// Inverse of the above, for echoing back the amount actually committed +// on-chain (photons are always the source of truth) rather than a caller's +// raw request value, which could carry more decimal precision than a whole +// photon count allows. +function photonsToRxd(photons: number): number { + return Number(Big(photons).div(100000000).toString()); +} + +function parseScript(script: string) { + return ( + ( + [ + [ContractType.RXD, parseP2pkhScript], + [ContractType.FT, parseFtScript], + [ContractType.NFT, parseNftScript], + ] as [ContractType, (script: string) => { address: string }][] + ).reduce<[ContractType, { address: string; ref?: string }] | undefined>( + (acc, [contractType, fn]) => { + if (acc) return acc; + const parsed = fn(script); + return parsed.address ? [contractType, parsed] : undefined; + }, + undefined + ) || [undefined, undefined] + ); +} + +async function fetchToken(ref: string): Promise { + const result = await db.glyph.where({ ref }).first(); + if (result) return result; + return electrumWorker.value.fetchGlyph(ref); +} + +async function getTokenRoyalty(glyph: SmartToken): Promise { + if (!glyph.revealOutpoint) return null; + try { + const reveal = Outpoint.fromString(glyph.revealOutpoint); + const txid = reveal.getTxid(); + let hex = await opfs.getTx(txid); + if (!hex) { + hex = await electrumWorker.value.getTransaction(txid); + if (hex) await opfs.putTx(txid, hex); + } + if (!hex) return null; + const tx = new Transaction(hex); + const input = tx.inputs[reveal.getVout()]; + if (!input?.script) return null; + const decoded = decodeGlyph(input.script); + if (!decoded) return null; + return parseRoyalty(decoded.payload); + } catch { + return null; + } +} + +export type SwapOfferOutcome = { + psrt: string; + reserveTxid: string; + reserveVout: number; + swapAddress: string; + ref: string; + payoutAddress: string; + priceRxd: number; +}; + +/** + * Reserve `req.ref` (an NFT owned by the wallet) into the swap subaccount + * and build a private-mode PSRT offering it for `req.priceRxd`. The + * reservation is a REAL on-chain transaction, broadcast immediately — + * `mode: "private"` only means no advertisement is published, not that + * nothing moves. + */ +export async function createSwapOffer( + req: SwapOfferRequest, + wif: string, + swapWif: string, + address: string, + swapAddress: string +): Promise { + if (req.mode !== "private") { + throw new SwapFlowError('only "private" mode is supported'); + } + + const refLE = reverseRef(req.ref); + const glyph = await db.glyph.where({ ref: req.ref }).first(); + if (!glyph) { + throw new SwapFlowError("this token was not found in your wallet"); + } + if (glyph.tokenType !== SmartTokenType.NFT) { + throw new SwapFlowError("only NFT offers are supported"); + } + if (glyph.swapPending) { + throw new SwapFlowError("this token already has a pending swap offer"); + } + + try { + await electrumWorker.value.manualSync(); + } catch (error) { + console.debug("[swapFlow] pre-offer UTXO refresh failed", error); + } + const coins: SelectableInput[] = await db.txo + .where({ contractType: ContractType.RXD, spent: 0 }) + .toArray(); + + const fromScript = nftScript(address, refLE); + const nft = await db.txo.where({ script: fromScript, spent: 0 }).first(); + if (!nft) { + throw new SwapFlowError("could not find the token's on-chain UTXO"); + } + + const { tx, selected } = transferNonFungible( + coins, + nft, + refLE, + address, + swapAddress, + feeRateSignal.value, + wif + ); + + const reserveTxid = await electrumWorker.value.broadcast(tx.toString()); + + // The reservation is now irreversible and already on-chain. Everything + // below this point — activity logging, local UTXO/balance bookkeeping — + // is best-effort: none of it is needed to hand the dApp a valid offer, so + // a failure here shouldn't surface as a request error (the connect error + // callback would tell the dApp the whole request failed, when in fact the + // NFT is already reserved). Worst case the wallet's own "Pending Swaps" + // view is stale until the next on-chain discovery sweep (`recoverSwaps`). + try { + await db.broadcast.put({ + txid: reserveTxid, + date: Date.now(), + description: "nft_swap_prepare", + }); + + const changeScript = p2pkhScript(address); + await updateWalletUtxos( + ContractType.NFT, + fromScript, + changeScript, + reserveTxid, + selected.inputs, + selected.outputs + ); + if (glyph.id) { + await db.glyph.update(glyph.id, { swapPending: true }); + } + await updateRxdBalances(address); + } catch (error) { + console.error( + "[swapFlow] post-reservation bookkeeping failed (offer already reserved on-chain)", + error + ); + } + + const priceRxdPhotons = rxdToPhotons(req.priceRxd); + const psrtOutput = { script: p2pkhScript(address), value: priceRxdPhotons }; + + const found = findTokenOutput(tx, refLE); + if (found.vout === undefined || !found.output) { + // Should never happen — `tx` is the exact transaction just broadcast — + // but if it does, the reservation is already irreversible; mention the + // txid so the offer can still be recovered/cancelled manually. + throw new SwapFlowError( + `reservation broadcast as ${reserveTxid}, but could not locate its swap output to build the offer` + ); + } + + const input = { + txid: tx.id, + vout: found.vout, + script: found.output.script.toHex(), + value: found.output.satoshis, + }; + const rawPsrt = partiallySigned(swapAddress, input, psrtOutput, swapWif).toString(); + + // Persist the offer locally, exactly like the local Swap page does + // (packages/app/src/pages/Swap.tsx) — without this row the offer is + // invisible in Pending Swaps (SwapPending.tsx reads `db.swap`) and + // uncancellable through the normal UI until the next on-chain discovery + // sweep (`recoverSwaps`) resynthesizes a degraded stub that's lost the + // negotiated price. Writing it now means the wallet's own "Cancel" button + // works immediately, with the real price, the moment the offer is created. + // Best-effort like the bookkeeping above: the PSRT is already valid and + // about to be returned to the caller regardless of whether this write + // succeeds. + try { + await db.swap.put({ + txid: tx.id, + vout: found.vout, + swapAddress, + tx: rawPsrt, + from: ContractType.NFT, + fromGlyph: req.ref, + fromValue: found.output.satoshis, + to: ContractType.RXD, + toGlyph: null, + toValue: priceRxdPhotons, + status: SwapStatus.PENDING, + date: Date.now(), + mode: SwapMode.PRIVATE, + }); + } catch (error) { + console.error( + "[swapFlow] failed to persist db.swap row for a successful offer (still returning it to the caller)", + error + ); + } + + return { + psrt: rawPsrt, + reserveTxid: tx.id, + reserveVout: found.vout, + swapAddress, + ref: req.ref, + payoutAddress: address, + // Echo the amount actually committed in the PSRT output, not the raw + // request value — see `photonsToRxd`. + priceRxd: photonsToRxd(priceRxdPhotons), + }; +} + +export type SwapAcceptPreview = { + priceRxd: number; + /** Undefined when the offered token couldn't be resolved (network hiccup, + * unindexed, or not actually an NFT offer) — the approval screen falls + * back to showing price-only in that case. */ + glyph?: SmartToken; +}; + +/** + * Resolve what a `swap-accept-request`'s PSRT is actually buying, for the + * approval screen — read-only, no signing, no broadcast, no wallet UTXOs + * touched. A PSRT only carries a price by itself (a single RXD output); to + * show the actual item being purchased this looks up the PSRT's single + * input's prevout (the reserved swap-subaccount NFT output) the same way + * `acceptSwapOffer` does before it starts moving funds, so what the user is + * shown matches what they're about to approve. Best-effort: any failure + * (malformed PSRT, prevout no longer resolvable, network hiccup) degrades to + * price-only rather than blocking the approval screen — `acceptSwapOffer` + * re-validates everything for real when the user actually approves. + */ +export async function previewSwapAccept( + req: Pick +): Promise { + let psrtTx: InstanceType; + try { + psrtTx = new Transaction(req.psrt); + } catch { + return null; + } + if (psrtTx.inputs.length !== 1 || psrtTx.outputs.length !== 1) return null; + + const priceRxd = photonsToRxd(psrtTx.outputs[0].satoshis); + + const txid = bytesToHex(psrtTx.inputs[0].prevTxId); + const vout = psrtTx.inputs[0].outputIndex; + try { + const hex = await electrumWorker.value.getTransaction(txid); + if (!hex) { + console.debug( + `[swapFlow] previewSwapAccept: no transaction found for reserved outpoint ${txid}:${vout}` + ); + return { priceRxd }; + } + const prevOutput = new Transaction(hex).outputs[vout]; + if (!prevOutput) { + console.debug( + `[swapFlow] previewSwapAccept: outpoint ${txid}:${vout} has no output at that index` + ); + return { priceRxd }; + } + const [from, fromParams] = parseScript(prevOutput.script.toHex()); + if (from !== ContractType.NFT) { + console.debug( + `[swapFlow] previewSwapAccept: reserved output ${txid}:${vout} is not an NFT script (got ${String( + from + )})` + ); + return { priceRxd }; + } + const ref = reverseRef(fromParams?.ref as string); + const glyph = await fetchToken(ref); + if (!glyph) { + console.debug( + `[swapFlow] previewSwapAccept: could not resolve glyph metadata for ref ${ref}` + ); + } + return { priceRxd, glyph }; + } catch (error) { + console.debug( + `[swapFlow] previewSwapAccept: failed to resolve reserved outpoint ${txid}:${vout}`, + error + ); + return { priceRxd }; + } +} + +export type SwapAcceptOutcome = { txid: string }; + +/** + * Complete and broadcast a maker's NFT-for-RXD PSRT, optionally appending a + * marketplace fee output alongside any enforced creator royalty. Always + * broadcasts — there is no "return unsigned" option, matching the + * mint-request flow. + */ +export async function acceptSwapOffer( + req: SwapAcceptRequest, + wif: string, + address: string +): Promise { + const psrtTx = new Transaction(req.psrt); + if (psrtTx.inputs.length !== 1 || psrtTx.outputs.length !== 1) { + throw new SwapFlowError("psrt must have exactly one input and one output"); + } + + const txid = bytesToHex(psrtTx.inputs[0].prevTxId); + const vout = psrtTx.inputs[0].outputIndex; + const hex = await electrumWorker.value.getTransaction(txid); + if (!hex) { + throw new SwapFlowError("could not fetch the offer's reserved transaction"); + } + const prevTx = new Transaction(hex); + const prevOutput = prevTx.outputs[vout]; + if (!prevOutput) { + throw new SwapFlowError("invalid offer: reserved output not found"); + } + + const isUnspent = await electrumWorker.value.isUtxoUnspent( + txid, + vout, + prevOutput.script.toHex() + ); + if (!isUnspent) { + throw new SwapFlowError("this offer has already been completed or cancelled"); + } + + const [from, fromParams] = parseScript(prevOutput.script.toHex()); + if (from !== ContractType.NFT) { + throw new SwapFlowError("only NFT offers are supported"); + } + const refLE = fromParams?.ref as string; + const fromGlyph = await fetchToken(reverseRef(refLE)); + if (!fromGlyph) { + throw new SwapFlowError("could not resolve the offered token's metadata"); + } + + const toScriptHex = psrtTx.outputs[0].script.toHex(); + const [to] = parseScript(toScriptHex); + if (to !== ContractType.RXD) { + throw new SwapFlowError("only RXD-priced offers are supported"); + } + + const fromValue = prevOutput.satoshis; + const toValue = psrtTx.outputs[0].satoshis; + const outputScript = nftScript(address, refLE); + + const makerPayment: UnfinalizedOutput = { script: toScriptHex, value: toValue }; + const assetToTaker: UnfinalizedOutput = { script: outputScript, value: fromValue }; + + const royaltyOutputs: UnfinalizedOutput[] = []; + const royalty = await getTokenRoyalty(fromGlyph); + if (royalty?.enforced) { + royaltyOutputs.push(...buildRoyaltyOutputs(royalty, toValue)); + } + + const platformFeeOutputs: UnfinalizedOutput[] = []; + if (req.feeRxd && req.feeAddress) { + platformFeeOutputs.push({ + script: p2pkhScript(req.feeAddress), + value: rxdToPhotons(req.feeRxd), + }); + } + + const outputs = buildSwapCompletionOutputs({ + makerPayment, + assetToTaker, + royaltyOutputs, + platformFeeOutputs, + }); + + const coins: SelectableInput[] = await db.txo + .where({ contractType: ContractType.RXD, spent: 0 }) + .toArray(); + const inputs: Utxo[] = [ + { txid, vout, script: prevOutput.script.toHex(), value: fromValue }, + ]; + + const changeScript = p2pkhScript(address); + const fund = fundTx(address, coins, inputs, outputs, changeScript, feeRateSignal.value); + if (!fund.funded) { + throw new SwapFlowError("failed to fund the completion transaction"); + } + inputs.push(...fund.funding); + outputs.push(...fund.change); + + const tx = buildTx(address, wif, inputs, outputs, false, (index, script) => { + if (index === 0) return psrtTx.inputs[0].script; + return script; + }); + + const txidResult = await broadcastSwapCompletion(tx.toString()); + return { txid: txidResult }; +} + +export type SwapCancelOutcome = { txid: string }; + +/** + * Cancel a pending offer identified by `req.ref`, reclaiming the reserved + * NFT back to the wallet's main address. Looks the offer up in `db.swap` + * (populated by `createSwapOffer`) rather than requiring the caller to know + * the reservation outpoint — the wallet already tracks one pending swap per + * glyph, so the ref alone is enough to identify which offer to cancel. + * Requires the wallet to be unlocked (`cancelSwap` reads the signing keys + * from wallet state directly, matching how the local Swap page's own Cancel + * button works). + */ +export async function cancelSwapOffer( + req: SwapCancelRequest +): Promise { + // `fromGlyph` isn't an indexed field (db.ts's `swap` schema only indexes + // `status`/`txid`/`mode`), so filter in JS rather than `.where()` on it. + const pending = await db.swap.where({ status: SwapStatus.PENDING }).toArray(); + const swap = pending.find((s) => s.fromGlyph === req.ref); + if (!swap) { + throw new SwapFlowError("could not find a pending offer for that token"); + } + + const txid = await cancelSwap( + swap.from, + swap.txid, + swap.fromValue, + swap.fromGlyph ?? undefined, + swap.vout ?? 0, + swap.swapAddress + ); + if (swap.id !== undefined) { + await db.swap.update(swap.id, { status: SwapStatus.CANCEL }); + } + + return { txid }; +} diff --git a/packages/app/src/opfs.ts b/packages/app/src/opfs.ts index 82b743b..5532432 100644 --- a/packages/app/src/opfs.ts +++ b/packages/app/src/opfs.ts @@ -20,7 +20,6 @@ export async function getTx(txid: string): Promise { try { const fileHandle = await dir.getFileHandle(txid); const buf = await (await fileHandle.getFile()).arrayBuffer(); - console.debug(`OPFS get ${txid}`); return bytesToHex(new Uint8Array(buf)); } catch { return undefined; diff --git a/packages/app/src/pages/Connect.tsx b/packages/app/src/pages/Connect.tsx index 3189cf2..e4792cf 100644 --- a/packages/app/src/pages/Connect.tsx +++ b/packages/app/src/pages/Connect.tsx @@ -1,23 +1,33 @@ /** - * "Connect & sign" — Phase A of external-wallet connect - * (GlyphGalaxy `docs/WALLET_CONNECT_SCOPE.md`). + * "Connect" — external-wallet connect for dApps. * - * Lets a dApp obtain a signed proof of address ownership over an out-of-band - * transport (QR / paste / deep-link `#/connect?req=...`) WITHOUT the user ever - * exposing their seed. This page is the human approval gate: it renders the - * requesting origin + the verbatim challenge, signs only on explicit approval - * (unlocking first if needed), and signs ONLY a magic-prefixed message via - * `@lib/sign` — never a transaction. Nothing is persisted; no key leaves the - * wallet's transient `withWif` frame. + * Two request types share this page (see `@app/connect/protocol`): + * + * - `sign-request` (Phase A, GlyphGalaxy `docs/WALLET_CONNECT_SCOPE.md`): a + * dApp obtains a signed proof of address ownership WITHOUT the user ever + * exposing their seed. Signs ONLY a magic-prefixed message via `@lib/sign` + * — never a transaction. + * - `psbt-sign-request` (`docs/psbt.md`): a dApp hands over a Radiant PSBT. + * The approval screen (`PsbtRequestPanel`) shows every input and output + * before anything is signed; the wallet signs only its own plain P2PKH + * inputs (`@app/connect/psbtFlow`) and either returns the (possibly still + * partial) signed PSBT or — only if the request opted in with + * `broadcast: true` and every input ends up signed — broadcasts and + * returns a txid. + * + * Both arrive over the same out-of-band transport (QR / paste / deep-link + * `#/connect?req=...`). This page is the human approval gate: nothing is + * signed until explicit approval (unlocking first if needed), and no key + * ever leaves the wallet's transient `withWif` frame. * * A deep-linked request may opt in to an automatic return by carrying a * `callback` URL, in which case approval navigates this tab back to the * requesting site with the result in the fragment (`canAutoReturn` below, - * `buildCallbackUrl` in `@app/connect/protocol`). Everything else is unchanged: - * a request without one — or one whose callback failed origin-binding — returns - * the signature by copy/paste or QR exactly as before. + * `buildCallbackUrl` / `buildPsbtCallbackUrl` in `@app/connect/protocol`). + * Everything else is unchanged: a request without one — or one whose + * callback failed origin-binding — returns the result by copy/paste or QR. */ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Alert, @@ -33,6 +43,7 @@ import { Flex, HStack, Heading, + Spinner, Stack, Text, Textarea, @@ -50,6 +61,16 @@ import { import { QRCodeSVG } from "qrcode.react"; import { Scanner } from "@yudiel/react-qr-scanner"; import Card from "@app/components/Card"; +import PsbtRequestPanel from "@app/components/connect/PsbtRequestPanel"; +import PsbtResultPanel from "@app/components/connect/PsbtResultPanel"; +import MintRequestPanel from "@app/components/connect/MintRequestPanel"; +import MintResultPanel from "@app/components/connect/MintResultPanel"; +import SwapOfferRequestPanel from "@app/components/connect/SwapOfferRequestPanel"; +import SwapOfferResultPanel from "@app/components/connect/SwapOfferResultPanel"; +import SwapAcceptRequestPanel from "@app/components/connect/SwapAcceptRequestPanel"; +import SwapAcceptResultPanel from "@app/components/connect/SwapAcceptResultPanel"; +import SwapCancelRequestPanel from "@app/components/connect/SwapCancelRequestPanel"; +import SwapCancelResultPanel from "@app/components/connect/SwapCancelResultPanel"; import { openModal, wallet } from "@app/signals"; import { readText, @@ -57,17 +78,45 @@ import { scanQrFromPhoto, isNativePlatform, } from "@app/platform"; -import { withWif } from "@app/wallet"; +import { withSwapWif, withWif } from "@app/wallet"; import { signMessageWithWif } from "@lib/sign"; +import { PsbtError, psbtFromBase64, type Psbt } from "@lib/psbt"; import { buildCallbackUrl, + buildErrorCallbackUrl, + buildMintCallbackUrl, + buildMintResult, + buildPsbtCallbackUrl, + buildPsbtResult, + buildRejectCallbackUrl, buildSignResult, + buildSwapAcceptCallbackUrl, + buildSwapAcceptResult, + buildSwapCancelCallbackUrl, + buildSwapCancelResult, + buildSwapOfferCallbackUrl, + buildSwapOfferResult, + classifyConnectError, encodeSignResult, isRecognizedConnectChallenge, - parseSignRequest, + parseConnectRequest, + type ConnectErrorCode, + type MintRequest, + type MintResult, + type PsbtSignRequest, + type PsbtSignResult, type SignRequest, type SignResult, + type SwapAcceptRequest, + type SwapAcceptResult, + type SwapCancelRequest, + type SwapCancelResult, + type SwapOfferRequest, + type SwapOfferResult, } from "@app/connect/protocol"; +import { enrichPsbt, signAndMaybeBroadcast, type EnrichedPsbt } from "@app/connect/psbtFlow"; +import { mintFromRequest } from "@app/connect/mintFlow"; +import { acceptSwapOffer, cancelSwapOffer, createSwapOffer } from "@app/connect/swapFlow"; /** * Whether this page may hand a signed result straight back to the request's @@ -91,9 +140,30 @@ export default function Connect() { const [rawInput, setRawInput] = useState(""); const [scanning, setScanning] = useState(false); const [result, setResult] = useState(null); + const [psbtResult, setPsbtResult] = useState(null); + const [enriched, setEnriched] = useState(null); + const [psbtBusy, setPsbtBusy] = useState(false); + const [mintResult, setMintResult] = useState(null); + const [mintBusy, setMintBusy] = useState(false); + const [swapOfferResult, setSwapOfferResult] = useState(null); + const [swapOfferBusy, setSwapOfferBusy] = useState(false); + const [swapAcceptResult, setSwapAcceptResult] = useState(null); + const [swapAcceptBusy, setSwapAcceptBusy] = useState(false); + const [swapCancelResult, setSwapCancelResult] = useState(null); + const [swapCancelBusy, setSwapCancelBusy] = useState(false); const [fromDeepLink, setFromDeepLink] = useState(false); const toast = useToast(); + // Synchronous re-entrancy guard for the approve action. Only one of + // sign/psbtSign/mintSign/swapOfferSign/swapAcceptSign may run at a time — + // this page only ever has one pending request. A `ref` (not the *Busy + // state, which only takes effect on the next render) is what makes the + // guard actually synchronous: if the unlock modal's onClose ever fires + // twice (see Unlock.tsx — its onCloseCallback isn't cleared after use) or + // a click handler double-fires, the second call is a no-op instead of a + // second real broadcast. + const approveInFlightRef = useRef(false); + // Deep-link entry: `#/connect?req=` (or ?challenge=). useEffect(() => { const req = searchParams.get("req") || searchParams.get("challenge"); @@ -104,19 +174,70 @@ export default function Connect() { }, []); const parsed = useMemo( - () => (rawInput.trim() ? parseSignRequest(rawInput) : null), + () => (rawInput.trim() ? parseConnectRequest(rawInput) : null), [rawInput] ); const request = parsed?.ok ? parsed.request : null; + // The protocol layer only validates that a psbt-sign-request's `psbt` + // field is base64-shaped; parsing it into a structured PSBT (and + // rejecting a malformed one) is `@lib/psbt`'s job, done here so a bad PSBT + // shows a clear error instead of silently falling through. + const psbtParse = useMemo(() => { + if (!request || request.t !== "psbt-sign-request") return null; + try { + return { ok: true as const, psbt: psbtFromBase64(request.psbt) }; + } catch (err) { + return { + ok: false as const, + error: err instanceof PsbtError ? err.message : String(err), + }; + } + }, [request]); + + // Enrichment (db ownership/spent checks, best-effort external prevout + // lookup) is async, so it runs once the PSBT parses and is cached until + // the request changes. + useEffect(() => { + setEnriched(null); + if (!psbtParse?.ok) return; + let cancelled = false; + enrichPsbt(psbtParse.psbt).then((e) => { + if (!cancelled) setEnriched(e); + }); + return () => { + cancelled = true; + }; + }, [psbtParse]); + const signerAddress = wallet.value.address; const locked = wallet.value.locked; + // Fires the generic error callback (distinct from both success and + // explicit reject) for a genuine wallet-side failure — locked, not found, + // insufficient funds, already spent, etc. Without this a deep-linked + // caller waiting on any of the sign handlers below has no way to tell + // "still working" from "failed" and is left hanging until its own + // timeout; the in-app toast alone only helps a human watching the screen. + const fireConnectError = useCallback( + ( + req: { callback?: string; id?: string }, + code: ConnectErrorCode, + message: string + ) => { + if (!canAutoReturn(fromDeepLink)) return; + const url = buildErrorCallbackUrl(req, { code, message }); + if (url) window.location.assign(url); + }, + [fromDeepLink] + ); + const sign = useCallback( (req: SignRequest) => { const signed = withWif((wif) => signMessageWithWif(req.challenge, wif)); if (!signed) { toast({ status: "error", title: "Wallet is locked — unable to sign" }); + fireConnectError(req, "locked", "Wallet is locked — unable to sign"); return; } const signResult = buildSignResult(req, signed); @@ -129,26 +250,284 @@ export default function Connect() { : undefined; if (callbackUrl) window.location.assign(callbackUrl); }, - [toast, fromDeepLink] + [toast, fromDeepLink, fireConnectError] + ); + + const psbtSign = useCallback( + async (req: PsbtSignRequest, psbt: Psbt) => { + setPsbtBusy(true); + try { + const outcomePromise = withWif((wif) => + signAndMaybeBroadcast(psbt, wif, { broadcast: req.broadcast === true }) + ); + if (!outcomePromise) { + toast({ + status: "error", + title: "Wallet is locked — unable to sign", + }); + fireConnectError(req, "locked", "Wallet is locked — unable to sign"); + return; + } + const outcome = await outcomePromise; + const signResult = buildPsbtResult(req, outcome); + // As with the sign-request flow, always render the manual-return + // panel first — it is the fallback if auto-return doesn't fire. + setPsbtResult(signResult); + if (outcome.broadcastError) { + toast({ + status: "warning", + title: "Broadcast failed", + description: outcome.broadcastError, + }); + } + + const callbackUrl = canAutoReturn(fromDeepLink) + ? buildPsbtCallbackUrl(req, signResult) + : undefined; + if (callbackUrl) window.location.assign(callbackUrl); + } catch (err) { + toast({ + status: "error", + title: "Unable to sign", + description: err instanceof Error ? err.message : String(err), + }); + const { code, message } = classifyConnectError(err); + fireConnectError(req, code, message); + } finally { + setPsbtBusy(false); + } + }, + [toast, fromDeepLink, fireConnectError] + ); + + const mintSign = useCallback( + async (req: MintRequest) => { + setMintBusy(true); + try { + const outcomePromise = withWif((wif) => + mintFromRequest(req, wif, wallet.value.address) + ); + if (!outcomePromise) { + toast({ + status: "error", + title: "Wallet is locked — unable to mint", + }); + fireConnectError(req, "locked", "Wallet is locked — unable to mint"); + return; + } + const outcome = await outcomePromise; + const mintResultValue = buildMintResult(req, outcome); + setMintResult(mintResultValue); + + // A dry run (broadcast:false) exists specifically to let the caller + // inspect the built hex before anything real happens — auto- + // returning immediately would defeat that. Only navigate away when + // something actually got sent. + const callbackUrl = + outcome.broadcast && canAutoReturn(fromDeepLink) + ? buildMintCallbackUrl(req, mintResultValue) + : undefined; + if (callbackUrl) window.location.assign(callbackUrl); + } catch (err) { + toast({ + status: "error", + title: "Unable to mint", + description: err instanceof Error ? err.message : String(err), + }); + const { code, message } = classifyConnectError(err); + fireConnectError(req, code, message); + } finally { + setMintBusy(false); + } + }, + [toast, fromDeepLink, fireConnectError] + ); + + const swapOfferSign = useCallback( + async (req: SwapOfferRequest) => { + setSwapOfferBusy(true); + try { + const address = wallet.value.address; + const swapAddress = wallet.value.swapAddress; + const outcomePromise = withWif((wif) => + withSwapWif((swapWif) => + createSwapOffer(req, wif, swapWif, address, swapAddress) + ) + ); + if (!outcomePromise) { + toast({ + status: "error", + title: "Wallet is locked — unable to list", + }); + fireConnectError(req, "locked", "Wallet is locked — unable to list"); + return; + } + const outcome = await outcomePromise; + const offerResult = buildSwapOfferResult(req, outcome); + setSwapOfferResult(offerResult); + + const callbackUrl = canAutoReturn(fromDeepLink) + ? buildSwapOfferCallbackUrl(req, offerResult) + : undefined; + if (callbackUrl) window.location.assign(callbackUrl); + } catch (err) { + toast({ + status: "error", + title: "Unable to list", + description: err instanceof Error ? err.message : String(err), + }); + const { code, message } = classifyConnectError(err); + fireConnectError(req, code, message); + } finally { + setSwapOfferBusy(false); + } + }, + [toast, fromDeepLink, fireConnectError] + ); + + const swapAcceptSign = useCallback( + async (req: SwapAcceptRequest) => { + setSwapAcceptBusy(true); + try { + const outcomePromise = withWif((wif) => + acceptSwapOffer(req, wif, wallet.value.address) + ); + if (!outcomePromise) { + toast({ + status: "error", + title: "Wallet is locked — unable to complete purchase", + }); + fireConnectError( + req, + "locked", + "Wallet is locked — unable to complete purchase" + ); + return; + } + const outcome = await outcomePromise; + const acceptResult = buildSwapAcceptResult(req, outcome); + setSwapAcceptResult(acceptResult); + + const callbackUrl = canAutoReturn(fromDeepLink) + ? buildSwapAcceptCallbackUrl(req, acceptResult) + : undefined; + if (callbackUrl) window.location.assign(callbackUrl); + } catch (err) { + toast({ + status: "error", + title: "Unable to complete purchase", + description: err instanceof Error ? err.message : String(err), + }); + const { code, message } = classifyConnectError(err); + fireConnectError(req, code, message); + } finally { + setSwapAcceptBusy(false); + } + }, + [toast, fromDeepLink, fireConnectError] + ); + + const swapCancelSign = useCallback( + async (req: SwapCancelRequest) => { + setSwapCancelBusy(true); + try { + if (wallet.value.locked) { + toast({ + status: "error", + title: "Wallet is locked — unable to cancel", + }); + fireConnectError(req, "locked", "Wallet is locked — unable to cancel"); + return; + } + // cancelSwapOffer (via @app/swap's cancelSwap) reads the signing + // keys from wallet state directly — no withWif frame needed, same + // convention the local Swap page's own Cancel button uses. + const outcome = await cancelSwapOffer(req); + const cancelResult = buildSwapCancelResult(req, outcome); + setSwapCancelResult(cancelResult); + + const callbackUrl = canAutoReturn(fromDeepLink) + ? buildSwapCancelCallbackUrl(req, cancelResult) + : undefined; + if (callbackUrl) window.location.assign(callbackUrl); + } catch (err) { + toast({ + status: "error", + title: "Unable to cancel", + description: err instanceof Error ? err.message : String(err), + }); + const { code, message } = classifyConnectError(err); + fireConnectError(req, code, message); + } finally { + setSwapCancelBusy(false); + } + }, + [toast, fromDeepLink, fireConnectError] ); const onApprove = useCallback(() => { if (!request) return; + const doSign = () => { + // Synchronous re-entrancy guard: if this fires twice (e.g. the unlock + // modal's onClose firing again — Unlock.tsx's onCloseCallback isn't + // cleared after use — or a double click), the second call is a no-op + // instead of a second real broadcast. + if (approveInFlightRef.current) return; + approveInFlightRef.current = true; + const release = () => { + approveInFlightRef.current = false; + }; + + if (request.t === "psbt-sign-request") { + if (psbtParse?.ok) void psbtSign(request, psbtParse.psbt).finally(release); + else release(); + } else if (request.t === "mint-request") { + void mintSign(request).finally(release); + } else if (request.t === "swap-offer-request") { + void swapOfferSign(request).finally(release); + } else if (request.t === "swap-accept-request") { + void swapAcceptSign(request).finally(release); + } else if (request.t === "swap-cancel-request") { + void swapCancelSign(request).finally(release); + } else { + try { + sign(request); + } finally { + release(); + } + } + }; if (wallet.value.locked) { // Reuse the global unlock modal; sign in its success callback. openModal.value = { modal: "unlock", onClose: (ok: boolean) => { - if (ok) sign(request); + if (ok) doSign(); }, }; } else { - sign(request); + doSign(); } - }, [request, sign]); + }, [ + request, + sign, + psbtSign, + psbtParse, + mintSign, + swapOfferSign, + swapAcceptSign, + swapCancelSign, + ]); const reset = () => { + approveInFlightRef.current = false; setResult(null); + setPsbtResult(null); + setEnriched(null); + setMintResult(null); + setSwapOfferResult(null); + setSwapAcceptResult(null); + setSwapCancelResult(null); setRawInput(""); setScanning(false); // Whatever is entered next was typed/scanned by hand, not deep-linked, so @@ -156,18 +535,128 @@ export default function Connect() { setFromDeepLink(false); }; + const onReject = useCallback(() => { + // Tell the dApp explicitly, the same way an approval would — otherwise a + // deep-linked caller has no way to distinguish "still waiting" from "the + // user said no" and is left hanging with no signal at all. + if (request && canAutoReturn(fromDeepLink)) { + const url = buildRejectCallbackUrl(request); + if (url) { + window.location.assign(url); + return; + } + } + reset(); + }, [request, fromDeepLink]); + + const isPsbtRequest = request?.t === "psbt-sign-request"; + const isMintRequest = request?.t === "mint-request"; + const isSwapOfferRequest = request?.t === "swap-offer-request"; + const isSwapAcceptRequest = request?.t === "swap-accept-request"; + const isSwapCancelRequest = request?.t === "swap-cancel-request"; + return ( - Connect & sign + Connect - Prove you control this wallet to an app by signing its challenge. This - never spends funds and never reveals your seed. + {isPsbtRequest + ? "Review and approve a transaction an app is asking you to sign." + : isMintRequest + ? "Review and approve an NFT an app is asking you to mint." + : isSwapOfferRequest + ? "Review and approve listing an item for sale." + : isSwapAcceptRequest + ? "Review and approve completing a purchase." + : isSwapCancelRequest + ? "Review and approve cancelling a listing." + : "Prove you control this wallet to an app by signing its challenge. This never spends funds and never reveals your seed."} {result ? ( + ) : psbtResult ? ( + + ) : request?.t === "psbt-sign-request" ? ( + psbtParse?.ok ? ( + enriched ? ( + + ) : ( + + + + Loading transaction details… + + + ) + ) : ( + + + + + {psbtParse?.error ?? "This isn't a valid PSBT."} + + + + + ) + ) : mintResult ? ( + + ) : request?.t === "mint-request" ? ( + + ) : swapOfferResult ? ( + + ) : request?.t === "swap-offer-request" ? ( + + ) : swapAcceptResult ? ( + + ) : request?.t === "swap-accept-request" ? ( + + ) : swapCancelResult ? ( + + ) : request?.t === "swap-cancel-request" ? ( + ) : request ? ( ) : ( ` uri-path). Running + * more than one regtest file per vitest invocation needs + * `--no-file-parallelism`: radiantd allows only one `scantxoutset` at a time. + */ +import { it, expect } from "vitest"; +import rjs from "@radiant-core/radiantjs"; +import { extractTx, finalizePsbt, Psbt, psbtFromBase64, psbtToBase64, signPsbt } from "../psbt"; +import { p2pkhScript } from "../script"; +import { SelectableInput } from "../coinSelect"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// radiantjs ships incomplete typings for Networks.regtest / PrivateKey.fromRandom; +// cast as any for the regtest harness (runtime is correct — see test output). +const { PrivateKey, Networks, Transaction, Script } = rjs as any; + +const RPC_URL = process.env.REGTEST_RPC_URL || "http://127.0.0.1:17443/"; +const RPC_USER = process.env.REGTEST_RPC_USER || "radiantrpc"; +const RPC_PASS = + process.env.REGTEST_RPC_PASS || "613c41227c677d8bc90f5729f93604a7"; +const PHOTONS = 100_000_000; + +type Unspent = { txid: string; vout: number; scriptPubKey: string; amount: number }; + +let rpcId = 0; +async function rpc( + method: string, + params: unknown[] = [] +): Promise { + const res = await fetch(RPC_URL, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: + "Basic " + Buffer.from(`${RPC_USER}:${RPC_PASS}`).toString("base64"), + }, + body: JSON.stringify({ jsonrpc: "1.0", id: rpcId++, method, params }), + }); + const json = (await res.json()) as { result: T; error: unknown }; + if (json.error) + throw new Error(`RPC ${method}: ${JSON.stringify(json.error)}`); + return json.result; +} + +type Key = { wif: string; address: string }; +function newKey(): Key { + const pk = PrivateKey.fromRandom(Networks.regtest); + return { wif: pk.toWIF(), address: pk.toAddress(Networks.regtest).toString() }; +} + +let MINE_ADDR = ""; +const mine = (n = 1) => rpc("generatetoaddress", [n, MINE_ADDR]); +async function fund(address: string, rxd: number) { + await rpc("sendtoaddress", [address, rxd]); + await mine(1); +} +const broadcast = (hex: string) => rpc("sendrawtransaction", [hex]); + +async function scanUnspents(desc: string): Promise { + const r = await rpc<{ unspents: Unspent[] }>("scantxoutset", [ + "start", + [{ desc }], + ]); + return r.unspents || []; +} +async function rxdCoins(address: string): Promise { + return (await scanUnspents(`addr(${address})`)) + .map((u) => ({ + txid: u.txid, + vout: u.vout, + script: u.scriptPubKey as string, + value: Math.round(u.amount * PHOTONS), + })) + .filter((u) => u.value > 1); +} +async function addrBalance(address: string): Promise { + return (await scanUnspents(`addr(${address})`)).reduce( + (s, u) => s + Math.round(u.amount * PHOTONS), + 0 + ); +} + +/** Build an unsigned, legacy-serialized tx hex — the shape a dApp would hand + * the wallet, with every scriptSig empty. */ +function buildUnsignedTx( + inputs: { txid: string; vout: number }[], + outputs: { script: string; value: number }[] +): string { + const tx = new Transaction(); + for (const input of inputs) { + tx.addInput( + new Transaction.Input({ + prevTxId: input.txid, + outputIndex: input.vout, + script: new Script(), + output: new Transaction.Output({ script: new Script(), satoshis: 0 }), + }) + ); + } + for (const output of outputs) { + tx.addOutput(new Transaction.Output({ script: output.script, satoshis: output.value })); + } + return tx.toString(); +} + +it.skipIf(process.env.REGTEST_E2E !== "1")( + "single-signer: PSBT spend of A's coin to B, signed and broadcast by A alone", + async () => { + console.log("\n=== regtest height:", await rpc("getblockcount")); + MINE_ADDR = await rpc("getnewaddress"); + + const A = newKey(); + const B = newKey(); + await fund(A.address, 10); + + const coins = await rxdCoins(A.address); + expect(coins.length).toBeGreaterThan(0); + const coin = coins[0]; + const sendValue = Math.floor(coin.value * 0.5); + + const unsignedTxHex = buildUnsignedTx( + [{ txid: coin.txid, vout: coin.vout }], + [{ script: p2pkhScript(B.address), value: sendValue }] + ); + const psbt: Psbt = { + unsignedTxHex, + inputs: [ + { + utxo: { script: coin.script, value: BigInt(coin.value) }, + partialSigs: new Map(), + bip32: [], + unknown: [], + }, + ], + outputs: [{ entries: [] }], + unknownGlobals: [], + }; + + const { psbt: signed, signedIndexes } = signPsbt(psbt, A.wif); + expect(signedIndexes).toEqual([0]); + const { psbt: finalized, complete } = finalizePsbt(signed); + expect(complete).toBe(true); + + const hex = extractTx(finalized); + const txid = await broadcast(hex); + expect(txid).toMatch(/^[0-9a-f]{64}$/); + await mine(1); + + const balB = await addrBalance(B.address); + expect(balB).toBe(sendValue); + console.log("single-signer PSBT confirmed:", txid); + }, + 120_000 +); + +it.skipIf(process.env.REGTEST_E2E !== "1")( + "multi-party: two independently-signed inputs cross a base64 hop, no combiner needed", + async () => { + MINE_ADDR = await rpc("getnewaddress"); + + const alice = newKey(); + const bob = newKey(); + const dest = newKey(); + await fund(alice.address, 5); + await fund(bob.address, 5); + + const [aliceCoin] = await rxdCoins(alice.address); + const [bobCoin] = await rxdCoins(bob.address); + expect(aliceCoin).toBeTruthy(); + expect(bobCoin).toBeTruthy(); + + const totalIn = aliceCoin.value + bobCoin.value; + const fee = 2000; + const sendValue = totalIn - fee; + + const unsignedTxHex = buildUnsignedTx( + [ + { txid: aliceCoin.txid, vout: aliceCoin.vout }, + { txid: bobCoin.txid, vout: bobCoin.vout }, + ], + [{ script: p2pkhScript(dest.address), value: sendValue }] + ); + const psbt: Psbt = { + unsignedTxHex, + inputs: [ + { + utxo: { script: aliceCoin.script, value: BigInt(aliceCoin.value) }, + partialSigs: new Map(), + bip32: [], + unknown: [], + }, + { + utxo: { script: bobCoin.script, value: BigInt(bobCoin.value) }, + partialSigs: new Map(), + bip32: [], + unknown: [], + }, + ], + outputs: [{ entries: [] }], + unknownGlobals: [], + }; + + // Alice signs her input only; the half-signed PSBT is handed to Bob the + // way it would be between two wallets — as a base64 string. + const afterAlice = signPsbt(psbt, alice.wif); + expect(afterAlice.signedIndexes).toEqual([0]); + expect(finalizePsbt(afterAlice.psbt).complete).toBe(false); + + const rehydrated = psbtFromBase64(psbtToBase64(afterAlice.psbt)); + const afterBob = signPsbt(rehydrated, bob.wif); + expect(afterBob.signedIndexes).toEqual([1]); + + const { psbt: finalized, complete } = finalizePsbt(afterBob.psbt); + expect(complete).toBe(true); + + const hex = extractTx(finalized); + const txid = await broadcast(hex); + expect(txid).toMatch(/^[0-9a-f]{64}$/); + await mine(1); + + const balDest = await addrBalance(dest.address); + expect(balDest).toBe(sendValue); + console.log("multi-party PSBT confirmed:", txid); + }, + 120_000 +); diff --git a/packages/lib/src/__tests__/psbt.test.ts b/packages/lib/src/__tests__/psbt.test.ts new file mode 100644 index 0000000..43bd508 --- /dev/null +++ b/packages/lib/src/__tests__/psbt.test.ts @@ -0,0 +1,701 @@ +/** + * Unit tests for the Radiant PSBT module (`../psbt`). + * + * The wire format under test is the Radiant Core (Bitcoin ABC-lineage, + * segwit-stripped) BIP-174 profile: per-input key 0x00 holds a bare CTxOut, + * sighash defaults to ALL|FORKID (0x41), transport is standard base64. + * Round-trips must be byte-identical so PSBTs survive a + * Photonic ⇄ Radiant Core hop unchanged. + * + * radiantjs transaction signing is deterministic (RFC-6979-style k), so the + * sign→finalize→extract path is asserted BYTE-EQUAL against `buildTx` over + * identical inputs/outputs — the two signers must be indistinguishable. + */ +import { describe, expect, it } from "vitest"; +import rjs from "@radiant-core/radiantjs"; +import { Buffer } from "buffer"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; +import { + DEFAULT_SIGHASH, + MAX_PSBT_TX_SIZE, + Psbt, + analyzePsbt, + extractTx, + finalizePsbt, + parsePsbt, + psbtFromBase64, + psbtToBase64, + serializePsbt, + signPsbt, +} from "../psbt"; +import { ByteReader, ByteWriter } from "../psbt/keyvalue"; +import { buildTx } from "../tx"; +import { nftScript, p2pkhScript } from "../script"; +import { bnFromValue, transactionFromHex } from "../rjsCompat"; + +const { PrivateKey, Script, Transaction, crypto } = rjs; + +const TXID_A = "aa".repeat(32); +const TXID_B = "bb".repeat(32); +const REF = "cd".repeat(36); + +/** A fresh key + its P2PKH script, on mainnet like the wallet's keys. */ +function newKey() { + const key = new PrivateKey(); + const address = key.toAddress().toString(); + return { key, wif: key.toWIF(), address, script: p2pkhScript(address) }; +} + +/** Legacy-serialized unsigned tx (empty scriptSigs) over the given io. */ +function makeUnsignedTx( + inputs: { txid: string; vout: number }[], + outputs: { script: string; value: number }[] +): string { + const tx = new Transaction(); + for (const input of inputs) { + tx.addInput( + new Transaction.Input({ + prevTxId: input.txid, + outputIndex: input.vout, + script: new Script(), + output: new Transaction.Output({ script: new Script(), satoshis: 0 }), + }) + ); + } + for (const output of outputs) { + tx.addOutput( + new Transaction.Output({ script: output.script, satoshis: output.value }) + ); + } + return tx.toString(); +} + +function makePsbt( + unsignedTxHex: string, + inputs: Partial[], + nOutputs: number +): Psbt { + return { + unsignedTxHex, + inputs: inputs.map((partial) => ({ + partialSigs: new Map(), + bip32: [], + unknown: [], + ...partial, + })), + outputs: Array.from({ length: nOutputs }, () => ({ entries: [] })), + unknownGlobals: [], + }; +} + +describe("serialization round-trips", () => { + it("serialize → parse → serialize is byte-identical (all field types)", () => { + const { script } = newKey(); + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 1 }], + [{ script, value: 5000 }] + ); + const psbt = makePsbt( + unsigned, + [ + { + utxo: { script, value: 6000n }, + sighashType: 0xc1, + redeemScript: "51", + partialSigs: new Map([["02".repeat(33).slice(0, 66), Uint8Array.of(1, 2, 3)]]), + bip32: [ + { + keyType: 0x06, + keyData: hexToBytes("03" + "11".repeat(32)), + value: hexToBytes("deadbeef00000000"), + }, + ], + unknown: [ + { keyType: 0xfc, keyData: hexToBytes("0102"), value: hexToBytes("aabb") }, + ], + }, + ], + 1 + ); + psbt.unknownGlobals.push({ + keyType: 0xf0, + keyData: new Uint8Array(0), + value: hexToBytes("99"), + }); + psbt.outputs[0].entries.push({ + keyType: 0x02, + keyData: hexToBytes("02" + "22".repeat(32)), + value: hexToBytes("cafe00000000"), + }); + + const bytes = serializePsbt(psbt); + const parsed = parsePsbt(bytes); + expect(bytesToHex(serializePsbt(parsed))).toBe(bytesToHex(bytes)); + expect(parsed.unsignedTxHex).toBe(unsigned); + expect(parsed.inputs[0].utxo).toEqual({ script, value: 6000n }); + expect(parsed.inputs[0].sighashType).toBe(0xc1); + expect(parsed.inputs[0].redeemScript).toBe("51"); + expect(parsed.inputs[0].partialSigs.size).toBe(1); + expect(parsed.inputs[0].bip32).toHaveLength(1); + expect(parsed.inputs[0].unknown).toHaveLength(1); + expect(parsed.unknownGlobals).toHaveLength(1); + expect(parsed.outputs[0].entries).toHaveLength(1); + }); + + it("base64 and base64url both decode; output is standard padded base64", () => { + const { script } = newKey(); + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 0 }], + [{ script, value: 1000 }] + ); + const psbt = makePsbt(unsigned, [{ utxo: { script, value: 2000n } }], 1); + const b64 = psbtToBase64(psbt); + expect(b64.startsWith("cHNidP8B")).toBe(true); // "psbt\xff" + 0x01 keylen + expect(psbtFromBase64(b64).unsignedTxHex).toBe(unsigned); + + const b64url = b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + expect(psbtFromBase64(b64url).unsignedTxHex).toBe(unsigned); + + expect(() => psbtFromBase64("not base64!!")).toThrowError( + expect.objectContaining({ code: "INVALID_BASE64" }) + ); + }); + + it("values beyond 2^53 round-trip exactly (bigint plumbing)", () => { + const { script } = newKey(); + const big = (1n << 53n) + 7n; + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 0 }], + [{ script, value: 1000 }] + ); + const psbt = makePsbt(unsigned, [{ utxo: { script, value: big } }], 1); + const parsed = parsePsbt(serializePsbt(psbt)); + expect(parsed.inputs[0].utxo?.value).toBe(big); + }); +}); + +describe("parse rejection", () => { + const { script } = newKey(); + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 0 }], + [{ script, value: 1000 }] + ); + const validBytes = serializePsbt(makePsbt(unsigned, [{}], 1)); + + function expectCode(bytes: Uint8Array, code: string) { + expect(() => parsePsbt(bytes)).toThrowError( + expect.objectContaining({ code }) + ); + } + + it("rejects bad magic", () => { + const bytes = Uint8Array.from(validBytes); + bytes[0] = 0x71; + expectCode(bytes, "INVALID_MAGIC"); + }); + + it("rejects a missing unsigned tx", () => { + const writer = new ByteWriter(); + writer.writeBytes(hexToBytes("70736274ff")); + writer.writeUInt8(0x00); // empty global map + expectCode(writer.toBytes(), "MISSING_UNSIGNED_TX"); + }); + + it("rejects duplicate keys within a map", () => { + const writer = new ByteWriter(); + writer.writeBytes(hexToBytes("70736274ff")); + const txBytes = hexToBytes(unsigned); + for (let i = 0; i < 2; i++) { + writer.writeVarSlice(Uint8Array.of(0x00)); // key: type 0, no keydata + writer.writeVarSlice(txBytes); + } + writer.writeUInt8(0x00); + expectCode(writer.toBytes(), "DUPLICATE_KEY"); + }); + + it("rejects an unsigned tx whose scriptSigs are not empty", () => { + const { wif, address, script: s } = newKey(); + const signed = buildTx( + address, + wif, + [{ txid: TXID_A, vout: 0, script: s, value: 100_000 }], + [{ script: s, value: 99_000 }], + false, + undefined, + undefined, + true + ).toString(); + const psbtBytes = serializePsbt(makePsbt(signed, [{}], 1)); + expectCode(psbtBytes, "UNSIGNED_TX_HAS_SCRIPTSIGS"); + }); + + it("rejects trailing data after the output maps", () => { + const withTrailing = new Uint8Array(validBytes.length + 1); + withTrailing.set(validBytes); + withTrailing[validBytes.length] = 0x42; + expectCode(withTrailing, "TRAILING_DATA"); + }); + + it("rejects truncated data", () => { + expectCode(validBytes.subarray(0, validBytes.length - 2), "TRUNCATED"); + }); + + it("rejects a global unsigned-tx key carrying extra key data", () => { + const writer = new ByteWriter(); + writer.writeBytes(hexToBytes("70736274ff")); + writer.writeVarSlice(Uint8Array.of(0x00, 0x99)); // type 0 + stray keydata + writer.writeVarSlice(hexToBytes(unsigned)); + writer.writeUInt8(0x00); + expectCode(writer.toBytes(), "INVALID_KEY"); + }); + + it("rejects a sighash field that is not 4 bytes", () => { + const writer = new ByteWriter(); + writer.writeBytes(hexToBytes("70736274ff")); + writer.writeVarSlice(Uint8Array.of(0x00)); + writer.writeVarSlice(hexToBytes(unsigned)); + writer.writeUInt8(0x00); + // input map: sighash (0x03) with a 1-byte value + writer.writeVarSlice(Uint8Array.of(0x03)); + writer.writeVarSlice(Uint8Array.of(0x41)); + writer.writeUInt8(0x00); + writer.writeUInt8(0x00); // output map + expectCode(writer.toBytes(), "INVALID_KEY"); + }); + + it("rejects a partial-sig key that is not a 33/65-byte pubkey", () => { + const writer = new ByteWriter(); + writer.writeBytes(hexToBytes("70736274ff")); + writer.writeVarSlice(Uint8Array.of(0x00)); + writer.writeVarSlice(hexToBytes(unsigned)); + writer.writeUInt8(0x00); + const key = new ByteWriter(); + key.writeUInt8(0x02); + key.writeBytes(hexToBytes("ab".repeat(10))); + writer.writeVarSlice(key.toBytes()); + writer.writeVarSlice(hexToBytes("30440220")); + writer.writeUInt8(0x00); + writer.writeUInt8(0x00); + expectCode(writer.toBytes(), "INVALID_KEY"); + }); + + it("rejects a CTxOut utxo value with trailing bytes", () => { + const writer = new ByteWriter(); + writer.writeBytes(hexToBytes("70736274ff")); + writer.writeVarSlice(Uint8Array.of(0x00)); + writer.writeVarSlice(hexToBytes(unsigned)); + writer.writeUInt8(0x00); + const ctxOut = new ByteWriter(); + ctxOut.writeUInt64LE(1000n); + ctxOut.writeVarSlice(hexToBytes(script)); + ctxOut.writeUInt8(0x77); // trailing garbage + writer.writeVarSlice(Uint8Array.of(0x00)); + writer.writeVarSlice(ctxOut.toBytes()); + writer.writeUInt8(0x00); + writer.writeUInt8(0x00); + expectCode(writer.toBytes(), "TRAILING_DATA"); + }); + + it("rejects an unsigned tx above the size cap", () => { + const writer = new ByteWriter(); + writer.writeBytes(hexToBytes("70736274ff")); + writer.writeVarSlice(Uint8Array.of(0x00)); + writer.writeVarSlice(new Uint8Array(MAX_PSBT_TX_SIZE + 1)); + writer.writeUInt8(0x00); + expectCode(writer.toBytes(), "TX_TOO_LARGE"); + }); + + it("rejects non-canonical varints like Core's ReadCompactSize", () => { + const bytes = Uint8Array.of(0xfd, 0x05, 0x00); // 5 encoded in 3 bytes + expect(() => new ByteReader(bytes).readVarInt()).toThrowError( + expect.objectContaining({ code: "NON_CANONICAL_VARINT" }) + ); + }); +}); + +describe("sign → finalize → extract", () => { + // radiantjs ECDSA signing is "hedged" — deterministic k mixed with fresh + // entropy per call (lib/crypto/ecdsa.js) — so two signs over the same + // preimage produce different (but both valid) DER encodings. Byte-identity + // with `buildTx` isn't achievable; verifying signPsbt's output against the + // same `Transaction.Sighash.verify` a node would run is the real contract. + it("produces a transaction structurally identical to, and interchangeable with, buildTx's", () => { + const maker = newKey(); + const dest = newKey(); + const input = { + txid: TXID_A, + vout: 2, + script: maker.script, + value: 100_000_000, + }; + const outputs = [{ script: dest.script, value: 99_990_000 }]; + + // Reference: the wallet's own builder, explicit-script path (signs with + // ALL|FORKID via Transaction.Sighash.sign — the exact call signPsbt uses). + const reference = buildTx( + maker.address, + maker.wif, + [input], + outputs, + false, + undefined, + undefined, + true + ).toString(); + + // Same tx, unsigned, wrapped in a PSBT with a CTxOut utxo field. + const unsignedTx = transactionFromHex(reference); + unsignedTx.inputs.forEach((i) => i.setScript(Script.empty())); + const psbt = makePsbt( + unsignedTx.toString(), + [{ utxo: { script: maker.script, value: BigInt(input.value) } }], + 1 + ); + + const signResult = signPsbt(psbt, maker.wif); + expect(signResult.signedIndexes).toEqual([0]); + expect(signResult.skipped).toEqual([]); + // The original object is untouched. + expect(psbt.inputs[0].partialSigs.size).toBe(0); + + const { psbt: finalized, complete } = finalizePsbt(signResult.psbt); + expect(complete).toBe(true); + expect(finalized.inputs[0].partialSigs.size).toBe(0); // cleared + + const extracted = extractTx(finalized); + const extractedTx = transactionFromHex(extracted); + const referenceTx = transactionFromHex(reference); + + // Same inputs/outputs, and the scriptSig shape matches (). + expect(extractedTx.inputs.length).toBe(referenceTx.inputs.length); + expect(extractedTx.outputs.length).toBe(referenceTx.outputs.length); + expect(extractedTx.outputs[0].script.toHex()).toBe( + referenceTx.outputs[0].script.toHex() + ); + expect(extractedTx.outputs[0].satoshis).toBe(referenceTx.outputs[0].satoshis); + const scriptSig = extractedTx.inputs[0].script; + expect(scriptSig.chunks).toHaveLength(2); + const sig = crypto.Signature.fromTxFormat(Buffer.from(scriptSig.chunks[0].buf)); + expect(sig.nhashtype).toBe(crypto.Signature.SIGHASH_ALL | crypto.Signature.SIGHASH_FORKID); + expect( + Transaction.Sighash.verify( + extractedTx, + sig, + maker.key.toPublicKey(), + 0, + Script.fromHex(maker.script), + bnFromValue(input.value) + ) + ).toBe(true); + }); + + it("supports sequential multi-party signing of separate inputs", () => { + const alice = newKey(); + const bob = newKey(); + const dest = newKey(); + const unsigned = makeUnsignedTx( + [ + { txid: TXID_A, vout: 0 }, + { txid: TXID_B, vout: 1 }, + ], + [{ script: dest.script, value: 150_000 }] + ); + const psbt = makePsbt( + unsigned, + [ + { utxo: { script: alice.script, value: 100_000n } }, + { utxo: { script: bob.script, value: 100_000n } }, + ], + 1 + ); + + const afterAlice = signPsbt(psbt, alice.wif); + expect(afterAlice.signedIndexes).toEqual([0]); + expect(afterAlice.skipped).toEqual([{ index: 1, reason: "not-mine" }]); + expect(finalizePsbt(afterAlice.psbt).complete).toBe(false); + + // The half-signed PSBT survives a serialization hop (as it would between + // two wallets) before the second signer takes over. + const rehydrated = psbtFromBase64(psbtToBase64(afterAlice.psbt)); + const afterBob = signPsbt(rehydrated, bob.wif); + expect(afterBob.signedIndexes).toEqual([1]); + + const { psbt: finalized, complete } = finalizePsbt(afterBob.psbt); + expect(complete).toBe(true); + const tx = transactionFromHex(extractTx(finalized)); + + // Each input's signature must verify against its own prevout. + [alice, bob].forEach((signer, i) => { + const scriptSig = tx.inputs[i].script; + const sigBuf = Buffer.from(scriptSig.chunks[0].buf); + const sig = crypto.Signature.fromTxFormat(sigBuf); + expect( + Transaction.Sighash.verify( + tx, + sig, + signer.key.toPublicKey(), + i, + Script.fromHex(signer.script), + bnFromValue(100_000) + ) + ).toBe(true); + }); + }); + + it("skips finalized, already-signed, and prevout-less inputs", () => { + const me = newKey(); + const unsigned = makeUnsignedTx( + [ + { txid: TXID_A, vout: 0 }, + { txid: TXID_B, vout: 0 }, + ], + [{ script: me.script, value: 1000 }] + ); + const psbt = makePsbt( + unsigned, + [{ finalScriptSig: "51" }, {}], + 1 + ); + const result = signPsbt(psbt, me.wif); + expect(result.signedIndexes).toEqual([]); + expect(result.skipped).toEqual([ + { index: 0, reason: "finalized" }, + { index: 1, reason: "no-prevout" }, + ]); + }); + + it("does not trust a fabricated partial signature claiming our own pubkey", () => { + const me = newKey(); + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 0 }], + [{ script: me.script, value: 1000 }] + ); + const utxo = { script: me.script, value: 2000n }; + const pubkeyHex = me.key.toPublicKey().toString(); + // Garbage bytes in the signature slot, but keyed under our own real + // pubkey — the shape a malformed/malicious PSBT (or a bad combiner) could + // produce. A DER-shaped-enough blob so `Signature.fromTxFormat` doesn't + // throw outright; the point is it must fail Sighash.verify. + const bogusSig = new Uint8Array([ + ...hexToBytes( + "3006020100020100" + ), + DEFAULT_SIGHASH & 0xff, + ]); + + // signPsbt must not treat this as "already-signed" — it should notice + // the signature doesn't verify and produce a real one instead. + const psbt = makePsbt( + unsigned, + [{ utxo, partialSigs: new Map([[pubkeyHex, bogusSig]]) }], + 1 + ); + const result = signPsbt(psbt, me.wif); + expect(result.signedIndexes).toEqual([0]); + expect(result.skipped).toEqual([]); + const realSig = result.psbt.inputs[0].partialSigs.get(pubkeyHex)!; + expect(realSig).not.toEqual(bogusSig); + + // And finalizePsbt must not assemble a finalScriptSig from the bogus + // signature on its own — a pubkey-hash match alone isn't proof. + const stillBogus = makePsbt( + unsigned, + [{ utxo, partialSigs: new Map([[pubkeyHex, bogusSig]]) }], + 1 + ); + const { complete } = finalizePsbt(stillBogus); + expect(complete).toBe(false); + + // But finalizing the REAL signature signPsbt just produced does work. + const { complete: realComplete } = finalizePsbt(result.psbt); + expect(realComplete).toBe(true); + }); + + it("enforces sighash policy: FORKID required, NONE refused, SINGLE|ACP allowed", () => { + const me = newKey(); + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 0 }], + [{ script: me.script, value: 1000 }] + ); + const utxo = { script: me.script, value: 2000n }; + + expect(() => + signPsbt(makePsbt(unsigned, [{ utxo, sighashType: 0x01 }], 1), me.wif) + ).toThrowError(expect.objectContaining({ code: "MISSING_FORKID" })); + + expect(() => + signPsbt(makePsbt(unsigned, [{ utxo, sighashType: 0x42 }], 1), me.wif) + ).toThrowError(expect.objectContaining({ code: "DISALLOWED_SIGHASH" })); + + const single = signPsbt( + makePsbt(unsigned, [{ utxo, sighashType: 0xc3 }], 1), + me.wif + ); + expect(single.signedIndexes).toEqual([0]); + const sig = single.psbt.inputs[0].partialSigs.values().next().value!; + expect(sig[sig.length - 1]).toBe(0xc3); // trailing sighash byte + }); + + it("refuses to co-sign a tx spending token-bearing outputs (overridable)", () => { + const me = newKey(); + const other = newKey(); + const tokenScript = nftScript(other.address, REF); + const unsigned = makeUnsignedTx( + [ + { txid: TXID_A, vout: 0 }, + { txid: TXID_B, vout: 0 }, + ], + [{ script: me.script, value: 1000 }] + ); + const psbt = makePsbt( + unsigned, + [ + { utxo: { script: me.script, value: 2000n } }, + { utxo: { script: tokenScript, value: 1n } }, + ], + 1 + ); + + expect(() => signPsbt(psbt, me.wif)).toThrowError( + expect.objectContaining({ code: "TOKEN_BEARING_INPUT" }) + ); + + const overridden = signPsbt(psbt, me.wif, { + allowTokenBearingInputs: true, + }); + expect(overridden.signedIndexes).toEqual([0]); + expect(overridden.skipped).toEqual([{ index: 1, reason: "not-mine" }]); + }); + + it("extractTx refuses an incomplete PSBT", () => { + const me = newKey(); + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 0 }], + [{ script: me.script, value: 1000 }] + ); + expect(() => extractTx(makePsbt(unsigned, [{}], 1))).toThrowError( + expect.objectContaining({ code: "NOT_FINALIZED" }) + ); + }); +}); + +describe("analyzePsbt", () => { + const me = newKey(); + const them = newKey(); + + it("computes totals, fee, ownership, and addresses when prevouts are known", () => { + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 3 }], + [ + { script: them.script, value: 60_000 }, + { script: me.script, value: 30_000 }, + ] + ); + const psbt = makePsbt( + unsigned, + [{ utxo: { script: me.script, value: 100_000n } }], + 2 + ); + const analysis = analyzePsbt(psbt, { ownScripts: new Set([me.script]) }); + + expect(analysis.inputs[0]).toMatchObject({ + txid: TXID_A, + vout: 3, + mine: true, + tokenBearing: false, + sighashType: DEFAULT_SIGHASH, + address: me.address, + }); + expect(analysis.outputs[0]).toMatchObject({ + mine: false, + address: them.address, + }); + expect(analysis.outputs[1].mine).toBe(true); + expect(analysis.totalIn).toBe(100_000n); + expect(analysis.totalOut).toBe(90_000n); + expect(analysis.fee).toBe(10_000n); + expect(analysis.feeRate).toBeGreaterThan(0); + expect(analysis.warnings).not.toContain("FEE_UNKNOWN"); + }); + + it("reports FEE_UNKNOWN + UNKNOWN_PREVOUT when a prevout is missing", () => { + const unsigned = makeUnsignedTx( + [ + { txid: TXID_A, vout: 0 }, + { txid: TXID_B, vout: 0 }, + ], + [{ script: them.script, value: 1000 }] + ); + const psbt = makePsbt( + unsigned, + [{ utxo: { script: me.script, value: 2000n } }, {}], + 1 + ); + const analysis = analyzePsbt(psbt); + expect(analysis.totalIn).toBeUndefined(); + expect(analysis.fee).toBeUndefined(); + expect(analysis.warnings).toContain("FEE_UNKNOWN"); + expect(analysis.warnings).toContain("UNKNOWN_PREVOUT"); + }); + + it("flags unusual sighash modes and token-bearing inputs", () => { + const tokenScript = nftScript(them.address, REF); + const unsigned = makeUnsignedTx( + [ + { txid: TXID_A, vout: 0 }, + { txid: TXID_B, vout: 1 }, + ], + [{ script: them.script, value: 1000 }] + ); + const psbt = makePsbt( + unsigned, + [ + { utxo: { script: tokenScript, value: 1n }, sighashType: 0xc3 }, + { utxo: { script: me.script, value: 5000n }, sighashType: 0x42 }, + ], + 1 + ); + const analysis = analyzePsbt(psbt); + expect(analysis.warnings).toContain("TOKEN_BEARING_INPUT"); + expect(analysis.warnings).toContain("SIGHASH_SINGLE"); + expect(analysis.warnings).toContain("SIGHASH_ANYONECANPAY"); + expect(analysis.warnings).toContain("SIGHASH_NONE"); + // Input 1 uses SIGHASH_SINGLE at an index with a matching output 0? No — + // index 0 has output 0, so no unmatched warning; index bound is exercised + // in the case below. + expect(analysis.warnings).not.toContain("SIGHASH_SINGLE_UNMATCHED"); + expect(analysis.inputs[0].tokenBearing).toBe(true); + }); + + it("flags SIGHASH_SINGLE with no matching output index", () => { + const unsigned = makeUnsignedTx( + [ + { txid: TXID_A, vout: 0 }, + { txid: TXID_B, vout: 0 }, + ], + [{ script: them.script, value: 1000 }] + ); + const psbt = makePsbt( + unsigned, + [ + { utxo: { script: me.script, value: 2000n } }, + { utxo: { script: me.script, value: 2000n }, sighashType: 0x43 }, + ], + 1 + ); + expect(analyzePsbt(psbt).warnings).toContain("SIGHASH_SINGLE_UNMATCHED"); + }); + + it("flags an absurdly high fee (unit-confusion guard)", () => { + const unsigned = makeUnsignedTx( + [{ txid: TXID_A, vout: 0 }], + [{ script: them.script, value: 1000 }] + ); + const psbt = makePsbt( + unsigned, + [{ utxo: { script: me.script, value: 100_000_000_000n } }], + 1 + ); + expect(analyzePsbt(psbt).warnings).toContain("HIGH_FEE"); + }); +}); diff --git a/packages/lib/src/__tests__/swap-load-output-order.test.ts b/packages/lib/src/__tests__/swap-load-output-order.test.ts index b13be50..5bfbac7 100644 --- a/packages/lib/src/__tests__/swap-load-output-order.test.ts +++ b/packages/lib/src/__tests__/swap-load-output-order.test.ts @@ -148,6 +148,53 @@ describe("swap completion output ordering", () => { expect(outputs[0].script).toBe(makerPaymentScript); }); + it("orders platform-fee outputs after royalty, before funding", () => { + const royaltyOutputs: UnfinalizedOutput[] = [ + { script: p2pkhScript(ADDR.royaltyA), value: 200_000 }, + ]; + const platformFeeOutputs: UnfinalizedOutput[] = [ + { script: p2pkhScript(ADDR.royaltyB), value: 50_000 }, + ]; + const fundingOutputs: UnfinalizedOutput[] = [ + { script: p2pkhScript(ADDR.buyer), value: 500_000 }, + ]; + + const outputs = buildSwapCompletionOutputs({ + makerPayment, + assetToTaker, + royaltyOutputs, + platformFeeOutputs, + fundingOutputs, + }); + + expect(outputs.map((o) => o.script)).toEqual([ + makerPaymentScript, + assetScript, + royaltyOutputs[0].script, + platformFeeOutputs[0].script, + fundingOutputs[0].script, + ]); + expect(outputs[0].script).toBe(makerPaymentScript); + }); + + it("supports a platform fee with no royalty present", () => { + const platformFeeOutputs: UnfinalizedOutput[] = [ + { script: p2pkhScript(ADDR.royaltyA), value: 50_000 }, + ]; + + const outputs = buildSwapCompletionOutputs({ + makerPayment, + assetToTaker, + platformFeeOutputs, + }); + + expect(outputs.map((o) => o.script)).toEqual([ + makerPaymentScript, + assetScript, + platformFeeOutputs[0].script, + ]); + }); + it("treats empty royalty/funding lists as absent", () => { const outputs = buildSwapCompletionOutputs({ makerPayment, diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index e412a1d..ca8ea1e 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -65,6 +65,9 @@ export { // Storage (Phase 2: Off-Chain Storage) export * from "./storage"; +// Radiant PSBT (BIP-174 profile, byte-compatible with Radiant Core) +export * from "./psbt"; + // Radiant Vault (CLTV timelocking) export * from "./vault"; diff --git a/packages/lib/src/psbt/analyze.ts b/packages/lib/src/psbt/analyze.ts new file mode 100644 index 0000000..7241698 --- /dev/null +++ b/packages/lib/src/psbt/analyze.ts @@ -0,0 +1,182 @@ +/** + * Pure inspection of a PSBT for the approval UI: per-input/-output rows with + * ownership flags, totals, fee (when every prevout is known), and typed + * warnings. Ownership is injected by the caller (`ownScripts`) because this + * module cannot touch the wallet database; the app derives the set from + * `p2pkhScript(wallet.address)` plus its `txo` table. + */ +import rjs from "@radiant-core/radiantjs"; +import { Buffer } from "buffer"; +import { MAX_REASONABLE_FEE_RATE } from "../feePolicy"; +import { transactionFromHex } from "../rjsCompat"; +import { + isTokenBearing, + p2pkhScriptSigSize, + parseP2pkhScript, +} from "../script"; +import { NetworkKey } from "../types"; +import { Psbt } from "./psbt"; +import { DEFAULT_SIGHASH } from "./sign"; + +const { Address, Networks } = rjs; + +export type PsbtWarning = + | "TOKEN_BEARING_INPUT" + | "UNKNOWN_PREVOUT" + | "FEE_UNKNOWN" + | "HIGH_FEE" + | "SIGHASH_NONE" + | "SIGHASH_SINGLE" + | "SIGHASH_SINGLE_UNMATCHED" + | "SIGHASH_ANYONECANPAY" + | "MISSING_FORKID" + | "ALREADY_SIGNED"; + +export type PsbtInputSummary = { + txid: string; + vout: number; + script?: string; + value?: bigint; + address?: string; + mine: boolean; + tokenBearing: boolean; + sighashType: number; + hasPartialSig: boolean; + finalized: boolean; +}; + +export type PsbtOutputSummary = { + script: string; + value: bigint; + address?: string; + mine: boolean; + tokenBearing: boolean; +}; + +export type PsbtAnalysis = { + inputs: PsbtInputSummary[]; + outputs: PsbtOutputSummary[]; + /** Sum of known input values; undefined when any prevout is unknown. */ + totalIn?: bigint; + totalOut: bigint; + fee?: bigint; + /** Size the tx will have once every pending input carries a P2PKH sig. */ + estSignedSize: number; + /** photons per byte; undefined while the fee is unknown. */ + feeRate?: number; + warnings: PsbtWarning[]; +}; + +export type AnalyzeContext = { + /** scriptPubKey hexes the wallet controls (for mine/change detection). */ + ownScripts?: Set; + /** Network used to render addresses; omitted ⇒ mainnet. */ + net?: NetworkKey; +}; + +function scriptToAddress(script: string, net: NetworkKey): string | undefined { + const { address: pkh } = parseP2pkhScript(script); + if (!pkh) return undefined; + try { + return Address.fromPublicKeyHash( + Buffer.from(pkh, "hex"), + Networks[net] + ).toString(); + } catch { + return undefined; + } +} + +export function analyzePsbt(psbt: Psbt, ctx?: AnalyzeContext): PsbtAnalysis { + const net: NetworkKey = ctx?.net ?? "mainnet"; + const ownScripts = ctx?.ownScripts ?? new Set(); + const tx = transactionFromHex(psbt.unsignedTxHex); + const warnings = new Set(); + + const outputs: PsbtOutputSummary[] = tx.outputs.map((o) => { + const script = o.script.toHex(); + return { + script, + // BN → string → bigint keeps values beyond 2^53 exact. + value: BigInt(o.satoshisBN.toString()), + address: scriptToAddress(script, net), + mine: ownScripts.has(script), + tokenBearing: isTokenBearing(script), + }; + }); + const totalOut = outputs.reduce((sum, o) => sum + o.value, 0n); + + let totalIn: bigint | undefined = 0n; + const inputs: PsbtInputSummary[] = tx.inputs.map((txin, i) => { + const pin = psbt.inputs[i]; + const utxo = pin?.utxo; + const sighashType = pin?.sighashType ?? DEFAULT_SIGHASH; + const finalized = pin?.finalScriptSig !== undefined; + const hasPartialSig = (pin?.partialSigs.size ?? 0) > 0; + + if (!utxo) { + totalIn = undefined; + warnings.add("UNKNOWN_PREVOUT"); + } else { + if (totalIn !== undefined) totalIn += utxo.value; + if (isTokenBearing(utxo.script)) warnings.add("TOKEN_BEARING_INPUT"); + } + if (finalized || hasPartialSig) warnings.add("ALREADY_SIGNED"); + + const base = sighashType & 0x1f; + if (!(sighashType & 0x40)) warnings.add("MISSING_FORKID"); + if (base === 0x02) warnings.add("SIGHASH_NONE"); + if (base === 0x03) { + warnings.add("SIGHASH_SINGLE"); + if (i >= tx.outputs.length) warnings.add("SIGHASH_SINGLE_UNMATCHED"); + } + if (sighashType & 0x80) warnings.add("SIGHASH_ANYONECANPAY"); + + return { + txid: txin.prevTxId.toString("hex"), + vout: txin.outputIndex, + script: utxo?.script, + value: utxo?.value, + address: utxo ? scriptToAddress(utxo.script, net) : undefined, + mine: utxo ? ownScripts.has(utxo.script) : false, + tokenBearing: utxo ? isTokenBearing(utxo.script) : false, + sighashType, + hasPartialSig, + finalized, + }; + }); + + // Unsigned inputs carry a 1-byte empty-script length; a P2PKH signature + // replaces that with ~1 + 107 bytes, so each pending input adds ~107. + const unsignedSize = psbt.unsignedTxHex.length / 2; + const estSignedSize = + unsignedSize + + psbt.inputs.reduce((sum, pin) => { + if (pin.finalScriptSig !== undefined) { + return sum + pin.finalScriptSig.length / 2; + } + return sum + p2pkhScriptSigSize; + }, 0); + + let fee: bigint | undefined; + let feeRate: number | undefined; + if (totalIn !== undefined) { + fee = totalIn - totalOut; + feeRate = Number(fee) / estSignedSize; + // Mirror feeCheck's 20% slack over the reference ceiling. + if (feeRate > MAX_REASONABLE_FEE_RATE * 1.2) warnings.add("HIGH_FEE"); + } else { + warnings.add("FEE_UNKNOWN"); + } + + return { + inputs, + outputs, + totalIn, + totalOut, + fee, + estSignedSize, + feeRate, + warnings: [...warnings], + }; +} diff --git a/packages/lib/src/psbt/errors.ts b/packages/lib/src/psbt/errors.ts new file mode 100644 index 0000000..392550d --- /dev/null +++ b/packages/lib/src/psbt/errors.ts @@ -0,0 +1,40 @@ +/** + * Typed errors for the Radiant PSBT module. + * + * Every parse/sign/finalize failure carries a stable {@link PsbtErrorCode} so + * callers (UI, protocol layer, tests) can branch on the cause without string + * matching. Messages are human-readable and safe to display. + */ + +export type PsbtErrorCode = + // Container / serialization + | "INVALID_MAGIC" + | "TRUNCATED" + | "TRAILING_DATA" + | "NON_CANONICAL_VARINT" + | "DUPLICATE_KEY" + | "INVALID_KEY" + | "INVALID_BASE64" + // Global map / unsigned tx + | "MISSING_UNSIGNED_TX" + | "INVALID_UNSIGNED_TX" + | "INPUT_INDEX_OUT_OF_RANGE" + | "UNSIGNED_TX_HAS_SCRIPTSIGS" + | "TX_TOO_LARGE" + | "VALUE_OUT_OF_RANGE" + // Signing / finalizing + | "MISSING_UTXO" + | "TOKEN_BEARING_INPUT" + | "MISSING_FORKID" + | "DISALLOWED_SIGHASH" + | "NOT_FINALIZED"; + +export class PsbtError extends Error { + constructor( + public readonly code: PsbtErrorCode, + message?: string + ) { + super(message ?? code); + this.name = "PsbtError"; + } +} diff --git a/packages/lib/src/psbt/index.ts b/packages/lib/src/psbt/index.ts new file mode 100644 index 0000000..831fdef --- /dev/null +++ b/packages/lib/src/psbt/index.ts @@ -0,0 +1,45 @@ +/** + * Radiant PSBT — BIP-174 profile byte-compatible with Radiant Core + * (`walletcreatefundedpsbt` / `walletprocesspsbt` / `finalizepsbt`). + * See `docs/psbt.md` for the wire-format specification. + */ +export { PsbtError, type PsbtErrorCode } from "./errors"; +export type { PsbtKeyValue } from "./keyvalue"; +export { + MAX_PSBT_TX_SIZE, + PSBT_GLOBAL_UNSIGNED_TX, + PSBT_IN_BIP32_DERIVATION, + PSBT_IN_FINAL_SCRIPTSIG, + PSBT_IN_PARTIAL_SIG, + PSBT_IN_REDEEM_SCRIPT, + PSBT_IN_SIGHASH, + PSBT_IN_UTXO, + PSBT_MAGIC, + inputPrevout, + parsePsbt, + psbtFromBase64, + psbtToBase64, + serializePsbt, + type Psbt, + type PsbtInput, + type PsbtOutput, + type PsbtUtxo, +} from "./psbt"; +export { + DEFAULT_ALLOWED_SIGHASHES, + DEFAULT_SIGHASH, + extractTx, + finalizePsbt, + signPsbt, + type SignPsbtOptions, + type SignPsbtResult, + type SkipReason, +} from "./sign"; +export { + analyzePsbt, + type AnalyzeContext, + type PsbtAnalysis, + type PsbtInputSummary, + type PsbtOutputSummary, + type PsbtWarning, +} from "./analyze"; diff --git a/packages/lib/src/psbt/keyvalue.ts b/packages/lib/src/psbt/keyvalue.ts new file mode 100644 index 0000000..6e278fa --- /dev/null +++ b/packages/lib/src/psbt/keyvalue.ts @@ -0,0 +1,188 @@ +/** + * Low-level byte plumbing for the PSBT container: a bounds-checked reader, + * a growable writer, Bitcoin CompactSize varints, and the BIP-174 + * key-value-map framing (`varint keylen ‖ keytype ‖ keydata` / + * `varint valuelen ‖ value`, maps terminated by a single 0x00 byte). + * + * Matches Radiant Core's deserializer strictness: varints must be minimally + * encoded (Core's `ReadCompactSize` rejects non-canonical forms) and a + * duplicate full key within one map is a hard error (BIP-174 requirement, + * enforced by Core's `SerializeToVector` reader). + */ +import { bytesToHex } from "@noble/hashes/utils"; +import { PsbtError } from "./errors"; + +/** One raw key-value entry: `keyType` varint + remaining key bytes + value. */ +export type PsbtKeyValue = { + keyType: number; + keyData: Uint8Array; + value: Uint8Array; +}; + +export class ByteReader { + private pos = 0; + + constructor(private readonly bytes: Uint8Array) {} + + get offset(): number { + return this.pos; + } + + eof(): boolean { + return this.pos >= this.bytes.length; + } + + readBytes(n: number): Uint8Array { + if (n < 0 || this.pos + n > this.bytes.length) { + throw new PsbtError("TRUNCATED", "unexpected end of data"); + } + const out = this.bytes.subarray(this.pos, this.pos + n); + this.pos += n; + return out; + } + + readUInt8(): number { + return this.readBytes(1)[0]; + } + + readUInt32LE(): number { + const b = this.readBytes(4); + return (b[0] | (b[1] << 8) | (b[2] << 16) | (b[3] << 24)) >>> 0; + } + + readUInt64LE(): bigint { + const b = this.readBytes(8); + let v = 0n; + for (let i = 7; i >= 0; i--) { + v = (v << 8n) | BigInt(b[i]); + } + return v; + } + + /** Bitcoin CompactSize varint; rejects non-canonical encodings like Core. */ + readVarInt(): number { + const first = this.readUInt8(); + let v: bigint; + if (first < 0xfd) return first; + if (first === 0xfd) { + const b = this.readBytes(2); + v = BigInt(b[0] | (b[1] << 8)); + if (v < 0xfdn) throw new PsbtError("NON_CANONICAL_VARINT"); + } else if (first === 0xfe) { + v = BigInt(this.readUInt32LE()); + if (v < 0x10000n) throw new PsbtError("NON_CANONICAL_VARINT"); + } else { + v = this.readUInt64LE(); + if (v < 0x100000000n) throw new PsbtError("NON_CANONICAL_VARINT"); + } + if (v > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new PsbtError("TRUNCATED", "varint length is absurdly large"); + } + return Number(v); + } + + /** varint length followed by that many bytes. */ + readVarSlice(): Uint8Array { + return this.readBytes(this.readVarInt()); + } +} + +export class ByteWriter { + private chunks: Uint8Array[] = []; + + writeBytes(b: Uint8Array): void { + this.chunks.push(b); + } + + writeUInt8(v: number): void { + this.chunks.push(Uint8Array.of(v & 0xff)); + } + + writeUInt32LE(v: number): void { + this.chunks.push( + Uint8Array.of(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff) + ); + } + + writeUInt64LE(v: bigint): void { + const b = new Uint8Array(8); + let x = v; + for (let i = 0; i < 8; i++) { + b[i] = Number(x & 0xffn); + x >>= 8n; + } + this.chunks.push(b); + } + + writeVarInt(v: number): void { + if (v < 0xfd) { + this.writeUInt8(v); + } else if (v <= 0xffff) { + this.writeUInt8(0xfd); + this.chunks.push(Uint8Array.of(v & 0xff, (v >>> 8) & 0xff)); + } else if (v <= 0xffffffff) { + this.writeUInt8(0xfe); + this.writeUInt32LE(v); + } else { + this.writeUInt8(0xff); + this.writeUInt64LE(BigInt(v)); + } + } + + writeVarSlice(b: Uint8Array): void { + this.writeVarInt(b.length); + this.writeBytes(b); + } + + toBytes(): Uint8Array { + const total = this.chunks.reduce((n, c) => n + c.length, 0); + const out = new Uint8Array(total); + let pos = 0; + for (const c of this.chunks) { + out.set(c, pos); + pos += c.length; + } + return out; + } +} + +/** + * Read one key-value map: entries until the 0x00 separator (an empty key). + * A repeated full key (type + keydata) within the map is a hard reject. + */ +export function readKeyValueMap(reader: ByteReader): PsbtKeyValue[] { + const entries: PsbtKeyValue[] = []; + const seen = new Set(); + for (;;) { + const keyLen = reader.readVarInt(); + if (keyLen === 0) return entries; // separator + const key = reader.readBytes(keyLen); + const keyReader = new ByteReader(key); + const keyType = keyReader.readVarInt(); + const keyData = key.subarray(keyReader.offset); + const value = reader.readVarSlice(); + const dupKey = bytesToHex(key); + if (seen.has(dupKey)) { + throw new PsbtError("DUPLICATE_KEY", `duplicate key in map: ${dupKey}`); + } + seen.add(dupKey); + entries.push({ keyType, keyData, value }); + } +} + +/** Serialize one entry. Key types in this profile all fit a single byte. */ +export function writeKeyValue(writer: ByteWriter, kv: PsbtKeyValue): void { + const keyWriter = new ByteWriter(); + keyWriter.writeVarInt(kv.keyType); + keyWriter.writeBytes(kv.keyData); + writer.writeVarSlice(keyWriter.toBytes()); + writer.writeVarSlice(kv.value); +} + +export function writeKeyValueMap( + writer: ByteWriter, + entries: PsbtKeyValue[] +): void { + for (const kv of entries) writeKeyValue(writer, kv); + writer.writeUInt8(0x00); // separator +} diff --git a/packages/lib/src/psbt/psbt.ts b/packages/lib/src/psbt/psbt.ts new file mode 100644 index 0000000..4703aa4 --- /dev/null +++ b/packages/lib/src/psbt/psbt.ts @@ -0,0 +1,334 @@ +/** + * Radiant PSBT container — parse/serialize for the BIP-174 profile Radiant + * Core implements (the Bitcoin ABC segwit-stripped variant, Core ~0.17 base). + * Wire-format ground truth is Radiant Core `src/psbt.h`/`src/psbt.cpp`; this + * module must stay byte-compatible with it so PSBTs round-trip between + * Photonic and `walletcreatefundedpsbt`/`walletprocesspsbt`/`finalizepsbt`. + * + * Profile notes (deviations from mainline BIP-174): + * - Per-input key 0x00 (`PSBT_IN_UTXO`) holds a bare CTxOut + * (int64-LE value ‖ varint-len scriptPubKey) — NOT a full previous + * transaction. This is safe on Radiant because the FORKID sighash commits + * to the spent output's script and value: lying about either just produces + * an invalid signature. + * - No witness key types exist (0x01/0x05/0x08 fall through to `unknown`). + * - The unsigned transaction is legacy-serialized (no witness marker). + * - Transport is standard base64; base64url is also accepted on parse for + * deep-link convenience. + * + * Unknown key-value pairs are preserved verbatim and re-emitted so combiner + * semantics and forward compatibility hold. Serialization writes known fields + * in Radiant Core's emission order, so a Core-produced PSBT round-trips + * byte-identically. + */ +import { bytesToHex, hexToBytes } from "@noble/hashes/utils"; +import { Buffer } from "buffer"; +import { PsbtError } from "./errors"; +import { + ByteReader, + ByteWriter, + PsbtKeyValue, + readKeyValueMap, + writeKeyValue, +} from "./keyvalue"; +import { transactionFromHex } from "../rjsCompat"; + +export type { PsbtKeyValue }; + +export const PSBT_MAGIC = Uint8Array.of(0x70, 0x73, 0x62, 0x74, 0xff); + +export const PSBT_GLOBAL_UNSIGNED_TX = 0x00; +export const PSBT_IN_UTXO = 0x00; +export const PSBT_IN_PARTIAL_SIG = 0x02; +export const PSBT_IN_SIGHASH = 0x03; +export const PSBT_IN_REDEEM_SCRIPT = 0x04; +export const PSBT_IN_BIP32_DERIVATION = 0x06; +export const PSBT_IN_FINAL_SCRIPTSIG = 0x07; + +/** Cap on the unsigned transaction, mirroring node-side sanity limits. */ +export const MAX_PSBT_TX_SIZE = 100_000; + +const MAX_I64 = (1n << 63n) - 1n; + +/** The spent output an input commits to: CTxOut under key 0x00. */ +export type PsbtUtxo = { + /** scriptPubKey hex */ + script: string; + /** photons — bigint because amounts can exceed 2^53 */ + value: bigint; +}; + +export type PsbtInput = { + utxo?: PsbtUtxo; + /** compressed/uncompressed pubkey hex → DER sig ‖ sighash byte */ + partialSigs: Map; + /** 4-byte LE uint32 on the wire */ + sighashType?: number; + /** hex; parsed & preserved, not consumed by the P2PKH signer */ + redeemScript?: string; + /** raw 0x06 entries (pubkey key-data + fingerprint/path value), preserved */ + bip32: PsbtKeyValue[]; + /** hex */ + finalScriptSig?: string; + unknown: PsbtKeyValue[]; +}; + +/** + * Output maps are not interpreted in v1 — entries (redeem_script, bip32, + * unknowns) are preserved verbatim, in order, for byte-identical round-trips. + */ +export type PsbtOutput = { + entries: PsbtKeyValue[]; +}; + +export type Psbt = { + /** The global unsigned transaction, raw legacy-serialized hex. */ + unsignedTxHex: string; + inputs: PsbtInput[]; + outputs: PsbtOutput[]; + /** Global entries other than the unsigned tx, preserved verbatim. */ + unknownGlobals: PsbtKeyValue[]; +}; + +function requireEmptyKeyData(kv: PsbtKeyValue, what: string): void { + if (kv.keyData.length > 0) { + throw new PsbtError("INVALID_KEY", `${what} key must be a single type byte`); + } +} + +function requirePubkeyKeyData(kv: PsbtKeyValue, what: string): string { + if (kv.keyData.length !== 33 && kv.keyData.length !== 65) { + throw new PsbtError("INVALID_KEY", `${what} key must be a 33/65-byte pubkey`); + } + return bytesToHex(kv.keyData); +} + +function parseUtxoValue(value: Uint8Array): PsbtUtxo { + const reader = new ByteReader(value); + const photons = reader.readUInt64LE(); + if (photons > MAX_I64) { + throw new PsbtError("VALUE_OUT_OF_RANGE", "utxo value exceeds int64"); + } + const script = reader.readVarSlice(); + if (!reader.eof()) { + throw new PsbtError("TRAILING_DATA", "trailing bytes after utxo CTxOut"); + } + return { script: bytesToHex(script), value: photons }; +} + +function serializeUtxoValue(utxo: PsbtUtxo): Uint8Array { + if (utxo.value < 0n || utxo.value > MAX_I64) { + throw new PsbtError("VALUE_OUT_OF_RANGE", "utxo value exceeds int64"); + } + const writer = new ByteWriter(); + writer.writeUInt64LE(utxo.value); + writer.writeVarSlice(hexToBytes(utxo.script)); + return writer.toBytes(); +} + +function parseInputMap(entries: PsbtKeyValue[]): PsbtInput { + const input: PsbtInput = { + partialSigs: new Map(), + bip32: [], + unknown: [], + }; + for (const kv of entries) { + switch (kv.keyType) { + case PSBT_IN_UTXO: + requireEmptyKeyData(kv, "input utxo"); + input.utxo = parseUtxoValue(kv.value); + break; + case PSBT_IN_PARTIAL_SIG: { + const pubkey = requirePubkeyKeyData(kv, "partial signature"); + if (kv.value.length === 0) { + throw new PsbtError("INVALID_KEY", "empty partial signature"); + } + input.partialSigs.set(pubkey, kv.value); + break; + } + case PSBT_IN_SIGHASH: { + requireEmptyKeyData(kv, "sighash"); + if (kv.value.length !== 4) { + throw new PsbtError("INVALID_KEY", "sighash value must be 4 bytes"); + } + input.sighashType = new ByteReader(kv.value).readUInt32LE(); + break; + } + case PSBT_IN_REDEEM_SCRIPT: + requireEmptyKeyData(kv, "redeem script"); + input.redeemScript = bytesToHex(kv.value); + break; + case PSBT_IN_BIP32_DERIVATION: + requirePubkeyKeyData(kv, "bip32 derivation"); + input.bip32.push(kv); + break; + case PSBT_IN_FINAL_SCRIPTSIG: + requireEmptyKeyData(kv, "final scriptSig"); + input.finalScriptSig = bytesToHex(kv.value); + break; + default: + input.unknown.push(kv); + } + } + return input; +} + +/** + * Emit an input map in Radiant Core's order: utxo; then (only while not yet + * finalized) partial sigs, sighash, redeem script, bip32; then the final + * scriptSig; then unknowns. + */ +function writeInputMap(writer: ByteWriter, input: PsbtInput): void { + if (input.utxo) { + writeKeyValue(writer, { + keyType: PSBT_IN_UTXO, + keyData: new Uint8Array(0), + value: serializeUtxoValue(input.utxo), + }); + } + if (!input.finalScriptSig) { + for (const [pubkey, sig] of input.partialSigs) { + writeKeyValue(writer, { + keyType: PSBT_IN_PARTIAL_SIG, + keyData: hexToBytes(pubkey), + value: sig, + }); + } + if (input.sighashType !== undefined) { + const value = new ByteWriter(); + value.writeUInt32LE(input.sighashType); + writeKeyValue(writer, { + keyType: PSBT_IN_SIGHASH, + keyData: new Uint8Array(0), + value: value.toBytes(), + }); + } + if (input.redeemScript !== undefined) { + writeKeyValue(writer, { + keyType: PSBT_IN_REDEEM_SCRIPT, + keyData: new Uint8Array(0), + value: hexToBytes(input.redeemScript), + }); + } + for (const kv of input.bip32) writeKeyValue(writer, kv); + } + if (input.finalScriptSig !== undefined) { + writeKeyValue(writer, { + keyType: PSBT_IN_FINAL_SCRIPTSIG, + keyData: new Uint8Array(0), + value: hexToBytes(input.finalScriptSig), + }); + } + for (const kv of input.unknown) writeKeyValue(writer, kv); + writer.writeUInt8(0x00); +} + +/** Count of inputs/outputs in the unsigned tx, plus the scriptSig-empty check. */ +function inspectUnsignedTx(hex: string): { nInputs: number; nOutputs: number } { + let tx: ReturnType; + try { + tx = transactionFromHex(hex); + } catch (err) { + throw new PsbtError("INVALID_UNSIGNED_TX", `unparseable unsigned tx: ${err}`); + } + for (const input of tx.inputs) { + if (input.script && input.script.toHex() !== "") { + throw new PsbtError( + "UNSIGNED_TX_HAS_SCRIPTSIGS", + "unsigned tx must have empty scriptSigs" + ); + } + } + return { nInputs: tx.inputs.length, nOutputs: tx.outputs.length }; +} + +export function parsePsbt(bytes: Uint8Array): Psbt { + const reader = new ByteReader(bytes); + const magic = reader.readBytes(PSBT_MAGIC.length); + if (!PSBT_MAGIC.every((b, i) => magic[i] === b)) { + throw new PsbtError("INVALID_MAGIC", "not a PSBT (bad magic)"); + } + + const globals = readKeyValueMap(reader); + let unsignedTxHex: string | undefined; + const unknownGlobals: PsbtKeyValue[] = []; + for (const kv of globals) { + if (kv.keyType === PSBT_GLOBAL_UNSIGNED_TX) { + requireEmptyKeyData(kv, "global unsigned tx"); + if (kv.value.length > MAX_PSBT_TX_SIZE) { + throw new PsbtError("TX_TOO_LARGE", "unsigned tx exceeds size cap"); + } + unsignedTxHex = bytesToHex(kv.value); + } else { + unknownGlobals.push(kv); + } + } + if (unsignedTxHex === undefined) { + throw new PsbtError("MISSING_UNSIGNED_TX", "PSBT has no unsigned tx"); + } + const { nInputs, nOutputs } = inspectUnsignedTx(unsignedTxHex); + + const inputs: PsbtInput[] = []; + for (let i = 0; i < nInputs; i++) { + inputs.push(parseInputMap(readKeyValueMap(reader))); + } + const outputs: PsbtOutput[] = []; + for (let i = 0; i < nOutputs; i++) { + outputs.push({ entries: readKeyValueMap(reader) }); + } + if (!reader.eof()) { + throw new PsbtError("TRAILING_DATA", "trailing bytes after output maps"); + } + + return { unsignedTxHex, inputs, outputs, unknownGlobals }; +} + +export function serializePsbt(psbt: Psbt): Uint8Array { + const writer = new ByteWriter(); + writer.writeBytes(PSBT_MAGIC); + + writeKeyValue(writer, { + keyType: PSBT_GLOBAL_UNSIGNED_TX, + keyData: new Uint8Array(0), + value: hexToBytes(psbt.unsignedTxHex), + }); + for (const kv of psbt.unknownGlobals) writeKeyValue(writer, kv); + writer.writeUInt8(0x00); + + for (const input of psbt.inputs) writeInputMap(writer, input); + for (const output of psbt.outputs) { + for (const kv of output.entries) writeKeyValue(writer, kv); + writer.writeUInt8(0x00); + } + return writer.toBytes(); +} + +/** + * Decode a base64 (or base64url) PSBT. Whitespace is tolerated at the edges; + * the payload itself must be clean base64. + */ +export function psbtFromBase64(b64: string): Psbt { + const s = b64.trim(); + if (!s || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(s)) { + throw new PsbtError("INVALID_BASE64", "not base64"); + } + const normalized = s.replace(/-/g, "+").replace(/_/g, "/"); + const padded = + normalized + "=".repeat((4 - (normalized.length % 4)) % 4); + const bytes = Uint8Array.from(Buffer.from(padded, "base64")); + // Buffer silently truncates malformed base64; a length check catches it. + if (bytes.length !== Math.floor((padded.replace(/=/g, "").length * 3) / 4)) { + throw new PsbtError("INVALID_BASE64", "malformed base64"); + } + return parsePsbt(bytes); +} + +/** Standard (padded) base64, matching Radiant Core's `EncodeBase64`. */ +export function psbtToBase64(psbt: Psbt): string { + return Buffer.from(serializePsbt(psbt)).toString("base64"); +} + +/** The declared spent output for input `i`, if the PSBT carries one. */ +export function inputPrevout(psbt: Psbt, i: number): PsbtUtxo | undefined { + return psbt.inputs[i]?.utxo; +} diff --git a/packages/lib/src/psbt/sign.ts b/packages/lib/src/psbt/sign.ts new file mode 100644 index 0000000..27e3095 --- /dev/null +++ b/packages/lib/src/psbt/sign.ts @@ -0,0 +1,265 @@ +/** + * Signer / finalizer / extractor roles for the Radiant PSBT profile. + * + * Signing MUST go through `rjs.Transaction.Sighash.sign` (the same call + * `buildTx` uses): Radiant's FORKID sighash preimage carries an extra + * push-ref-aware `hashOutputHashes` field that radiantjs implements and a + * hand-rolled BIP-143 preimage would get wrong. + * + * v1 policy, mirroring Radiant Core's `SignPSBTInput` where it applies: + * - an input is only signable when its `utxo` (CTxOut) field is present; + * - only plain P2PKH inputs matching the provided key are signed; + * - FORKID is mandatory; SIGHASH_NONE is refused (outputs could be swapped + * after signing); ALL/SINGLE ± ANYONECANPAY are allowed; + * - transactions spending token-bearing UTXOs are refused outright unless + * explicitly overridden — co-signing one risks burning someone's tokens. + */ +import rjs from "@radiant-core/radiantjs"; +import { Buffer } from "buffer"; +import { bnFromValue, transactionFromHex } from "../rjsCompat"; +import { isTokenBearing, p2pkhScript, parseP2pkhScript } from "../script"; +import { PsbtError } from "./errors"; +import { Psbt, PsbtInput } from "./psbt"; + +const { PrivateKey, PublicKey, Script, Transaction, crypto } = rjs; + +const SIGHASH_ALL = 0x01; +const SIGHASH_NONE = 0x02; +const SIGHASH_SINGLE = 0x03; +const SIGHASH_FORKID = 0x40; +const SIGHASH_ANYONECANPAY = 0x80; + +export const DEFAULT_SIGHASH = SIGHASH_ALL | SIGHASH_FORKID; // 0x41 + +export const DEFAULT_ALLOWED_SIGHASHES: readonly number[] = [ + SIGHASH_ALL | SIGHASH_FORKID, + SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_ANYONECANPAY, + SIGHASH_SINGLE | SIGHASH_FORKID, + SIGHASH_SINGLE | SIGHASH_FORKID | SIGHASH_ANYONECANPAY, +]; + +export type SkipReason = + | "not-mine" + | "already-signed" + | "finalized" + | "no-prevout"; + +export type SignPsbtResult = { + /** A new Psbt object with our signatures added; the input is not mutated. */ + psbt: Psbt; + signedIndexes: number[]; + skipped: { index: number; reason: SkipReason }[]; +}; + +export type SignPsbtOptions = { + allowedSighashes?: readonly number[]; + /** Override the refusal to participate in txs spending token UTXOs. */ + allowTokenBearingInputs?: boolean; +}; + +/** + * Verify an existing partial signature actually validates for this input, + * rather than trusting a pubkey-hash match alone. A PSBT — from a + * multi-party combiner flow, or simply malformed/malicious — can carry + * garbage bytes in a signature slot that happens to claim our own pubkey; + * without this check that garbage would be silently treated as "already + * signed" (skipped in `signPsbt`) or assembled straight into a `finalScriptSig` + * (`finalizePsbt`), reporting a transaction as `complete` when it isn't + * validly signed at all. + */ +function isValidPartialSig( + tx: ReturnType, + index: number, + utxo: { script: string; value: bigint }, + pubkeyHex: string, + sigBytes: Uint8Array +): boolean { + try { + const sig = crypto.Signature.fromTxFormat(Buffer.from(sigBytes)); + return Transaction.Sighash.verify( + tx, + sig, + PublicKey.fromHex(pubkeyHex), + index, + Script.fromHex(utxo.script), + bnFromValue(utxo.value.toString()) + ); + } catch { + return false; + } +} + +function cloneInput(input: PsbtInput): PsbtInput { + return { + ...input, + partialSigs: new Map(input.partialSigs), + bip32: [...input.bip32], + unknown: [...input.unknown], + }; +} + +function clonePsbt(psbt: Psbt): Psbt { + return { + unsignedTxHex: psbt.unsignedTxHex, + inputs: psbt.inputs.map(cloneInput), + outputs: psbt.outputs.map((o) => ({ entries: [...o.entries] })), + unknownGlobals: [...psbt.unknownGlobals], + }; +} + +/** + * Sign every input the given key controls (utxo script === our P2PKH script). + * Throws on policy violations; inputs that are simply not ours are skipped. + */ +export function signPsbt( + psbt: Psbt, + wif: string, + opts?: SignPsbtOptions +): SignPsbtResult { + const allowed = opts?.allowedSighashes ?? DEFAULT_ALLOWED_SIGHASHES; + const privKey = PrivateKey.fromWIF(wif); + const pubkeyHex = privKey.toPublicKey().toString(); + const ownScript = p2pkhScript(privKey.toAddress().toString()); + + const tx = transactionFromHex(psbt.unsignedTxHex); + if (tx.inputs.length !== psbt.inputs.length) { + throw new PsbtError( + "INVALID_UNSIGNED_TX", + "input map count does not match unsigned tx" + ); + } + + if (!opts?.allowTokenBearingInputs) { + for (const [i, input] of psbt.inputs.entries()) { + if (input.utxo && isTokenBearing(input.utxo.script)) { + throw new PsbtError( + "TOKEN_BEARING_INPUT", + `input ${i} spends a token-bearing output` + ); + } + } + } + + const out = clonePsbt(psbt); + const signedIndexes: number[] = []; + const skipped: { index: number; reason: SkipReason }[] = []; + + out.inputs.forEach((input, index) => { + if (input.finalScriptSig !== undefined) { + skipped.push({ index, reason: "finalized" }); + return; + } + if (!input.utxo) { + skipped.push({ index, reason: "no-prevout" }); + return; + } + if (input.utxo.script !== ownScript) { + skipped.push({ index, reason: "not-mine" }); + return; + } + const existingSig = input.partialSigs.get(pubkeyHex); + if ( + existingSig && + isValidPartialSig(tx, index, input.utxo, pubkeyHex, existingSig) + ) { + skipped.push({ index, reason: "already-signed" }); + return; + } + // No existing signature, or an invalid one (garbage claiming our pubkey) + // — either way, produce a real one below, overwriting anything bogus. + + const sighash = input.sighashType ?? DEFAULT_SIGHASH; + if (!(sighash & SIGHASH_FORKID)) { + throw new PsbtError( + "MISSING_FORKID", + `input ${index} requests a sighash without SIGHASH_FORKID` + ); + } + if ((sighash & 0x1f) === SIGHASH_NONE || !allowed.includes(sighash)) { + throw new PsbtError( + "DISALLOWED_SIGHASH", + `input ${index} requests disallowed sighash 0x${sighash.toString(16)}` + ); + } + + const sig = Transaction.Sighash.sign( + tx, + privKey, + sighash, + index, + Script.fromHex(input.utxo.script), + // String form dodges bn.js's 2^53 safe-integer limit. + bnFromValue(input.utxo.value.toString()) + ); + input.partialSigs.set( + pubkeyHex, + Uint8Array.from( + Buffer.concat([sig.toBuffer(), Buffer.from([sighash & 0xff])]) + ) + ); + signedIndexes.push(index); + }); + + return { psbt: out, signedIndexes, skipped }; +} + +/** + * Finalize every P2PKH input that has a matching partial signature: + * `final_scriptSig = `, clearing the now-redundant + * signing fields (BIP-174 finalizer semantics). Inputs that cannot be + * finalized are left untouched. `complete` is true when every input carries a + * final scriptSig. + */ +export function finalizePsbt(psbt: Psbt): { psbt: Psbt; complete: boolean } { + const out = clonePsbt(psbt); + const tx = transactionFromHex(out.unsignedTxHex); + + out.inputs.forEach((input, index) => { + if (input.finalScriptSig !== undefined) return; + if (!input.utxo) return; + const { address: pkh } = parseP2pkhScript(input.utxo.script); + if (!pkh) return; // not P2PKH — some other finalizer's job + + for (const [pubkeyHex, sig] of input.partialSigs) { + const hash = crypto.Hash.sha256ripemd160(Buffer.from(pubkeyHex, "hex")); + if (Buffer.from(hash).toString("hex") !== pkh) continue; + // Confirm the signature actually validates before finalizing on it — + // a pubkey-hash match alone doesn't prove the bytes in this slot are a + // real signature (see `isValidPartialSig`). + if (!isValidPartialSig(tx, index, input.utxo, pubkeyHex, sig)) continue; + input.finalScriptSig = Script.empty() + .add(Buffer.from(sig)) + .add(Buffer.from(pubkeyHex, "hex")) + .toHex(); + input.partialSigs = new Map(); + input.sighashType = undefined; + input.redeemScript = undefined; + input.bip32 = []; + break; + } + }); + + const complete = out.inputs.every((i) => i.finalScriptSig !== undefined); + return { psbt: out, complete }; +} + +/** + * Extract the fully-signed network transaction (raw hex). Throws + * NOT_FINALIZED if any input still lacks a final scriptSig. + */ +export function extractTx(psbt: Psbt): string { + const tx = transactionFromHex(psbt.unsignedTxHex); + if (tx.inputs.length !== psbt.inputs.length) { + throw new PsbtError( + "INVALID_UNSIGNED_TX", + "input map count does not match unsigned tx" + ); + } + psbt.inputs.forEach((input, i) => { + if (input.finalScriptSig === undefined) { + throw new PsbtError("NOT_FINALIZED", `input ${i} is not finalized`); + } + tx.inputs[i].setScript(Script.fromHex(input.finalScriptSig)); + }); + return tx.toString(); +} diff --git a/packages/lib/src/swapOutputs.ts b/packages/lib/src/swapOutputs.ts index e32a4a8..a45208b 100644 --- a/packages/lib/src/swapOutputs.ts +++ b/packages/lib/src/swapOutputs.ts @@ -45,6 +45,13 @@ export type SwapCompletionOutputs = { * function only fixes their POSITION. */ royaltyOutputs?: UnfinalizedOutput[]; + /** + * Marketplace/platform fee payouts (e.g. a connect-driven `swap-accept- + * request`'s `feeRxd`/`feeAddress`) — a distinct concept from creator + * royalty, but positionally identical: index 2+, after royalties, never + * displacing the maker payment. + */ + platformFeeOutputs?: UnfinalizedOutput[]; /** * Outputs funding the asset the maker wants (token-for-token swaps), from * `fundFungible` / `fundNonFungible`. Appended last. @@ -56,7 +63,7 @@ export type SwapCompletionOutputs = { * Assemble swap-completion outputs in the only order the maker's signature * permits: * - * [ makerPayment, assetToTaker, ...royaltyOutputs, ...fundingOutputs ] + * [ makerPayment, assetToTaker, ...royaltyOutputs, ...platformFeeOutputs, ...fundingOutputs ] * * Change is appended by the caller AFTER `fundTx` computes it (funding needs * this output list first), so it always trails and never affects index 0. @@ -71,6 +78,10 @@ export function buildSwapCompletionOutputs( outputs.push(...opts.royaltyOutputs); } + if (opts.platformFeeOutputs?.length) { + outputs.push(...opts.platformFeeOutputs); + } + if (opts.fundingOutputs?.length) { outputs.push(...opts.fundingOutputs); } diff --git a/packages/lib/src/types/radiantjs.d.ts b/packages/lib/src/types/radiantjs.d.ts index 3e65350..3158794 100644 --- a/packages/lib/src/types/radiantjs.d.ts +++ b/packages/lib/src/types/radiantjs.d.ts @@ -50,6 +50,8 @@ declare module "@radiant-core/radiantjs" { // Sighash.sign — upstream uses `(...args: any[])` which loses // call-site type checking. Replace with the real signature. + // Sighash.verify — exists at runtime (lib/transaction/sighash.js) but is + // not declared upstream; used by the PSBT tests to check partial sigs. namespace Sighash { function sign( tx: Transaction, @@ -59,6 +61,25 @@ declare module "@radiant-core/radiantjs" { subscript: Script, satoshisBN: crypto.BN ): crypto.Signature; + function verify( + tx: Transaction, + signature: crypto.Signature, + publicKey: PublicKey, + inputIndex: number, + subscript: Script, + satoshisBN: crypto.BN + ): boolean; + } + } + + namespace crypto { + // Parse a transaction-format signature (DER ‖ trailing sighash byte), + // populating `nhashtype`. Ships at runtime, undeclared upstream. + interface Signature { + nhashtype: number; + } + namespace Signature { + function fromTxFormat(buf: Buffer): Signature; } } @@ -72,5 +93,9 @@ declare module "@radiant-core/radiantjs" { script: Script, network?: Networks.Network | string ): Address; + function fromPublicKeyHash( + hash: Buffer, + network?: Networks.Network | string + ): Address; } }