feat(connect): unblock WizardConnect end-to-end + honest sign preview

Two prior sessions and this one collectively unblocked WizardConnect for
chipnet beta. Landed here:

Licence trap resolved:
- @wizardconnect/* is LGPL-3.0-or-later (verified against gitlab
  repo LICENSE.txt, README, Riften Labs announcement). "Proprietary"
  in `npm view` is a missing `license` field artefact, not a term.
- WizardConnect_reference.md §0/§3.3, README wallet table, wz-adapter.ts
  and wz-handler.ts headers corrected. Mainnet is no longer blocked;
  §6 of the integration guide lists the remaining LGPL §4 obligations.

Wrapper unwrap bug fixed (responder.ts:279-310):
- SignTransactionRequest.transaction is a WcSignTransactionRequest
  wrapper, not the tx. Old code passed the wrapper through as `tx`, so
  every WZ sign request threw "malformed request — missing tx.inputs"
  at signer.ts:136. Now unwrapped + hex-string variant decoded +
  broadcast/userPrompt forwarded. Regression tests added.

Sign-preview honesty (AppsScreen.tsx + preflight.ts):
- Replaced hardcoded "✓ no token UTXOs spent" / "bchtest:qp2…7lm" fake
  pre-flight block with real request-derived outputs, fee, and token
  flags. Renders dApp's `userPrompt` verbatim per protocol.
- Hides the block when the tx can't be decoded rather than lying about
  what was checked.

Dropped SDK events surfaced (wz-handler.ts + responder.ts + useConnect.ts):
- signCancelled + remoteDisconnect now flow through WzHandlerEventMap.
- Responder evicts pending WZ entries on cancel; new
  ResponderEvents.onSignRequestCancel clears the approval screen if the
  user was about to approve a withdrawn request.

WIF-restored wallets get an honest message (useConnect.ts):
- KeyStoreError('not-supported') from a WIF-restored wallet now logs a
  quiet console info line instead of a red "WizardConnect not supported
  by this build" banner. WC/CC remain available. Other failures still
  surface normally.

Definition of Done (see WIZARDCONNECT-INTEGRATION-GUIDE.md §11) items
§1–§4 landed here. §5–§9 handed off to a fresh session via
NEXT-SESSION-PROMPT.md — includes Reown decoupling, LGPL §4(d)(1)
chunk split, session re-hydrate, on-device test against Silent Mode
registrar (with Cashonize v0.9.0 as comparison implementation), and
ADR-0044 for the Nostr transport.

Tests: 162/162 passing. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-07-27 20:07:50 +02:00
parent d8f999effc
commit 6987591065
14 changed files with 1304 additions and 54 deletions

View file

@ -0,0 +1,210 @@
# Prompt for a fresh session — WizardConnect completion (§5–§9)
_Written 2026-07-27. Paste this whole file to a new session. Everything the
session needs is either in this prompt or referenced by path — do not read
back the compacted-summary transcript from the earlier session._
---
## Context
WizardConnect (Nostr NIP-17 transport, `hdwalletv1` protocol, LGPL-3.0-or-later)
is **already implemented + tested** in the Deviant wallet (`shells/app/`). A
prior session traced every anchor against the working tree and wrote a
definitive integration guide at
`Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md` — **read it
first, verbatim.** The Definition-of-Done in §11 of that guide is the target.
The previous session (this one) landed §1–§4 of that DoD:
1. Corrected the licence-Proprietary trap in `WizardConnect_reference.md`
§0/§3.3 (`Proprietary` → `LGPL-3.0-or-later`, with verified citations).
2. Replaced the hardcoded fake pre-flight block in `AppsScreen.tsx` with
real request-derived data (`shells/app/src/connect/preflight.ts` + a
rewritten `SigningPreview`). Renders `userPrompt` verbatim per protocol.
3. Surfaced `signCancelled` + `remoteDisconnect` from
`WalletConnectionManager` through `wz-handler.ts`; wired the responder
to evict pending WZ sign entries on cancel and forward a readable error
on remote disconnect. Added `onSignRequestCancel` to `ResponderEvents`
and threaded it through `useConnect.ts` so the approval screen clears
itself when the dApp withdraws the request.
4. Made the WIF-restored WZ-not-supported path honest: instead of a red
error banner, `useConnect.ts` catches `KeyStoreError('not-supported')`
specifically and logs a console `[connect]` info line explaining WC/CC
remain available. Any other failure still surfaces normally.
All 162 shell tests pass. `tsc --noEmit` is clean. Nothing is committed —
review the diff alongside the ~82 files of in-flight work already on
`design/dex-p2p-architecture`.
---
## Your remit — DoD items §5 through §9 of the guide
Do these in order. Each is scoped to be one commit.
### §5 — Decouple WZ from the Reown project ID
`shells/app/src/connect/useConnect.ts:63-76` (`getWalletKit()`) throws when
`VITE_WC_PROJECT_ID` is unset. `useConnect.ts:160` awaits `getWalletKit()`
**before anything else**, so a missing Reown ID kills the Nostr path too —
even though WizardConnect needs no Reown account at all. Refactor so:
- `wzHandler` initialises independently of `walletKit`.
- If `PROJECT_ID` is missing, WC/CC are dark but WZ still pairs.
- `ConnectState.missingProjectId` stays truthful; add a parallel
`ConnectState.wzReady` (or fold into `ready`) so the UI can say
"WalletConnect unavailable — Reown project ID missing; WizardConnect
still works" instead of a monolithic "not ready".
- Update `test/connect-responder-compat.test.ts` if the fake `WalletKit`
path changes. Add a new test for the WZ-only path.
### §6 — LGPL §4(d)(1) build compliance + attribution
`shells/app/vite.config.ts:91` has `manualChunks` rules for react, mainnet-js,
libauth, cashscript, qrcode — but no `@wizardconnect`. Fold in the
three-line rule from the guide §6 so `@wizardconnect/*` becomes its own
`vendor-wizardconnect` chunk (LGPL §4(d)(1) "suitable shared library
mechanism"). Then:
- Ship LGPL-3.0 and GPL-3.0 licence texts as assets (they are referenced
from LGPL-3.0 by transclusion — both are required). Put them under
`shells/app/public/licenses/`.
- Add an About / Licences screen entry (Settings tab is the current home
for build info; add there if there is nowhere better). Line: "This app
uses WizardConnect by Riften Labs (LGPL-3.0-or-later) — see /licenses/."
- Cross-reference the guide §6 in the commit message. **Do these before
any mainnet build.**
### §7.3 — Re-hydrate sessions on reload (affects WC + CC + WZ)
`shells/app/src/connect/responder.ts:126` keeps sessions in an in-memory
`Map`. Nothing calls `walletKit.getActiveSessions()` on init anywhere in
`shells/app/src/`, so WC sessions WalletKit restores from its own
IndexedDB never reappear in the responder's map after a reload. Same shape
of problem exists for WZ (the SDK persists completed sign sequences but
not connections; the responder must remember and re-pair).
Design:
- On `createResponder` construction, call `walletKit.getActiveSessions()`
and rebuild the `sessions` map + emit synthetic `onSessionProposal`-shaped
entries OR expose a `listSessions()` that returns the reconciled list.
- For WZ: connection state is not persisted by `@wizardconnect/wallet` —
either persist the URI list ourselves (localStorage) and reconnect on
init, OR document that WZ sessions are per-page-load (acceptable for v1,
ADR-worthy).
- Update `test/connect-responder-compat.test.ts` with a re-hydrate case.
This is the cross-cutting one — affects all three transports.
### §8 — On-device verification against Silent Mode registrar
**This step requires a phone + the user's participation** — write it up as
the last thing, ask the user to run it, and capture the outcome in a
snapshot. **Do not skip.** Nothing above has ever been exercised against a
real dApp.
Comparison implementation to reference during debugging: **Cashonize v0.9.0+**
(released 2026-07-25) is the second wallet to ship WizardConnect on
chipnet. If Deviant's approval flow misbehaves, pairing the same Silent
Mode registration URI to Cashonize tells you which side is wrong: two
wallets that disagree with the same dApp on the same request is a
protocol-boundary bug; one wallet disagreeing is a wallet-side bug. **Do
not use Paytaca as the comparison** — no chipnet support.
Full setup + expected behaviour + what to report is in the guide §8. Test
plan pinned there so it is not re-derived. Notable: **two approvals** on
first sign is a one-off self-send required by CashToken genesis (needs an
outpoint at index 0), not a bug — page states it up-front.
### §9 — ADR + wiki update
There is NO ADR for the Nostr transport in `wiki/decisions.md`. ADR-0043
predates it and still describes `classifyUri` as returning `'wc' | 'cc' | null`.
`wiki/wallet/connector.md:120` still lists "WizardConnect probe — pair the
shell with Paytaca" as an open question — that is wrong on both counts
(Paytaca has no chipnet support; the probe is done).
Write ADR-0044 (or the next free number — check `wiki/decisions.md`):
- Nostr transport as a parallel channel to WC2, deliberately not a WC2
extension (transport is genuinely different).
- Session lifecycle differences: no `session_proposal`, sessions are
manufactured from the first sign request at
`shells/app/src/connect/responder.ts:243-263`.
- SIGHASH_UTXOS (0x61) as a non-negotiable protocol requirement.
- LGPL §4 combined-work reasoning (why copyleft does not propagate).
- Chipnet-first rollout, mainnet gated on §6 chunk-split + attribution.
Update ADR-0043 to reference ADR-0044 and correct the `classifyUri` claim.
Update `wiki/wallet/connector.md` — replace the "probe with Paytaca"
open question with "validated on-device against Silent Mode registrar,
snapshot 0NNN" (whatever number §8 lands at). And update
`Parameters/WalletConnect/README.md` if any status rows drift.
---
## Traps to avoid (from the guide §9)
- **`wiz:` routing lives in TWO files.** `classifier.ts:71` AND
`qr/qr-uri.ts:55`. Deliberate — QR routing must not depend on the
connector module. Change both when adding new schemes.
- **`sighashType` applies to every input**, not per input. Don't try to
make it per-input.
- **`addressIndex` is `number` in `inputPaths` but `bigint` in `getPubkey`.**
Convert at the boundary.
- **`@wizardconnect/core` re-exports libauth.** Keep
`@bitauth/libauth@3.1.0-next.8` version-locked with the SDK. Two copies
of libauth in one bundle causes a nasty WASM init race.
- **`isOwnedInput` is exported but not called by `coSignTransaction`.**
Signer inlines an equivalent check with asymmetric behaviour: explicit
`inputPaths` mismatch = hard throw; unmapped foreign input = silent skip.
- **`getPublicKey` throws by design** (`wz-adapter.ts:139`). Don't "fix" it
— the manager never calls it at 0.2.2. If you upgrade the SDK, verify.
- **Two chat sessions may share this working tree.** Check
`shells/app/WALLET-COORDINATION.md` before editing wallet files.
---
## Verification
After every change:
```bash
cd shells/app
npx tsc --noEmit
npm test
```
Target: still `162 passed`, plus any new tests you add. Any regression
must be diagnosed, not skipped.
For §8 (on-device), verification is the user's report + a `snapshots/0NNN-*.md`
entry with `domain: wallet` frontmatter.
---
## Files loaded in this session's context (for reference)
- `Parameters/WalletConnect/WIZARDCONNECT-INTEGRATION-GUIDE.md` (the DoD source)
- `Parameters/WalletConnect/WizardConnect_reference.md` (licence fix landed;
§5 dApp-side is the reference the guide points at)
- `Parameters/WalletConnect/README.md` (routing rule + wallet table)
- `shells/app/src/connect/{wz-adapter,wz-handler,responder,useConnect,preflight,index,signer,classifier}.ts`
- `shells/app/src/screens/AppsScreen.tsx` (SigningPreview)
- `shells/app/src/connect/preflight.ts` (new — request-derived pre-flight)
- `src/wallet/keystore.ts` (HD interface + KeyStoreError codes)
---
## What NOT to do
- Do not re-litigate the "Proprietary vs LGPL" question — it is settled
(see the guide §2 + §6 with three-way citation).
- Do not add ambient `mkdir` / dir-exists checks; the memory dir is
managed by the harness (per your user-global instructions).
- Do not attempt on-device pairing yourself — the phone is the user's.
Prepare the code, then hand off to the user with clear instructions.
- Do not touch `dapp.entry.ts` unless §7.3 forces it — that file is used
by the Checkers session and any bump requires re-vendoring on their side.

View file

@ -8,7 +8,8 @@ reasons (the first one we integrated). Each has its own deep-dive file:
|---|---|---|
| [`WalletConnect_integration_reference.md`](./WalletConnect_integration_reference.md) | ✅ **Implemented + on-device validated** (snapshot 0057) | wc2-bch-bcr over WC2 relay — pass-through co-sign, the broadest BCH wallet coverage |
| [`CashConnect_reference.md`](./CashConnect_reference.md) | ✅ **Implemented (handshake, multi-protocol dispatch)** (snapshot 0152). Covenant co-sign blocked by V0 — routes through WC. **v-next `bch_executeAction` model is designed to fix this** (see doc §10). | **CashRPC** = the underlying protocol (transport-agnostic); **CashConnect** = CashRPC + WalletConnect v2. BCH community, @jimtendo proposal (Dec 2023 BCR forum), merged into Cashonize |
| [`WizardConnect_reference.md`](./WizardConnect_reference.md) | ⏳ **Deferred** until a wallet-required dApp uses `inputPaths` (Path A) or the Nostr vision layer ships (Path B) | Two-layer situation: **shipping** = WC2 extension adding `inputPaths` HD-key metadata (Paytaca); **vision** = Nostr NIP-17 E2E stack. Both from Riften Labs |
| [`WizardConnect_reference.md`](./WizardConnect_reference.md) | ⚠️ Research deep-dive, partly stale. **Its §0/§3.3 licence claim is WRONG** (it is LGPL-3.0, not proprietary) — see the guide below. | Nostr NIP-17 E2E transport + WC2-shape tx payload (`inputPaths` HD-key metadata). Riften Labs. Test wallet: **Cashonize v0.9.0+** (chipnet ✓); **Paytaca has no chipnet support**. |
| [**`WIZARDCONNECT-INTEGRATION-GUIDE.md`**](./WIZARDCONNECT-INTEGRATION-GUIDE.md) | ✅ **Implemented (snapshots 0193/0194) + blocking bug fixed 2026-07-27.** Remaining work + the on-device test are listed in the guide. | **Read this first for anything WizardConnect.** Status, verified `file:line` map, protocol reference, the resolved LGPL question, traps, and a completion checklist. |
The runtime that uses all of the above lives at
`shells/app/src/connect/` (the wallet-side responder, ADR-0043 + snapshot 0152)
@ -30,7 +31,7 @@ files in this folder are **reference copies + dApp-developer documentation**.
| **Wire encoding** | libauth `stringify`, JSON tree of EJSON markers | libauth `stringify`, single EJSON-string blob | Same as WC2 + `inputPaths` array | Nostr event |
| **Covenant co-sign?** | ✅ Yes (validated 0057) | ❌ No at V0 (structural — see CC §6). **v-next: yes** via template-bound actions | Should work — pass-through inherits WC2 covenant support | TBD |
| **Standard sends?** | ✅ | ✅ | ✅ | TBD |
| **Wallets** | Cashonize, Paytaca, others | Cashonize | **Paytaca** | (future) |
| **Wallets** | Cashonize, Paytaca, others | Cashonize | **Cashonize v0.9.0+** (chipnet ✓), Paytaca (**mainnet only — no chipnet**), Deviant | (future) |
| **dApps** | Cauldron, BCH Bull, Tote/Checkers, Flipstarter, … | (CC's audience overlaps with WC) | Anything attaching `inputPaths` to WC2 tx objects | **ParyonUSD** |
| **License (SDK)** | MIT | MIT | LGPL-3.0-or-later + CLA | Same |
| **Reown project ID needed?** | Yes | Yes (same one) | Yes (same one — extends WC2) | No |

View file

@ -0,0 +1,387 @@
# WizardConnect in Deviant — status, reference, and completion guide
**Last verified: 2026-07-27** against `@wizardconnect/core@0.2.4`,
`@wizardconnect/wallet@0.2.2`, and the official docs at
[docs.riftenlabs.com/wizardconnect](https://docs.riftenlabs.com/wizardconnect/).
Every `file:line` below was checked against the working tree on that date.
> **Start here.** The integration is **already built and now unblocked.** It is not
> a greenfield task. A blocking bug that made *every* sign request fail has been
> fixed and regression-tested; the licence question that blocked mainnet has been
> resolved. What remains is finishing work, listed in §7, plus one on-device test.
---
## 1. Status at a glance
| Piece | State |
|---|---|
| Nostr transport + protocol (`hdwalletv1`) | ✅ SDK does it — `@wizardconnect/wallet` |
| `WalletAdapter` over Deviant's KeyStore | ✅ `wz-adapter.ts` |
| Connection manager wrapper | ✅ `wz-handler.ts` |
| URI routing (`wiz:` → `'wz'`) | ✅ `classifier.ts` + `qr/qr-uri.ts` |
| Responder integration (sessions, sign events) | ✅ — **was broken, fixed 2026-07-27** |
| Signing with `SIGHASH_ALL\|FORKID\|UTXOS` (0x61) | ✅ `signer.ts` — already supported |
| HD paths `receive`/`change`/`defi` | ✅ `src/wallet/keystore.ts` |
| Approval UI | ⚠️ works, but shows **hardcoded fake** pre-flight data (§7.1) |
| Licence / mainnet | ✅ resolved — LGPL-3.0. One build change outstanding (§6) |
| On-device verification | ❌ **never done** — the one thing nobody can skip (§8) |
| ADR | ❌ none exists for the Nostr transport (§7.6) |
Tests: **162 passing**, `tsc --noEmit` clean (2026-07-27).
---
## 2. Why this stalled before
The earlier notes were half a picture for two reasons, both now removed:
1. **The licence was believed to block mainnet.** `wz-adapter.ts` and
`wz-handler.ts` carried standing warnings that `@wizardconnect/*` was
"proprietary-per-npm-view … mainnet ship needs explicit Riften Labs
permission." That was wrong — see §6.
2. **`WizardConnect_reference.md` §0/§3.3 still asserts "License: Proprietary …
NOT LGPL as previous version of this doc claimed."** That "correction" was
itself the error; the *earlier* version was right. **Do not trust §0/§3.3 of
that file on licensing.** (`README.md` in this folder has it right.)
---
## 3. The file map (verified anchors)
### Wallet side — `shells/app/src/connect/`
| File | What it is |
|---|---|
| `wz-adapter.ts:117` | `createWizardConnectAdapter(opts): WalletAdapter` |
| `wz-adapter.ts:89` | `buildXpubCache()` — pre-computes xpubs so the adapter's **sync** `getXpub` needs no I/O (KeyStore is async: biometrics, secure storage) |
| `wz-adapter.ts:130` | `getRelayPrivateKey(uri)` = `sha256(sha256(utf8(uri)))` — deterministic per URI |
| `wz-adapter.ts:139` | `getPublicKey()` deliberately **throws** — `WalletConnectionManager@0.2.2` never calls it |
| `wz-adapter.ts:148` | `getXpub(path)` — reads the cache |
| `wz-handler.ts:89` | `createWzHandler(opts): Promise<WzHandler>` |
| `wz-handler.ts:100` | wraps `new WalletConnectionManager(adapter)` |
| `wz-handler.ts:64` | the `WzHandler` interface — `pair`/`listConnections`/`approve`/`reject`/`disconnect`/`on`/`off` |
| `responder.ts:279` | **the unwrap** (see §4) |
| `responder.ts:255` | session chain, now from `allowedChains[0]` |
| `responder.ts:135-136` | `wzPending` map + synthetic request ids from `1_000_000` |
| `classifier.ts:33` | `export type Protocol = 'wc' \| 'cc' \| 'wz'` |
| `classifier.ts:71-73` | `classifyUri` — `wiz:` → `'wz'` after lowercasing |
| `signer.ts:51` | `WIZARDCONNECT_SIGHASH = allOutputs \| forkId \| utxos` (**0x61**) |
| `signer.ts:57` | `ACCEPTED_SIGHASH = { 0x41, 0x61 }` — anything else throws |
| `signer.ts:131` | `coSignTransaction(input)` |
| `signer.ts:136` | the "malformed request" throw the bug used to hit |
| `useConnect.ts:192` | conditional `createWzHandler(...)` |
| `useConnect.ts:331` | passes `sighashType: WIZARDCONNECT_SIGHASH` |
| `qr/qr-uri.ts:55` | **duplicate** `wiz:` routing — deliberately independent of the connector module. **Any new scheme must be added in both places.** |
### Core — `src/wallet/keystore.ts` (compiled to `dist/`, aliased `@core`)
| Anchor | What |
|---|---|
| `keystore.ts:265` | `HD_PATH_BRANCH = { receive: 0, change: 1, defi: 7 }` |
| `keystore.ts:272` | `BCH_COIN_TYPE = 145` |
| `keystore.ts:276` | `bip44PathFor(pathName, addressIndex, account = 0)` → `m/44'/145'/0'/<branch>/<index>` |
| `keystore.ts:291` | `bip44BranchPathFor(pathName, account = 0)` → `m/44'/145'/0'/<branch>` — the depth `exportXpub` serialises at |
| `keystore.ts:214` | `supportedPathNames()` — sync |
| `keystore.ts:226` | `deriveChildKey(pathName, addressIndex)` |
| `keystore.ts:247` | `exportXpub(pathName)` — branch-level xpub |
Three implementations, all complete: `WebKeyStore`, `CapacitorKeyStore`,
`MultiKeyStore` (derives from the **primary** wallet's seed only).
### UI — `shells/app/src/screens/AppsScreen.tsx`
`SigningPreview` at **:674** is the approve screen; handlers `approveSign` :230 /
`rejectSign` :239; router :258; QR entry :532.
---
## 4. The bug that was fixed (know this before touching `responder.ts`)
`SignTransactionRequest.transaction` **is not a transaction.** It is a
`WcSignTransactionRequest` wrapper from `@bch-wc2/interfaces`:
```ts
interface WcSignTransactionRequest {
transaction: Transaction | string; // ← the actual tx (object OR hex)
sourceOutputs: WcSourceOutput[];
broadcast?: boolean;
userPrompt?: string;
}
```
`sequence` and `inputPaths` live on the **outer** message; `sourceOutputs`,
`broadcast` and `userPrompt` live **inside** the wrapper. The old code assigned
the whole wrapper as `request.transaction` while simultaneously reaching *into*
it for `sourceOutputs` — so `tx.inputs` was `undefined` and `coSignTransaction`
threw *"malformed request — missing tx.inputs or sourceOutputs"* (`signer.ts:136`)
on **every** WizardConnect request. Nothing could ever have been signed.
Now, at `responder.ts:279-310`, the responder:
1. unwraps to the inner transaction;
2. **decodes the hex-string variant** (`Transaction | string` — the signer cannot
decode, so normalise here) and rejects undecodable input instead of surfacing
it for approval;
3. forwards `broadcast` and `userPrompt` (previously dropped, which is why every
prompt read a generic "Approve transaction");
4. takes the session chain from `allowedChains[0]`.
Regression tests: `test/connect-responder-compat.test.ts`, the four tests after
`// ── Regression: the wrapper must be unwrapped ──`. They were verified to
**fail against the old code** — reverting the mapping breaks three of them.
**Why the old tests missed it:** they asserted only `sessionId` / `id` / `kind`
and never looked inside `request`. When adding coverage here, assert on the
payload, not just the envelope.
---
## 5. Protocol reference (authoritative — do not re-derive)
Pinned against the shipped packages and the official docs.
| Question | Answer |
|---|---|
| Protocol name | `hdwalletv1` |
| Transport | Nostr WebSocket relay — **not** WalletConnect |
| Default relays | `wss://relay.riften.net:443` **and** `wss://relay.cauldron.quest:443` |
| Encryption | NIP-17 gift-wrap (kind 14 rumor → 13 seal → 1059 wrap). Relay sees ciphertext only |
| URI scheme | `wiz://?p=<pubkey>&s=<secret>`, plus QR-alphanumeric `WIZ://%3FP%3D…%26S%3D…`. Both start `wiz:` once lowercased |
| Custom relay | `explicitRelayUrls`; when set, **only** that relay is used |
| Reconnect | fixed `reconnectInterval` (default 5000 ms), no backoff |
| Chunking | messages > NIP-44's 65 535-byte plaintext cap are split into `ChunkMessage`s (~30 000 B each) when both peers advertise `chunk` |
| Handshake | `dapp_ready` ⇄ `wallet_ready`; wallet delivers `PathXpub[]`, dApp derives addresses locally |
| Path names → branch | `receive`→0, `change`→1, `defi`→7 (Cauldron) |
| xpub depth | at the **branch** node `m/44'/145'/0'/<branch>`; deriving child `n` equals `m/44'/145'/0'/<branch>/n` |
| `inputPaths` | `[inputIndex, pathName, addressIndex][]` — `addressIndex` is a **`number`** here, but `bigint` in `getPubkey`. Convert at the boundary |
| Sign response | `{ action, sequence, signedTransaction, error?, time }` — **no txid** |
| Who broadcasts | **the dApp.** The wallet SDK has no broadcast step |
| Message actions | `dapp_ready`, `wallet_ready`, `sign_transaction_request`, `sign_transaction_response`, `sign_cancel`, `disconnect`, `chunk`, `ping`, `pong` |
| Message signing | **not supported** — `hdwalletv1` is transaction-signing only. Fall through to WC2 for `bch_signMessage` |
| Session persistence | wallet side persists only *completed sign sequences*, not connections |
| Replay defence | every message carries `time`; older-than-last-processed is discarded |
### The one non-negotiable
> "Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID |
> SIGHASH_UTXOS` and MUST reject any request that would require different flags."
That is **0x61**, not WC2's 0x41. `SIGHASH_UTXOS` is what makes the `inputPaths`
trust model safe: it commits to the exact set of UTXOs spent, so an input cannot
be substituted after signing. Already handled — `signer.ts:51`, passed at
`useConnect.ts:331`.
### Docs vs. shipped package — one real divergence
| | Published docs | `@wizardconnect/wallet@0.2.2` types |
|---|---|---|
| `getRelayPrivateKey` | `(): Uint8Array`, *"stability across restarts is not required"* | `(uri: string): Uint8Array`, *"should be deterministic per URI"* so reconnects keep the same Nostr identity and different dApps cannot correlate you |
**The package is what calls it.** Deviant already implements the URI-keyed form
(`wz-adapter.ts:130`) — correct; leave it.
---
## 6. Licence — RESOLVED, one task outstanding
`@wizardconnect/{core,wallet,dapp}` are **LGPL-3.0-or-later**, free for
commercial and non-commercial use. `npm view` renders "Proprietary" **only**
because the packages omit the `license` field and exclude `LICENSE.txt` from
their published `files`. Confirmed three independent ways:
1. Repo-root `LICENSE.txt` at `gitlab.com/riftenlabs/lib/wizardconnect` —
verbatim LGPL-3.0, 7652 bytes, **no additional terms appended**.
2. Repo `README.md`: *"LGPL-3.0-or-later. Free for commercial and non-commercial
use. Modifications to the library must be released under the same license."*
3. Riften Labs' own launch announcement: *"Open source (LGPL)"*.
**Mainnet is no longer blocked.** The headers in `wz-adapter.ts` and
`wz-handler.ts` have been corrected.
We *link* the library and do not modify it, so this is LGPL **§4 Combined Work**
and copyleft does **not** propagate to Deviant's own code. Obligations:
- **§4(a)** — say in-app that WizardConnect is used and is LGPL-covered
(About/Licences screen).
- **§4(b)(c)** — ship copies of **LGPL-3.0 and GPL-3.0** with the app.
- **§4(d)** — let a user substitute their own build. Easiest is **§4(d)(1)**: a
"suitable shared library mechanism" — keep `@wizardconnect/*` as its own chunk
rather than inlined into a bundle with our code.
### ⚠️ Outstanding build task
`shells/app/vite.config.ts:91` has `manualChunks` rules for react, mainnet-js,
libauth, cashscript and qrcode — but **none for `@wizardconnect`**, so it is
currently folded into the app chunk. Add:
```ts
if (id.includes('node_modules/@wizardconnect')) {
// LGPL-3.0 §4(d)(1): keep the library a separately replaceable chunk.
return 'vendor-wizardconnect';
}
```
Then add the two licence texts to the app's assets and a credit line to the
About screen. Do this **before** any mainnet build.
Full analysis: SilentMode `Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md`.
---
## 7. Remaining work, in the order worth doing it
### 7.1 Replace the fake pre-flight block *(highest value — it is misleading)*
`AppsScreen.tsx:708-714` renders a **hardcoded** "Pre-flight checks" section:
literal `✓ no token UTXOs spent`, `✓ within normal range`, and a hardcoded
recipient `bchtest:qp2…7lm`. None of it is computed from the request.
This is worse than showing nothing: it tells a user their transaction was checked
when it was not, on the one screen where they decide whether to sign. Derive
these from `req.request.transaction.outputs` / `sourceOutputs`, or delete the
block. Also render `userPrompt` (now forwarded) verbatim — the protocol expects
the wallet to show the dApp's own wording.
### 7.2 Surface the SDK events that are dropped
`wz-handler.ts:100-104` re-emits only `pendingSignRequest` and
`connectionsChanged`. `WalletConnectionManager` also emits
`connectionStatusChanged`, `remoteDisconnect`, `signCancelled`, and `message`.
Without `signCancelled` the UI can leave a stale approval on screen after the
dApp has cancelled — the user then approves something nobody is waiting for.
### 7.3 Re-hydrate sessions on reload *(affects WC and CC too)*
`responder.ts:126` keeps sessions in an in-memory `Map`. Nothing calls
`walletKit.getActiveSessions()` anywhere in `src/`, so WC sessions WalletKit
restores from its own IndexedDB never reappear in the responder's map after a
reload. WZ has the same shape of problem (the SDK persists only completed sign
sequences).
### 7.4 Fix the misleading WIF-restored-wallet path
`useConnect.ts:190` gates WZ on `keyStore.supportedPathNames()` including
`receive`/`change`/`defi`. That array is **static**, so a WIF-restored wallet
passes the gate, then fails inside `buildXpubCache` when `exportXpub` throws
`'not-supported'`. The user is told *"WizardConnect not supported by this build"*
when the truth is *"this wallet was imported from a WIF and has no HD tree."*
### 7.5 Decouple WZ from the Reown project ID
`useConnect.ts:160` awaits `getWalletKit()` before anything else, and
`getWalletKit` throws when `VITE_WC_PROJECT_ID` is unset (`useConnect.ts:63-68`).
So a missing Reown ID kills the **Nostr** path too — even though WizardConnect
needs no Reown account at all. Make WZ initialise independently of WalletKit.
### 7.6 Write the ADR + refresh the wiki
There is **no ADR for the Nostr transport**. ADR-0043 (`wiki/decisions.md`)
predates it and still describes `classifyUri` as returning `'wc' | 'cc' | null`.
`wiki/wallet/connector.md:120` still lists "WizardConnect probe — pair the shell
with Paytaca" as an open question. Two notes for whoever writes it:
- **Paytaca cannot be the test wallet — it has no chipnet support.** Use
**Cashonize v0.9.0+** (2026-07-25 added WizardConnect; supports chipnet).
- The transport is Nostr, so several WC2 assumptions do not apply: there is no
`session_proposal` (pairing is unilateral — the responder *manufactures* a
session on the first sign request, `responder.ts:243-259`), and there are no
per-request ids (hence the synthetic counter at `responder.ts:136`).
### 7.7 Relay policy
`@wizardconnect/core` hardcodes the two default relays. Deviant supplies no
override. Consider exposing `explicitRelayUrls` in settings — running your own
relay is one of the protocol's selling points.
---
## 8. On-device verification (the step nobody can skip)
Nothing here has ever been exercised against a real dApp. There is now a live
counterpart to test against: the Silent Mode name registrar, which speaks
WizardConnect as a dApp and is chipnet-only.
**Setup**
1. Deviant on **chipnet** with an HD wallet (not WIF-restored) and a few test coins.
2. Open the Silent Mode register page (`silentmode.st/register.html`, or serve
`D:\Dev\SilentMode\site` locally).
3. Search a free name → *Connect a wallet — WizardConnect* → scan/paste the
`wiz://` URI into Deviant's QR scanner.
**What should happen**
- `wallet_ready` delivers three xpubs; the dApp derives addresses locally and
reads your balance with no further round-trips.
- You are asked for **one** approval — or **two** if your wallet holds no coin at
output index 0. That second one is a one-off self-send, not a fee: CashTokens
can only create a token category in a transaction that spends an outpoint at
index 0. The page states which before you commit.
- Approving mints a **name certificate** to your own address. The dApp
broadcasts, not the wallet.
**What to report back**
- Did the wallet accept `inputPaths`, and derive the right child key per input?
- Did it sign with **0x61**? (`signer.ts` should; confirm end-to-end.)
- Did `userPrompt` render verbatim on the approval screen?
- Did the certificate land at *your* address? Check with
`node Argus/src/lib/resolve.js <name>` in SilentMode, or navigate.st.
**A cheap first smoke test** that needs no dApp: pair, and confirm three xpubs
reach the dApp and derive the addresses you expect. That alone exercises the
relay, key exchange, NIP-17 wrap/unwrap, and `buildXpubCache`.
---
## 9. Traps
- **`wiz:` routing lives in two files.** `classifier.ts:71` *and* `qr/qr-uri.ts:55`.
Deliberate (QR routing must not depend on the connector module) — change both.
- **`isOwnedInput` is exported but `coSignTransaction` does not call it.** It
inlines an equivalent check at `signer.ts:207`, with asymmetric behaviour worth
knowing: an explicit `inputPaths` mapping that does not match the source output
is a **hard throw** that aborts the whole request; an unmapped foreign input is
**silently skipped**. `isOwnedInput` is used only by tests.
- **`sighashType` applies to every input**, not per input.
- **`getPublicKey` throws by design** (`wz-adapter.ts:139`) — the manager never
calls it at 0.2.2. If you upgrade the SDK, check that assumption first.
- **The tx may arrive as hex.** Handled at `responder.ts:290` now; do not remove.
- **`addressIndex` is `number` in `inputPaths` but `bigint` in `getPubkey`.**
- **`@wizardconnect/core` re-exports libauth symbols.** Deviant's app shell has
`@bitauth/libauth@3.1.0-next.8` — the same version the SDK expects. Keep them
aligned; two copies of libauth in one bundle causes a genuinely nasty
initialisation race (its WASM crypto uses top-level await, and a second copy
can hand out `undefined` for `secp256k1` on cold load).
---
## 10. Cross-references
- `README.md` (this folder) — protocol comparison table. Licence row is correct here.
- `WizardConnect_reference.md` — deep dive. **§0/§3.3 licensing is wrong** (§2 above).
- `WalletConnect_integration_reference.md` — WC2. §5 wire format and §6.4 signing
discipline apply to WizardConnect too; the tx payload is the same WC2 object.
- `CashConnect_reference.md` — §6 explains why wallet-orchestrated protocols cannot
carry structure-strict transactions. Same reason CashConnect cannot mint a name
certificate, while WizardConnect can: pass-through vs. orchestration.
- Official docs: [protocol](https://docs.riftenlabs.com/wizardconnect/protocol/) ·
[wallet](https://docs.riftenlabs.com/wizardconnect/wallet/) ·
[transport](https://docs.riftenlabs.com/wizardconnect/transport/) ·
[pubkey derivation](https://docs.riftenlabs.com/wizardconnect/pubkey-derivation/) ·
[dapp](https://docs.riftenlabs.com/wizardconnect/dapp/)
- The dApp counterpart: SilentMode `Argus/src/lib/connect/wizardconnect.js`, and
`Decentralized.DNS/REGISTRATION-STATUS.md` for how the registration flow uses it.
---
## 11. Definition of done
- [ ] A registration signed on-device through Deviant, certificate at the user's
own address, name resolving from the chain (§8).
- [ ] Approval screen shows real, request-derived data and the dApp's `userPrompt`
(§7.1).
- [ ] `signCancelled` and `remoteDisconnect` handled in the UI (§7.2).
- [ ] Sessions survive an app reload (§7.3).
- [ ] WIF-restored wallets get an honest message (§7.4).
- [ ] WZ works without a Reown project ID (§7.5).
- [ ] `vendor-wizardconnect` chunk + licence texts + About-screen credit (§6).
- [ ] ADR written; ADR-0043 and `wiki/wallet/connector.md` updated (§7.6).
- [ ] `WizardConnect_reference.md` §0/§3.3 licence claim corrected (§2).

View file

@ -6,6 +6,19 @@ part of this doc. Reference for evaluating it as a third Apps-tab protocol in
the Deviant wallet alongside WalletConnect (`WalletConnect_integration_reference.md`)
and CashConnect (`CashConnect_reference.md`). **Not yet implemented in Deviant.**_
> **⏱ Update 2026-07-25 — adoption has moved; older "Paytaca-only" facts below are stale.**
> - **Cashonize v0.9.0** (released 2026-07-25, desktop Win/Linux + Android) **added
> WizardConnect** — so wallet support is **no longer Paytaca-only**. The same
> release ships an **upgraded CashConnect "now over Nostr"** and a unified
> sessions view for all three connectors. Source: Cashonize v0.9.0 announcement
> (TradingView/CoinMarketCal, 25 Jul 2026).
> - **Broader adoption reported** (multiple wallets + dApps beyond Paytaca) but
> **not yet independently verified per-wallet/per-dApp here** — re-pin against
> the SDK + each wallet's release notes when confirming. Treat the §0/§0a tables
> below as the 2026-06-29 source-pinned snapshot, corrected by this note.
> - Notable second-order fact: **CashConnect moving to Nostr transport** narrows
> the WC2-vs-CC-vs-WZ distinction — two of the three now ride Nostr.
**Sources (both partially describe the same thing — pinned against the
installed source 2026-06-29):**
- <https://docs.riftenlabs.com/wizardconnect/> — describes the **transport
@ -61,12 +74,12 @@ client, not extend the existing WC handler with `inputPaths` support.
| Sign-tx payload | `SignTransactionRequest.transaction: WcSignTransactionRequest` (imported from `@bch-wc2/interfaces`) + `inputPaths: [inputIndex, pathName, addressIndex][]` |
| Message actions | `dapp_ready`, `wallet_ready`, `sign_transaction_request`, `sign_transaction_response`, `sign_cancel`, `disconnect`, `chunk`, `ping`, `pong` |
| Reconnection | Auto — both sides reconnect independently (per Riften Labs docs) |
| Wallet support | **Paytaca** (reference wallet) |
| dApp support | **Cauldron** (per docs.riftenlabs.com/cauldron/), **ParyonUSD** |
| Wallet support | **Paytaca** (reference wallet) + **Cashonize v0.9.0** (added 2026-07-25). No longer Paytaca-only; broader adoption reported, verify per-wallet. |
| dApp support | **Cauldron** (per docs.riftenlabs.com/cauldron/), **ParyonUSD** — 2026-06-29 snapshot; likely broader now (unverified) |
| SDK packages | `@wizardconnect/core@0.2.4`, `@wizardconnect/wallet@0.2.2`, `@wizardconnect/dapp@0.2.2`, `@wizardconnect/react@?` |
| Dependencies of core | `@bch-wc2/interfaces`, `nostr-tools`, `@bitauth/libauth`, `eventemitter3`, `isomorphic-ws`, `lossless-json`, `ws` |
| License | **Proprietary** (per `npm view @wizardconnect/core`) — NOT LGPL as previous version of this doc claimed |
| Status in Deviant | **Not yet implemented.** Adapter design in §4 below. |
| License | **LGPL-3.0-or-later** — free for commercial + non-commercial use. `npm view` shows "Proprietary" only because the packages omit the `license` field + exclude `LICENSE.txt` from `files`. Verified 2026-07-27 against `gitlab.com/riftenlabs/lib/wizardconnect` `LICENSE.txt` (verbatim LGPL, no additional terms), repo README, and Riften Labs' launch announcement. **Correction to earlier "Proprietary … NOT LGPL" claim in this doc — that "correction" was itself the error.** See `WIZARDCONNECT-INTEGRATION-GUIDE.md` §6 for the LGPL §4 obligations. |
| Status in Deviant | **Implemented + fixed.** Adapter, handler, responder wiring, and unwrap bug fix all in `shells/app/src/connect/`. On-device pairing pending — see `WIZARDCONNECT-INTEGRATION-GUIDE.md` §8. |
## 0b. What CashScript itself says (verbatim summary)
@ -122,7 +135,7 @@ dApps to reason about purpose-of-address.
## 2. SDK packages
Four packages (**Proprietary license** — corrected 2026-06-29):
Four packages (**LGPL-3.0-or-later** — corrected 2026-07-27; see §0 table):
| Package | Role |
|---|---|
@ -179,11 +192,19 @@ material a session may see.
### 3.3 License implications
**Proprietary.** Consuming the packages as a dependency is legally standard
provided the terms allow (verify with npm-view of the LICENSE field before
adopting — proprietary bundles sometimes have restrictions on production
use, redistribution, or embedded shipping). The Deviant wallet is
open-source; check the SDK's actual LICENSE file if adopting.
**LGPL-3.0-or-later** (verified 2026-07-27 — see §0 table). Free for
commercial + non-commercial use. We *link* the library and do not modify
it, so this is LGPL **§4 Combined Work** — copyleft does **not** propagate
to Deviant's own code. Obligations:
- **§4(a)** — attribute WizardConnect + LGPL in the About/Licences screen.
- **§4(b)(c)** — ship copies of LGPL-3.0 and GPL-3.0 with the app.
- **§4(d)(1)** — keep `@wizardconnect/*` a separately replaceable chunk
(add `manualChunks` rule in `vite.config.ts`).
Full checklist in `WIZARDCONNECT-INTEGRATION-GUIDE.md` §6. **Mainnet is
no longer blocked by licensing** (was previously flagged as blocked; that
flag was based on the incorrect Proprietary reading).
---
@ -295,7 +316,204 @@ its swap + LP flows genuinely need HD-key-per-input mapping.
---
## 5. Why it's still interesting to track
## 5. dApp-side integration reference (from docs.riftenlabs.com/wizardconnect/dapp/)
The reference above (§4) covers the **wallet-side** SDK — the surface Deviant
implements to serve WizardConnect dApps. This section documents the
**dApp-side** SDK — the surface a Deviant-facing dApp (Cauldron, or a
Deviant-authored dApp like the tote) implements to pair with wallets.
Pinned against the same package generation (`@wizardconnect/dapp@0.2.2`).
### 5.1 `DappConnectionManager` — the primary class
```ts
constructor(
dappName?: string,
dappIcon?: string,
options?: { session?: DappSessionOptions | false; },
)
```
Manages **one dApp↔wallet session** — handshake, xpub storage, sign-request
round-trips. Extends `EventEmitter`.
### 5.2 Methods (grouped by purpose)
**Connection lifecycle:**
- `updateConnection(client: RelayClient | null, status: RelayStatus): void`
— called from the relay callback (`initiateDappRelay`), keeps the manager's
internal relay-status view in sync.
- `attachRelay(relay)` — bind the initialised relay to the manager.
- `isWalletDiscovered(): boolean` — true once the wallet's `wallet_ready`
message has been received. Used to gate address-derivation calls.
- `sendDisconnect(message?: string): Promise<void>` — courtesy disconnect.
- `clearStoredSession(): void` — wipes the persisted session.
**Sign requests (two levels):**
- `signTransaction(request, options?): Promise<SignTransactionResponse>` —
**the recommended high-level API.** Auto-fills `action`, `sequence`,
`time`. Accepts `options.signal` for `AbortController` cancellation.
- `sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>`
— low-level. Caller sets `sequence` (via `nextSequence()`) and `time`.
- `sendSignCancel(sequence: number, reason?: string): Promise<void>` —
cancel an in-flight request by sequence number.
- `nextSequence(): number` — get the next monotonic sequence.
**Address derivation (via delegated `pubkeyState`):**
- `getPubkey(childIndex: number, index: bigint): Uint8Array | undefined`
- `getPubkeys(childIndex: number): Map<bigint, Uint8Array>`
- `getAddressIndex(childIndex: number): bigint` / `setAddressIndex(...)`
- `getIndexToUse(childIndex, options?): bigint` — smart index selection
(dApps often want "next unused address"; this handles the bookkeeping).
- `getIndexRange(childIndex): { min?: bigint; max?: bigint }`
- `getXpubNode(childIndex): HdPublicNodeValid | undefined`
`childIndex` = the enum from `@wizardconnect/wallet` (`0` receive, `1`
change, `7` defi/Cauldron). Same numbers as `bip44BranchPathFor` in
Deviant's `src/wallet/keystore.ts`.
**Session cache:**
- `getSessionPaths(): PathXpub[]`
- `restoreSessionPaths(paths: PathXpub[]): void`
### 5.3 Events
- `walletready` — fires when the wallet sends `wallet_ready` (xpubs
available). Payload includes `wallet_name` and the `PathXpub[]` list.
- `messagesent` / `messagereceived` — protocol-level events for logging /
debugging.
- `disconnect` — wallet gone or protocol mismatch. Handler receives
`(reason: DisconnectReason, message?: string)`.
### 5.4 Connection lifecycle (verbatim code from docs)
```ts
// 1. Construct + initiate relay
const dappMgr = new DappConnectionManager('My Dapp', 'https://…/icon.png');
const relay = initiateDappRelay((payload) => {
dappMgr.updateConnection(payload.client, payload.status);
});
dappMgr.attachRelay(relay);
console.log('Scan this URI:', relay.uri); // wiz://?p=…&s=…
// 2. Wait for wallet
dappMgr.on('walletready', (msg) => {
console.log('Wallet:', msg.wallet_name);
// xpubs now available — dApp can derive addresses locally
});
// 3. Derive addresses (any time after walletready)
const RECEIVE = 0;
const pubkey = dappMgr.getPubkey(RECEIVE, 0n);
// 4. Sign a transaction (high-level, recommended)
const response = await dappMgr.signTransaction({
transaction: {
transaction: txHex,
sourceOutputs,
userPrompt: 'Confirm swap',
broadcast: true,
},
inputPaths: [[0, 'receive', 0], [1, 'defi', 5]],
});
console.log('Signed tx:', response.signedTransaction);
// 5. Disconnect
dappMgr.clearStoredSession();
await dappMgr.sendDisconnect('user closed the tab');
relay.cleanup();
```
### 5.5 Cancellation (AbortController)
```ts
const controller = new AbortController();
try {
const response = await dappMgr.signTransaction(
{ transaction: {...}, inputPaths: [...] },
{ signal: controller.signal },
);
} catch (err) {
if (err.name === 'AbortError') console.log('Cancelled');
}
```
Under the hood: `signTransaction` sends the request, then races the
promise against the abort signal. On abort, calls `sendSignCancel(seq)`.
### 5.6 Session persistence
**Enabled by default.** The manager auto-restores xpub paths at construction
and saves wallet metadata + xpubs on `wallet_ready`.
Config options:
| Option | Effect |
|---|---|
| `session` omitted | Default — localStorage, key `wizardconnect-session` |
| `session: { key: 'my-app-session' }` | Custom localStorage key |
| `session: { storage: myCustomStorage }` | Custom storage backend (must expose `getItem` / `setItem` / `removeItem`) |
| `session: false` | Disable persistence entirely |
Manual access if you need to interoperate with the persistence layer:
```ts
const session = loadSession(); // standalone loader
if (session?.walletPublicKey) {
const relay = initiateDappRelay(callback, {
existingCredentials: session,
});
}
```
### 5.7 Reconnection semantics
When the relay's status transitions to `'connected'`, the manager sends a
fresh `dapp_ready`. `walletDiscovered` state **persists across reconnects**.
Any pending sign requests are automatically **re-sent on `wallet_ready`**,
keeping the caller's promise valid — the dApp doesn't need to manually
retry sign requests during a transient disconnect.
### 5.8 React integration (`@wizardconnect/react`)
Hook: `useWizardConnect({ dappName })` returns `{ manager, ... }`.
```tsx
const wc = useWizardConnect({ dappName: 'My Dapp' });
useEffect(() => {
if (!wc.manager) return;
const wallet = new MyWalletAdapter(wc.manager);
// dispatch to store …
}, [wc.manager]);
```
The hook manages session persistence, auto-reconnect, and cleanup
automatically. Suitable when a Deviant-authored dApp is built as a React
SPA.
### 5.9 Implications for Deviant proposer (`src/connect/proposer.ts`)
The Deviant proposer library today wraps WalletConnect v2's dApp-side
sign-client. **If a Deviant-authored dApp (tote, escrow, etc.) needs to
support WizardConnect wallets**, the natural extension pattern is:
- Add a `WzSession` class in the proposer that wraps `DappConnectionManager`
(mirrors the existing `WcSession` / `CcSession` pattern in
`spike/wc-connect/`).
- Expose `signTransaction(request, options?)` on the session; the caller
builds a WC2-shape tx + `inputPaths` list and gets a signed hex back.
- Reuse Deviant's `WcTransactionObject` builder — the `transaction` +
`sourceOutputs` payload is identical between WC and WizardConnect.
Only the transport wrapper differs.
Checkers explicitly asked about this in
`Checkers-response-to-instructions.md` §Ask 2. If the Deviant proposer
grows a WzSession, Checkers can vendor it and skip reimplementing.
---
## 6. Why it's still interesting to track
- The **xpub-handshake-then-derive-locally** model is genuinely better
than WC2's per-address pattern. If the BCH dApp ecosystem migrates
@ -308,7 +526,7 @@ its swap + LP flows genuinely need HD-key-per-input mapping.
---
## 6. Pinned facts (2026-06-29 tarball inspection) + remaining open items
## 7. Pinned facts (2026-06-29 tarball inspection) + remaining open items
**Pinned from `@wizardconnect/core@0.2.4`:**
- URI scheme: **`wiz://`** (QR-safe form `WIZ://%3F...`)
@ -338,7 +556,7 @@ its swap + LP flows genuinely need HD-key-per-input mapping.
---
## 7. Cross-references
## 8. Cross-references
- `WalletConnect_integration_reference.md` — WC2 reference (this folder)
- `CashConnect_reference.md` — CC reference (this folder)

View file

@ -0,0 +1,27 @@
Introducing ✨🔮 WizardConnect 🔮✨
WizardConnect is a brand new, Bitcoin Cash-first wallet connection protocol built from the ground up by @RiftenLabs 🪄
WizardConnect is:
🔮 Bitcoin Cash first
🔮 Open source (LGPL)
🔮 Fast, stable & reliable
🔮 Full HD-wallet compatable
🔮 Supports multiple addresses
🔮 Secure & decentralized using Nostr
🔮 E2E encrypted (run on any relay you want)
🔮 Future-proof and designed for multisig, NFTs & more
WalletConnect was never made for BCH. WizardConnect is.
Documentation:
🔮 Relay transport Guide:
https://docs.riftenlabs.com/wizardconnect/transport/
🔮 Wallet Integration Guide:
https://docs.riftenlabs.com/wizardconnect/dapp/
🔮 Protocol Specification:
https://docs.riftenlabs.com/wizardconnect/wallet/
🔮 All technical deep-dives:
https://docs.riftenlabs.com/wizardconnect/protocol/
The WizardConnect protocol is fully open source and built with love for Bitcoin Cash, by @RiftenLabs 🪄

View file

@ -125,6 +125,10 @@ export type SignRequestPreview =
export interface ResponderEvents {
onSessionProposal: (proposal: SessionProposal) => void;
onSignRequest: (request: SignRequestPreview) => void;
/** The dApp cancelled a pending sign request the wallet already surfaced.
* UI must clear the approval screen if it is showing this request — an
* approve tap afterwards would ship a signature nobody is waiting for. */
onSignRequestCancel: (requestId: number, reason?: string) => void;
onSessionDelete: (sessionId: string) => void;
onError: (error: Error) => void;
}

