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; } }