View file

@ -0,0 +1,144 @@
/**
* preflight.ts — derive user-visible pre-flight facts from a sign-tx request.
*
* The SigningPreview screen used to render hardcoded strings ("no token UTXOs
* spent", "within normal range", a literal `bchtest:qp2…7lm` recipient) —
* misleading on the one screen where a user decides whether to sign. This
* module computes those facts from the actual request instead.
*
* Everything is defensive: any decode failure returns `null`/omission rather
* than throwing, so a malformed field never blocks the preview. The rendered
* block should hide any row it doesn't have data for.
*
* Pure — no I/O, no libauth WASM required beyond the address encoder.
*/
import { lockingBytecodeToCashAddress } from '@bitauth/libauth';
import type { BchSignTxRequest } from './index';
export interface PreflightOutput {
/** Best-effort recipient. Cashaddr when the locking bytecode decodes cleanly,
* the hex of the bytecode otherwise (op_return, non-standard, etc.). */
readonly recipient: string;
/** Output value in bits (= satoshis; naming per project convention). */
readonly bits: number;
/** True if this output carries a CashToken. */
readonly hasToken: boolean;
}
export interface Preflight {
readonly outputs: readonly PreflightOutput[];
/** Fee in bits. `null` when `sourceOutputs` is empty/missing — we cannot
* compute (inputs − outputs) without knowing what's being spent. */
readonly feeBits: number | null;
/** True if any source output being spent carries a CashToken. */
readonly anyTokenInput: boolean;
/** True if any created output carries a CashToken. */
readonly anyTokenOutput: boolean;
/** Sum of output values, in bits. */
readonly totalOutBits: number;
}
interface MinimalOutput {
lockingBytecode?: Uint8Array;
valueSatoshis?: bigint;
token?: unknown;
}
interface MinimalTx {
outputs?: readonly MinimalOutput[];
}
/** The cashaddr prefix is chosen by network. Chipnet + testnet4 both use
* `bchtest`; regtest uses `bchreg`; mainnet uses `bitcoincash`. */
export type CashAddrPrefix = 'bitcoincash' | 'bchtest' | 'bchreg';
export function computePreflight(
request: BchSignTxRequest,
prefix: CashAddrPrefix = 'bchtest',
): Preflight | null {
const tx = request.transaction as MinimalTx | undefined;
if (!tx || !Array.isArray(tx.outputs)) return null;
const outputs: PreflightOutput[] = [];
let totalOut = 0n;
let anyTokenOutput = false;
for (const o of tx.outputs) {
const bytecode = o.lockingBytecode;
const value = typeof o.valueSatoshis === 'bigint' ? o.valueSatoshis : 0n;
totalOut += value;
const hasToken = Boolean(o.token);
if (hasToken) anyTokenOutput = true;
let recipient = '';
if (bytecode instanceof Uint8Array) {
const result = lockingBytecodeToCashAddress({
bytecode,
prefix,
tokenSupport: hasToken,
});
if (typeof result === 'object' && 'address' in result) {
recipient = result.address;
} else {
// Non-standard bytecode (op_return, custom covenant lock) — show
// an unambiguous "script" marker + short hex fingerprint.
recipient = 'script:' + bytesToShortHex(bytecode);
}
} else {
recipient = '(missing lockingBytecode)';
}
outputs.push({
recipient,
bits: Number(value),
hasToken,
});
}
// Fee = inputs − outputs. `sourceOutputs` is authoritative for input values
// in the WC2 wire format — a raw tx doesn't carry them (they're on-chain,
// not in the tx). Without it we can't compute a fee honestly.
let feeBits: number | null = null;
let anyTokenInput = false;
const sources = request.sourceOutputs;
if (Array.isArray(sources) && sources.length > 0) {
let totalIn = 0n;
let sawValidValue = false;
for (const s of sources) {
const src = s as MinimalOutput;
if (typeof src.valueSatoshis === 'bigint') {
totalIn += src.valueSatoshis;
sawValidValue = true;
}
if (src.token) anyTokenInput = true;
}
if (sawValidValue) {
const fee = totalIn - totalOut;
feeBits = Number(fee);
}
}
return {
outputs,
feeBits,
anyTokenInput,
anyTokenOutput,
totalOutBits: Number(totalOut),
};
}
/** Elide a Uint8Array to `abcd…efgh` for a compact non-address display. */
function bytesToShortHex(bin: Uint8Array): string {
const hex = Array.from(bin, (b) => b.toString(16).padStart(2, '0')).join('');
if (hex.length <= 10) return hex;
return hex.slice(0, 4) + '…' + hex.slice(-4);
}
/** Elide a cashaddr for compact display: `bchtest:qp2xxx…yylm`. */
export function shortenCashAddr(addr: string): string {
const colon = addr.indexOf(':');
const head = colon >= 0 ? addr.slice(0, colon + 1) : '';
const body = colon >= 0 ? addr.slice(colon + 1) : addr;
if (body.length <= 10) return addr;
return head + body.slice(0, 4) + '…' + body.slice(-4);
}

View file

@ -29,7 +29,7 @@
* so the active KeyStore (Capacitor secure-storage / WebKeyStore) stays in charge.
*/
import { WalletKit, type WalletKitTypes } from '@reown/walletkit';
import { stringify } from '@bitauth/libauth';
import { stringify, decodeTransaction, hexToBin } from '@bitauth/libauth';
import {
CC_EVENTS,
CC_METHODS,
@ -41,6 +41,7 @@ import { handleGetAddresses } from './wc-handler';
import { buildCcSession, type CcSession } from './cc-handler';
import type { WzHandler } from './wz-handler';
import type { PendingSignRequest, RelayConnectionState } from '@wizardconnect/wallet';
import type { DisconnectReason } from '@wizardconnect/core';
import type {
AccountIdentity,
BchChain,
@ -248,7 +249,11 @@ export function createResponder(config: ResponderConfig): Responder {
sessions.set(topic, {
topic,
protocol: 'wz',
chain: 'bch:bchtest', // WZ SignTx doesn't carry a chain — assume current network; wire from config in a follow-up.
// hdwalletv1 sign requests carry no chain, so fall back to the one the
// wallet is configured for. `useConnect` pins allowedChains to the
// active network, so this is the current chain rather than a guess —
// a mainnet build previously mislabelled every WZ session as chipnet.
chain: allowedChains[0] ?? 'bch:bchtest',
peer: {
name: conn?.dappName || conn?.label || 'WizardConnect dApp',
url: '',
@ -266,6 +271,32 @@ export function createResponder(config: ResponderConfig): Responder {
sequence: wzReq.request.sequence,
});
// Unwrap. `SignTransactionRequest.transaction` is NOT the transaction —
// it is a `WcSignTransactionRequest` wrapper `{ transaction, sourceOutputs,
// broadcast?, userPrompt? }` (@bch-wc2/interfaces, re-exported by
// @wizardconnect/core). Passing the wrapper through as `transaction` leaves
// `tx.inputs` undefined and `coSignTransaction` rejects every WZ request as
// malformed. `inputPaths`/`sequence` sit on the OUTER message, not the wrapper.
const wc = wzReq.request.transaction as {
transaction: unknown;
sourceOutputs?: readonly unknown[];
broadcast?: boolean;
userPrompt?: string;
};
// The wrapper's `transaction` is `Transaction | string`; dApps built with
// cashscript send the decoded object, but hex is equally legal. Downstream
// (signer, preview) expects the decoded form, so normalise here.
let decoded = wc?.transaction;
if (typeof decoded === 'string') {
const result = decodeTransaction(hexToBin(decoded));
if (typeof result === 'string') {
void wzHandler.reject(wzReq.connectionId, wzReq.request.sequence, `malformed transaction: ${result}`);
return;
}
decoded = result;
}
// Surface as a SignRequestPreview to the UI, same shape as WC/CC.
const preview: SignRequestPreview = {
kind: 'sign-tx',
@ -273,8 +304,10 @@ export function createResponder(config: ResponderConfig): Responder {
sessionId: topic,
appName: session.peer.name,
request: {
transaction: wzReq.request.transaction,
sourceOutputs: (wzReq.request.transaction as { sourceOutputs?: readonly unknown[] })?.sourceOutputs ?? [],
transaction: decoded,
sourceOutputs: wc?.sourceOutputs ?? [],
broadcast: wc?.broadcast,
userPrompt: wc?.userPrompt,
inputPaths: wzReq.request.inputPaths,
} as BchSignTxRequest,
};
@ -292,6 +325,27 @@ export function createResponder(config: ResponderConfig): Responder {
}
});
// The dApp cancelled a pending sign_transaction_request before we responded.
// Evict our pending entry so a late approval is dropped, and tell the UI to
// clear its approval screen if it is showing this request.
wzHandler.on('signCancelled', (connectionId: string, sequence: number, reason: string | undefined) => {
for (const [requestId, entry] of wzPending.entries()) {
if (entry.connectionId === connectionId && entry.sequence === sequence) {
wzPending.delete(requestId);
events.onSignRequestCancel(requestId, reason);
return;
}
}
});
// Remote peer closed the connection. `connectionsChanged` also fires and
// handles the session eviction; here we just surface a readable reason so
// the shell can flash a toast instead of a silent disappearance.
wzHandler.on('remoteDisconnect', (connectionId: string, reason: DisconnectReason, message: string | undefined) => {
const label = message ? `${reason}: ${message}` : reason;
events.onError(new Error(`WizardConnect dApp closed the connection (${connectionId.slice(0, 8)}…): ${label}`));
});
wzHandler.on('error', (err: Error) => events.onError(toError(err)));
}

View file

@ -180,15 +180,20 @@ export function useConnect(
const allowedChains: readonly BchChain[] = [networkToChain(currentNetwork)];
// WizardConnect handler — instantiate iff a KeyStore with HD support is
// available. Failure to build it is non-fatal (WC/CC still work); we
// record the error and continue without the WZ transport.
// WizardConnect handler — instantiate iff the active KeyStore has an
// HD tree available. `supportedPathNames()` is static (from the
// interface) so it includes receive/change/defi even for WIF-restored
// wallets that cannot export xpubs — that array alone is not proof of
// HD availability. `buildXpubCache` inside `createWzHandler` throws
// KeyStoreError('not-supported') for WIF-restored wallets; translate
// that specific case into an honest silent skip (WC/CC still work),
// and only surface *other* failures as visible errors.
let wzHandler: WzHandler | undefined;
keyStoreRef.current = keyStore;
if (keyStore) {
try {
const paths = keyStore.supportedPathNames();
if (paths.includes('receive') && paths.includes('change') && paths.includes('defi')) {
const paths = keyStore.supportedPathNames();
if (paths.includes('receive') && paths.includes('change') && paths.includes('defi')) {
try {
wzHandler = await createWzHandler({
keyStore,
walletName: DEVIANT_METADATA.name,
@ -209,11 +214,21 @@ export function useConnect(
});
if (cancelled) return;
wzHandlerRef.current = wzHandler;
} catch (e) {
const err = e as { code?: string };
if (err?.code === 'not-supported') {
// Valid wallet configuration (WIF-restored), not an error. Log
// to the console for support diagnostics but keep the UI clean —
// showing a red banner here would misrepresent the situation as
// a broken build. WalletConnect and CashConnect remain available.
// eslint-disable-next-line no-console
console.info(
'[connect] WizardConnect unavailable: this wallet was imported from a WIF and has no HD tree. WalletConnect and CashConnect remain available.',
);
} else {
setError(e as Error);
}
}
} catch (e) {
// Log to the error surface so it's visible, but don't fail the whole
// responder init — WC/CC still work.
setError(e as Error);
}
}
@ -224,6 +239,17 @@ export function useConnect(
events: {
onSessionProposal: (p) => { if (!cancelled) setProposal(p); },
onSignRequest: (req) => { if (!cancelled) setSignRequest(req); },
onSignRequestCancel: (requestId, reason) => {
if (cancelled) return;
// Clear the approval screen if it is showing the cancelled request
// — approving a request the dApp has already withdrawn would ship a
// signature to a peer that no longer expects it.
const current = signRequestRef.current;
if (current && current.id === requestId) {
setSignRequest(null);
if (reason) setError(new Error(`Sign request cancelled by dApp: ${reason}`));
}
},
onSessionDelete: (topic) => {
if (cancelled) return;
setSessions((prev) => prev.filter((s) => s.topic !== topic));

View file

@ -21,13 +21,30 @@
* per `hdwalletv1.js:PATH_DEFI = 'defi'`). We translate at the enum ↔ string
* boundary.
*
* ⚠ LICENSE STATUS (2026-07-04): @wizardconnect/{core,wallet} publish
* with no LICENSE file, no `license` field in package.json, and no LICENSE
* at their gitlab. Community reports treat it as "open, no license" — which
* is technically copyright-restricted by default. Community usage precedent
* (Cauldron, Paytaca, Selene ship with it) makes chipnet-beta consumption
* defensible. Before mainnet ship, get an explicit permission from Riften
* Labs (Dagur / H4lvor). Tracked in `Parameters/WalletConnect/WizardConnect_reference.md`.
* LICENSE STATUS — RESOLVED (2026-07-27). The note here used to read "no license,
* copyright-restricted by default, mainnet ship needs explicit permission." That
* was wrong; the mainnet block can be lifted.
*
* @wizardconnect/{core,wallet,dapp} are **LGPL-3.0-or-later**. The npm packages
* omit the `license` field and exclude LICENSE.txt from their published `files`,
* which is the only reason `npm view` renders "Proprietary". The governing licence
* is the repo-root LICENSE.txt at gitlab.com/riftenlabs/lib/wizardconnect —
* verbatim LGPL-3.0 with no additional terms — the repo README says
* "LGPL-3.0-or-later … Free for commercial and non-commercial use", and Riften
* Labs' own launch announcement lists "Open source (LGPL)".
*
* What LGPL §4 obliges us to do, since we link the library and do not modify it
* (our own code keeps its licence — copyleft does NOT propagate to it):
* §4(a) state in-app that WizardConnect is used and is LGPL-covered;
* §4(b)(c) ship copies of LGPL-3.0 + GPL-3.0 with the app;
* §4(d) let the user substitute their own build. Easiest is §4(d)(1) — keep
* @wizardconnect/* as its own chunk instead of inlining it into one
* minified bundle. Verify the Vite build does not fold it in before
* mainnet ship.
*
* Full analysis: SilentMode `Decentralized.DNS/WIZARDCONNECT-LICENSE-FINDING.md`.
* Note `Parameters/WalletConnect/WizardConnect_reference.md` §0/§3.3 still carries
* the old "Proprietary" claim and should be corrected.
*/
import {
DerivationPath,

View file

@ -19,10 +19,11 @@
* union + a URI-to-transport router. Both are structurally similar to how
* CashConnect was wired via `cc-handler.ts` and `useConnect.ts`.
*
* ⚠ LICENSE STATUS: see wz-adapter.ts header. @wizardconnect/{core,wallet} are
* proprietary-per-npm-view but publish no LICENSE file. Community usage
* precedent (Cauldron/Paytaca/Selene) supports chipnet-beta consumption;
* mainnet ship needs explicit Riften Labs permission.
* LICENSE STATUS — RESOLVED (2026-07-27): @wizardconnect/* is LGPL-3.0-or-later,
* free for commercial use. "Proprietary" in `npm view` is an artefact of a missing
* `license` field, not a licence term. Mainnet is no longer blocked; the remaining
* obligations are attribution + shipping the licence texts + keeping the library a
* separate chunk (LGPL §4a/4b/4d1). See the wz-adapter.ts header for the detail.
*/
import {
WalletConnectionManager,
@ -30,6 +31,7 @@ import {
type PendingSignRequest,
type SignTransactionResult,
} from '@wizardconnect/wallet';
import type { DisconnectReason } from '@wizardconnect/core';
import type { SignTransactionRequest } from '@wizardconnect/core';
import type { KeyStore } from '@core/wallet/keystore.js';
import {
@ -57,6 +59,13 @@ export interface WzHandlerOpts {
export interface WzHandlerEventMap {
pendingSignRequest: (req: PendingSignRequest) => void;
connectionsChanged: (connections: readonly RelayConnectionState[]) => void;
/** The dApp cancelled a pending sign request. The responder must evict the
* matching pending entry so an approval taped through afterwards is dropped
* rather than shipped to a dApp that no longer expects it. */
signCancelled: (connectionId: string, sequence: number, reason: string | undefined) => void;
/** The dApp closed its side of the connection. `connectionsChanged` also
* fires; this event carries the reason for logging/user messaging. */
remoteDisconnect: (connectionId: string, reason: DisconnectReason, message: string | undefined) => void;
error: (error: Error) => void;
}
@ -98,13 +107,16 @@ export async function createWzHandler(opts: WzHandlerOpts): Promise<WzHandler> {
const manager = new WalletConnectionManager(adapter);
// Simple multi-listener event bus. Only the three event kinds our interface
// exposes are supported — the underlying manager has more (remoteDisconnect,
// signCancelled, message, connectionStatusChanged) but we don't surface them
// at v1; add them here when a caller needs one.
// Multi-listener event bus. `connectionStatusChanged` and raw `message` from
// the underlying manager are still not surfaced — add them if a UI need
// arises. `signCancelled` and `remoteDisconnect` were added 2026-07-27
// because their absence let the UI leave stale approvals on screen after the
// dApp cancelled or closed the connection.
const listeners: { [K in keyof WzHandlerEventMap]: Set<WzHandlerEventMap[K]> } = {
pendingSignRequest: new Set(),
connectionsChanged: new Set(),
signCancelled: new Set(),
remoteDisconnect: new Set(),
error: new Set(),
};
@ -131,6 +143,14 @@ export async function createWzHandler(opts: WzHandlerOpts): Promise<WzHandler> {
emit('connectionsChanged', Object.values(manager.getConnections()));
});
manager.on('signCancelled', (connectionId: string, sequence: number, reason: string | undefined) => {
emit('signCancelled', connectionId, sequence, reason);
});
manager.on('remoteDisconnect', (connectionId: string, reason: DisconnectReason, message: string | undefined) => {
emit('remoteDisconnect', connectionId, reason, message);
});
return {
pair(uri: string): string {
return manager.connect(uri);

View file

@ -3,6 +3,7 @@ import type { Network } from '../core/wallet';
import { QrScanner } from '../qr/QrScanner';
import { parseQrUri } from '../qr/qr-uri';
import type { ConnectHandle } from '../connect/useConnect';
import { computePreflight, shortenCashAddr, type Preflight, type CashAddrPrefix } from '../connect/preflight';
// Apps — dApp connectivity (Connected | Discover), a wallet-global network
// switcher, and dual-protocol connect (WalletConnect + CashConnect, auto-
@ -53,6 +54,17 @@ interface SigningRequest {
summary: string;
detail: string;
sats?: number;
/** Verbatim `userPrompt` the dApp attached to the request; the protocol
* expects wallets to render it as-is. `null` for sign-message or when the
* dApp didn't set one. */
userPrompt?: string | null;
/** Real, request-derived pre-flight facts. `null` for sign-message or when
* the transaction couldn't be decoded — SigningPreview hides the block. */
preflight?: Preflight | null;
}
function networkToCashAddrPrefix(n: Network): CashAddrPrefix {
return n === 'mainnet' ? 'bitcoincash' : 'bchtest';
}
// ── Internal navigation ───────────────────────────────────────────────────
@ -135,6 +147,7 @@ export function AppsScreen({
detail: display.length > 200 ? display.slice(0, 200) + '…' : display,
};
}
const preflight = computePreflight(r.request, networkToCashAddrPrefix(network));
return {
sessionId: r.sessionId,
appName: r.appName,
@ -143,8 +156,10 @@ export function AppsScreen({
detail: r.request.broadcast
? 'The dApp will broadcast this transaction after you sign.'
: 'The dApp will assemble + broadcast after collecting your signature.',
userPrompt: r.request.userPrompt ?? null,
preflight,
};
}, [connect?.state.signRequest]);
}, [connect?.state.signRequest, network]);
// When a proposal arrives, make sure we're on the app-preview view (covers
// the case where the user navigated away while we were waiting).
@ -678,8 +693,9 @@ function SigningPreview({
onApprove: () => void;
onReject: () => void;
}) {
const satsStr = req.sats ? req.sats.toLocaleString() + ' sat' : '';
const satsStr = req.sats ? req.sats.toLocaleString() + ' bits' : '';
const bchStr = req.sats ? (req.sats / 1e8).toFixed(5) + ' BCH' : '';
const pf = req.preflight ?? null;
return (
<>
@ -697,7 +713,8 @@ function SigningPreview({
</div>
</div>
{/* Summary */}
{/* Summary — verbatim `userPrompt` from the dApp when present, per the
protocol requirement that wallets render the dApp's own wording. */}
<div className="value-region" style={{ marginBottom: 16 }}>
<div className="pnv-label">Requested action</div>
<div className="pnv" style={{ fontSize: 22 }}>{req.summary}</div>
@ -705,13 +722,61 @@ function SigningPreview({
{satsStr && <div className="pnv-raw">{satsStr}</div>}
</div>
{/* Pre-flight */}
<div className="sec-label">Pre-flight checks</div>
<div className="card" style={{ marginBottom: 14 }}>
<div className="brow"><span className="k">Outputs</span><span className="v" style={{ color: 'var(--acid)' }}>✓ no token UTXOs spent</span></div>
<div className="brow"><span className="k">Fee</span><span className="v" style={{ color: 'var(--acid)' }}>✓ within normal range</span></div>
<div className="brow" style={{ borderBottom: 0 }}><span className="k">Recipient</span><span className="v">bchtest:qp2…7lm</span></div>
</div>
{/* Pre-flight — computed from the request. Hidden entirely if the
transaction couldn't be decoded (rather than showing a lie). */}
{pf && (
<>
<div className="sec-label">Pre-flight</div>
<div className="card" style={{ marginBottom: 14 }}>
{pf.outputs.slice(0, 4).map((o, i, arr) => (
<div
key={i}
className="brow"
style={i === arr.length - 1 && pf.feeBits === null && !pf.anyTokenInput ? { borderBottom: 0 } : undefined}
>
<span className="k">
{arr.length === 1 ? 'Recipient' : `Output ${i}`}
{o.hasToken && ' · token'}
</span>
<span className="v" title={o.recipient}>
{shortenCashAddr(o.recipient)}
{o.bits > 0 && (
<span style={{ marginLeft: 8, color: 'var(--muted)' }}>
{o.bits.toLocaleString()} bits
</span>
)}
</span>
</div>
))}
{pf.outputs.length > 4 && (
<div className="brow">
<span className="k">…{pf.outputs.length - 4} more output{pf.outputs.length - 4 === 1 ? '' : 's'}</span>
<span className="v" />
</div>
)}
{pf.feeBits !== null && (
<div className="brow" style={!pf.anyTokenInput && !pf.anyTokenOutput ? { borderBottom: 0 } : undefined}>
<span className="k">Fee</span>
<span className="v">
{pf.feeBits < 0
? <span style={{ color: 'var(--warn, #e6a03a)' }}>outputs exceed inputs by {(-pf.feeBits).toLocaleString()} bits</span>
: `${pf.feeBits.toLocaleString()} bits`}
</span>
</div>
)}
{(pf.anyTokenInput || pf.anyTokenOutput) && (
<div className="brow" style={{ borderBottom: 0 }}>
<span className="k">Tokens</span>
<span className="v">
{pf.anyTokenInput && 'spends token UTXOs'}
{pf.anyTokenInput && pf.anyTokenOutput && ' · '}
{pf.anyTokenOutput && 'creates token outputs'}
</span>
</div>
)}
</div>
</>
)}
<p className="lead-note">{req.detail}</p>

View file

@ -51,6 +51,8 @@ function makeFakeWzHandler(): WzHandler & {
const listeners: { [K in keyof WzHandlerEventMap]: Set<WzHandlerEventMap[K]> } = {
pendingSignRequest: new Set(),
connectionsChanged: new Set(),
signCancelled: new Set(),
remoteDisconnect: new Set(),
error: new Set(),
};
const calls = {
@ -132,6 +134,7 @@ function makeEvents(): ResponderEvents & {
signRequests, sessionDeletes, errors,
onSessionProposal() {},
onSignRequest(req) { signRequests.push(req); },
onSignRequestCancel() {},
onSessionDelete(topic) { sessionDeletes.push(topic); },
onError(e) { errors.push(e); },
};
@ -246,6 +249,80 @@ describe('multi-protocol responder — WZ pendingSignRequest flow', () => {
expect(preview.id).toBeGreaterThan(1_000_000);
});
// ── Regression: the wrapper must be unwrapped ────────────────────────────
//
// `SignTransactionRequest.transaction` is NOT a transaction. It is a
// `WcSignTransactionRequest` wrapper — `{ transaction, sourceOutputs,
// broadcast?, userPrompt? }`. Passing the wrapper straight through as
// `request.transaction` leaves `tx.inputs` undefined, and coSignTransaction
// rejects EVERY WizardConnect request with "malformed request — missing
// tx.inputs or sourceOutputs". The earlier tests missed it because they only
// asserted on sessionId/id/kind and never looked inside `request`.
const wrapped = (over: Record<string, unknown> = {}): PendingSignRequest => ({
connectionId: 'wz-conn-1',
request: {
action: 'sign_transaction_request' as never,
time: Date.now(),
sequence: 7,
inputPaths: [[0, 'receive', 3]],
transaction: {
transaction: { version: 2, locktime: 0, inputs: [{ outpointIndex: 0 }], outputs: [{ valueSatoshis: 1000n }] },
sourceOutputs: [{ lockingBytecode: new Uint8Array([0x76]), valueSatoshis: 5000n }],
broadcast: true,
userPrompt: 'Register alice.p2p',
...over,
} as never,
},
});
it('unwraps WcSignTransactionRequest so the signer receives a real transaction', () => {
wzHandler.emit('pendingSignRequest', wrapped());
const preview = events.signRequests[0] as { request: Record<string, any> };
const tx = preview.request.transaction as Record<string, unknown>;
// The inner transaction, not the wrapper.
expect(Array.isArray(tx.inputs)).toBe(true);
expect(tx).not.toHaveProperty('sourceOutputs');
expect(tx.version).toBe(2);
// The precise precondition coSignTransaction checks first (signer.ts:135).
expect(Array.isArray(preview.request.sourceOutputs)).toBe(true);
expect((tx.inputs as unknown[]).length).toBe(preview.request.sourceOutputs.length);
});
it('forwards broadcast, userPrompt and inputPaths instead of dropping them', () => {
wzHandler.emit('pendingSignRequest', wrapped());
const preview = events.signRequests[0] as { request: Record<string, any> };
// userPrompt is what the approval screen must render verbatim — dropping it
// left every WizardConnect prompt reading a generic "Approve transaction".
expect(preview.request.userPrompt).toBe('Register alice.p2p');
expect(preview.request.broadcast).toBe(true);
expect(preview.request.inputPaths).toEqual([[0, 'receive', 3]]);
});
it('accepts the hex-string transaction variant, which the type also permits', () => {
// WcSignTransactionRequest.transaction is `Transaction | string`. dApps built
// with cashscript send the decoded object, but hex is equally legal and the
// signer cannot decode it itself.
const hex =
'0200000001' + '00'.repeat(32) + '00000000' + '00' + 'ffffffff' +
'01' + 'e803000000000000' + '00' + '00000000';
wzHandler.emit('pendingSignRequest', wrapped({ transaction: hex }));
const preview = events.signRequests[0] as { request: Record<string, any> };
const tx = preview.request.transaction as Record<string, unknown>;
expect(typeof tx).toBe('object');
expect(Array.isArray(tx.inputs)).toBe(true);
expect((tx.inputs as unknown[]).length).toBe(1);
});
it('rejects an undecodable transaction rather than surfacing it for approval', () => {
wzHandler.emit('pendingSignRequest', wrapped({ transaction: 'not-hex-at-all' }));
expect(events.signRequests).toHaveLength(0);
expect(wzHandler.calls.reject.length).toBe(1);
expect(wzHandler.calls.reject[0][0]).toBe('wz-conn-1');
expect(wzHandler.calls.reject[0][1]).toBe(7);
});
it('approveSignRequest for a WZ request routes to wzHandler.approve with the correct (connId, sequence)', async () => {
const wzReq: PendingSignRequest = {
connectionId: 'wz-conn-1',