Compare commits

..

9 commits

Author SHA1 Message Date
Håvard Kittelsen
dbb2616689 Merge branch 'docs/claude-md-accuracy' into 'master'
docs: correct the integration test path, note two things easy to get wrong

See merge request riftenlabs/lib/wizardconnect!34
2026-08-19 08:00:20 +00:00
Håvard Kittelsen
34f5bcb6b7 docs: correct the integration test path, note two things easy to get wrong 2026-08-19 08:00:20 +00:00
Håvard Kittelsen
70971c1b2a Merge branch 'fix/audit-advisories' into 'master'
chore(deps): resolve npm audit advisories

See merge request riftenlabs/lib/wizardconnect!33
2026-08-19 07:07:09 +00:00
Håvard Kittelsen
c2b60b5b3a chore(deps): resolve npm audit advisories
Declared ranges — the lockfile is not published, so these are what consumers
resolve against:

  core                       ws     ^8.18.0 -> ^8.21.3  (prod, vuln 8.0.0-8.20.1)
  core, dapp, wallet, react  vitest ^3.2.3  -> ^3.2.7   (dev,  vuln <3.2.6)

Transitive, lockfile only: vite 7.3.2 -> 7.3.6, postcss 8.5.12 -> 8.5.26,
nanoid 3.3.11 -> 3.3.18, brace-expansion 5.0.5 -> 5.0.9.

npm audit --audit-level=moderate now exits 0. esbuild's low-severity Windows
dev-server advisory is left: needs a major bump behind a peer range.
2026-08-19 09:01:29 +02:00
Håvard Kittelsen
2b004a3f77 Merge branch 'fix/disconnect-race' into 'master'
Deliver the courtesy disconnect before tearing the relay down

See merge request riftenlabs/lib/wizardconnect!32
2026-08-19 06:31:09 +00:00
Håvard Kittelsen
dcda4fce6a fix(wallet): deliver the courtesy disconnect before tearing the relay down
doDisconnect fired the courtesy `disconnect` message without awaiting it, then
tore the relay connection down on the next line:

    conn.client.relay(disconnectMsg).catch(() => {});   // fire and forget
    ...
    conn.cleanup();                                      // closes the pool underneath it

relay() resolves only after `Promise.allSettled(pool.publish(...))` — a real
round trip to every configured relay. cleanup() closed the pool while that
publish was still in flight, so the message usually never left and the dapp went
on believing the wallet was connected until its own liveness timeout fired.
Downstream wallets were patching this out of the published package.

Teardown now splits into two halves with opposite timing requirements.

Registry removal stays synchronous. getConnections() is what a UI renders, and
connect() returns an existing connection for a URI, so leaving this one in the
map while its teardown is pending would hand a caller a dying connection. This
is the one place this differs from !30 and from the downstream patches, which
defer the registry removal along with the teardown.

Relay teardown is deferred until the publish settles, bounded by
DISCONNECT_PUBLISH_TIMEOUT_MS (5s). The bound matters: "the publish never
settles" is exactly the case where a relay is unreachable, and a socket that is
never closed is worse than a courtesy message that is never delivered.

disconnect() keeps its synchronous void signature — not a breaking change.

Why it shipped broken: disconnect.test.ts only covered dapp → wallet. Nothing
exercised wallet → dapp, and the failure is invisible from the wallet's side —
its own state is correct either way, and only the peer notices.
disconnect-delivery.test.ts covers that direction over a live relay, including
the two invariants the deferral must not break (registry cleared immediately,
URI reusable afterwards).

Also fixes a latent hang in the integration harness that the new file exposed.
setupConnection gated both the dapp_ready send and the message handler inside
the keyexchangecomplete callback, so the handshake hung on receiving the wallet's
single wallet_ready for the cycle. Miss it — the dapp's subscription can come up
after the wallet has already published — and key exchange never resolves, the
handler never registers, no dapp_ready is ever sent, and the wallet, guarded by
walletReadySentThisCycle, has nothing prompting it to retry. It now re-announces
dapp_ready(wallet_discovered=false) every 2s until key exchange completes, which
resets that guard and earns another wallet_ready: the recovery path mutual
discovery already specifies, which the harness was not using. Plus retry: 2 on
the integration config, since these tests talk to live relays and a dropped
connection is an environment failure rather than a regression.

docs/wallet.md gains a "Sending disconnect" section for the synchronous/deferred
split and what a caller may rely on. docs/protocol.md gains the sender-side half
of the courtesy-disconnect semantics, which previously read as though "no
acknowledgement" licensed fire-and-forget. That reading is what produced the bug.

The race was diagnosed and first fixed by hantyrram (Ronaldo Ramano) in !30,
which this supersedes — the deferral is their fix; this changes only how it is
scoped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 16:29:34 +02:00
jakobsn
167ec21474 Merge branch 'revert-e71f76c1' into 'master'
Revert "Merge branch 'randomTradeSummary' into 'master'"

See merge request riftenlabs/lib/wizardconnect!29
2026-05-11 08:48:04 +00:00
jakobsn
d580bb8927 Revert "Merge branch 'randomTradeSummary' into 'master'"
This reverts merge request !27
2026-05-11 08:46:49 +00:00
Dagur Valberg Johannsson
dc01931ad6 Merge branch 'react-dev' into 'master'
Fix issue with restoring session in React dev mode

See merge request riftenlabs/lib/wizardconnect!28
2026-05-09 18:34:46 +00:00
22 changed files with 343 additions and 1117 deletions

View file

@ -21,8 +21,12 @@ This codebase communicates over a live relay with timing-sensitive handshakes an
**Integration tests** (`npm run test:integration` in a package):
- Hit the real relays at `wss://relay.riften.net:443` and `wss://relay.cauldron.quest:443`
- Test the full protocol handshake end-to-end
- Located in `src/__tests__/*.integration.test.ts`
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
- Located in `src/integration/*.test.ts` (wallet is the only package with them today)
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`, serially
(`singleFork`) to avoid relay contention, and with `retry: 2` — these hit third-party
relays, so a dropped connection is an environment failure rather than a regression
- A failure that reproduces locally is real; one that does not is usually the relay.
Check whether the same test passed on an earlier pipeline before assuming a regression
- Must pass before any release
### Running tests
@ -87,3 +91,13 @@ npm run build # builds all packages in dependency order
```
Packages must be built before integration tests run (tests import from `dist/`).
## Releases
The `publish` CI job runs on `master` only, via `contrib/auto-publish.js`.
**The `version` in each `package.json` is a floor, not the shipped version** — all declare
`0.2.0` while npm carries higher patches. Use `npm view @wizardconnect/<pkg> version`.
The lockfile is not published, so clearing a dependency advisory means bumping the declared
range in `package.json`, not just `package-lock.json`.

View file

@ -164,10 +164,7 @@ const response = await dappMgr.signTransaction({
userPrompt: "Confirm swap",
broadcast: true,
},
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex, slot?]
// A contract input with several sig/pubkey placeholders lists its index once
// per slot, e.g. [[3, "receive", 0, 0], [3, "defi", 7, 1]]. Only do this when
// the wallet advertised the `multislot` extension — see below.
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
});
console.log("Signed tx:", response.signedTransaction);
@ -184,7 +181,7 @@ cancelButton.onclick = () => controller.abort("User cancelled");
try {
const response = await dappMgr.signTransaction(
{ transaction: { ... }, inputPaths: [[0, "receive", 0]] },
{ transaction: { ... }, inputPaths: [...] },
{ signal: controller.signal },
);
} catch (err) {
@ -205,7 +202,7 @@ const request: SignTransactionRequest = {
sequence: seq,
time: Math.floor(Date.now() / 1000),
transaction: { ... },
inputPaths: [[0, "receive", 0]], // one entry per key; repeat the index for a multi-key contract input
inputPaths: [[0, "receive", 0]],
};
const response = await dappMgr.sendSignRequest(request);

View file

@ -172,22 +172,9 @@ const adapter: WalletAdapter = {
};
```
Whatever `getExtensions()` returns becomes `session["hdwalletv1"].extensions` verbatim in the
`wallet_ready` message — the `WalletConnectionManager` merges it in (it does not transform the keys).
That is the exact location the dapp reads (see "Discovering extensions" below). So returning
`{ multislot: {} }` is what the dapp sees as `session["hdwalletv1"].extensions.multislot`.
Wallets that don't implement these methods produce the same session as before (receive/change/defi
paths only, no extensions field).
> Version note: folding `getExtensions()` into the session was added in a later `@wizardconnect`
> release, together with the `EXT_MULTISLOT` constant and the optional `slot` (4th) element of the
> `inputPaths` tuple type. Packages from the `0.1.x` line ignore `getExtensions()` and type
> `inputPaths` as a 3-tuple without `EXT_MULTISLOT`, so an extension advertised that way silently
> fails to negotiate and a TypeScript consumer won't compile the 4-tuple. Confirm your
> `@wizardconnect/core` / `@wizardconnect/wallet` version actually ships these (or read the `slot`
> positionally and use the `"multislot"` string literal) before relying on them.
## Discovering extensions (dapp side)
Dapps check for extension support after receiving `wallet_ready`:
@ -222,35 +209,5 @@ wallet doesn't support, inform the user rather than failing silently.
| `bch_stealth_bip352` | `stealth_spend`, `stealth_scan` | `m/352'/145'/0'/0'`, `m/352'/145'/0'/1'` | BCH stealth addresses (BIP352 structure). Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
| `rpa_bip47` | `rpa_spend`, `rpa_scan` | `m/47'/145'/0'/0'`, `m/47'/145'/0'/1'` | BIP47 reusable payment addresses. Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
| `decrypt` | — | — | Dapp-side encrypted storage. Wallet provides a public key; dapp encrypts data for storage and sends `decrypt_request` messages when the data is needed. | Proposed |
| `multislot` | — | — | Wallet can fill **more than one** `sig`/`pubkey` placeholder per input, routed by the `slot` element of `inputPaths`. Needed for contracts that take multiple signatures from distinct keys in a single input. | Proposed |
See the discussions and specifications for each extension as they are formalized.
### multislot
By default an `inputPaths` entry is a 3-tuple `[inputIndex, pathName, addressIndex]` and the wallet
contributes exactly one signature (and at most one public key) per input. Some contracts need the
wallet to fill several `sig`/`pubkey` placeholders in a single input, each with a different key.
A wallet that supports this advertises `multislot` in its session extensions:
```json
{
"paths": [ /* ... */ ],
"extensions": { "multislot": {} }
}
```
The value is `{}` — no handshake data is needed; presence alone signals support.
When advertised, the dapp may add a fourth element, `slot`, to `inputPaths` entries and may list the
same `inputIndex` more than once (one entry per placeholder slot). The wallet fills the `slot`-th
sig placeholder and the `slot`-th pubkey placeholder of that input with the named key. See
[protocol.md § Multiple placeholders per input](protocol.md#multiple-placeholders-per-input-the-slot-element)
for the placeholder byte format and exact slot semantics.
**A dapp MUST NOT** emit `slot > 0` (or repeat an `inputIndex`) toward a wallet that has not
advertised `multislot`. A wallet that does not advertise it continues to receive only ordinary
3-tuple, single-signature requests, so it is unaffected. This negotiation is what prevents an
unaware wallet from silently filling only the first placeholder and returning an unspendable
transaction.

View file

@ -268,108 +268,22 @@ to match responses to requests.
(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the
wallet UI.
`inputPaths` is a list of positional tuples. Each tuple has 3 or 4 elements:
| index | field | type | meaning |
|-------|-------|------|---------|
| `[0]` | `inputIndex` | integer | which transaction input this entry is for |
| `[1]` | `PathName` | string | named derivation path (`"receive"`, `"change"`, `"defi"`, …) |
| `[2]` | `addressIndex` | integer | address index within that path |
| `[3]` | `slot` | integer, optional | placeholder slot within the input (default `0`) — see below |
Each entry names one HD key (by named derivation path and address index within that path) that the
wallet must contribute to the input at position `inputIndex`. Only inputs the wallet must sign need
an entry — a contract input whose unlocking bytecode the dapp fully provides (including any
non-wallet keys, e.g. a counterparty's) is omitted. An input with **no** entry is simply not the
wallet's to sign and is left untouched; this is distinct from an entry the wallet cannot satisfy,
which is an error (see Wallet rules below). This lets the wallet derive the correct private keys
without scanning or guessing which key was used.
For an ordinary P2PKH input, one entry suffices and `slot` is omitted: the wallet derives that key
and produces the input's single signature.
##### Multiple placeholders per input (the `slot` element)
A contract input may carry **several `sig`/`pubkey` placeholders**, each to be filled by a
different key (e.g. an N-of-N agreement, or a function taking `(sig a, pubkey A, sig b, pubkey B)`).
The dapp builds the unlocking bytecode with one placeholder per slot and lists the input's index
once per slot, setting the fourth tuple element, `slot`:
```
inputPaths: [
[3, "receive", 0, 0], // input 3, placeholder slot 0 -> key receive/0
[3, "defi", 7, 1], // input 3, placeholder slot 1 -> key defi/7
]
```
Placeholder format (the de-facto BCH-WC2 convention this builds on). Signatures **MUST** be
**Schnorr** — the fixed-length placeholder scheme has no room for variable-length DER ECDSA:
- a **signature placeholder** is a data push of **65 zero bytes** (`0x41` followed by 65 `0x00`) —
room for a 64-byte Schnorr signature plus its one trailing sighash-flag byte;
- a **public-key placeholder** is a data push of **33 zero bytes** (`0x21` followed by 33 `0x00`).
Because the real signature (65 bytes) and real compressed public key (33 bytes) are exactly the
length of their placeholders, the wallet splices the value in **value-for-value, keeping the same
push opcode** (`0x41` / `0x21`) and not changing the bytecode length.
`slot` semantics:
- `slot` counts **sig placeholders and pubkey placeholders independently**, left-to-right, starting
at 0. An input may legally have a different number of each (e.g. 2 sig placeholders but only 1
pubkey placeholder, when one key's pubkey is hard-coded in the redeem script).
- An entry `[i, path, addr, k]` tells the wallet: derive the key for `(path, addr)`, write its
signature into the **k-th sig placeholder** of input `i`, and write its compressed public key into
the **k-th pubkey placeholder** of input `i` if one is present.
- `slot` is **optional and defaults to 0**, so an entry with no `slot` fills the first sig (and
first pubkey) placeholder — identical to the single-signature behaviour. Existing 3-tuple requests
are therefore unchanged.
The dapp ships the unsigned unlocking-bytecode template per input at **`sourceOutputs[i].unlockingBytecode`**
(equivalently, the decoded `transaction.inputs[i].unlockingBytecode`). The wallet locates the k-th
placeholder by scanning that template for the k-th occurrence of the zero-filled push above, then
splices in the real signature / public key. It does **not** rely on the order of the `inputPaths`
tuples — the `slot` value is authoritative.
Wallet rules (**MUST**):
- **Do not deduplicate `inputPaths` by `inputIndex`.** Several entries may share one `inputIndex`
(one per slot); collapsing them into a map keyed by index drops signatures and yields an
unspendable transaction. Keep every entry.
- **Compute each input's sighash once.** All signatures within one input commit to the **same
sighash**: the signing serialization covers `sourceOutputs[i].contract.redeemScript` and the
transaction, *not* the unlocking bytecode being filled — so filling one slot does not invalidate
another's sighash. Compute it once per input and reuse it for every slot, varying only the key.
- **Reject, don't under-fill.** If an entry cannot be satisfied — the named path/index does not map
to a key, or the input has no placeholder at the requested `slot` — the wallet **MUST** fail the
whole request with an error. It **MUST NOT** return a transaction with a leftover zero
placeholder, which would be silently unspendable.
##### Capability negotiation (required)
Filling more than one placeholder per input is gated behind the **`multislot` extension**. A wallet
that supports it advertises the key in its `wallet_ready` handshake:
`session["hdwalletv1"].extensions.multislot` (see [extensions.md](extensions.md#multislot)). A dapp
**MUST NOT** send any entry with `slot > 0` (nor more than one entry for the same `inputIndex`)
unless the connected wallet advertised `multislot`. A wallet that does not advertise it only ever
receives single-signature (`slot` 0 / 3-tuple) requests. This prevents the silent failure mode
where a wallet unaware of slots fills only the first placeholder and returns an unspendable
transaction.
`inputPaths` is a sparse array of `[inputIndex, PathName, addressIndex]` tuples. Each entry identifies
the HD derivation path name and address index the dapp used to derive the locking script for the input
at position `inputIndex`. Only inputs that require wallet signing need an entry — contract inputs with
pre-set unlocking bytecode can be omitted. This allows the wallet to sign each input without scanning
or guessing which key was used.
#### SIGHASH requirement (security-critical)
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID` and **SHOULD** additionally set
`SIGHASH_UTXOS`. Concretely, the sighash-flag byte — the last byte of each signature push — **MUST**
be either `0x41` (`SIGHASH_ALL | SIGHASH_FORKID`) or `0x61` (`SIGHASH_ALL | SIGHASH_FORKID |
SIGHASH_UTXOS`); the wallet **MUST** reject any request that would require any other flags. The bit
values are `SIGHASH_ALL = 0x01`, `SIGHASH_UTXOS = 0x20`, `SIGHASH_FORKID = 0x40`. (`validateSighashFlags`
in `@wizardconnect/wallet` enforces exactly this `{0x41, 0x61}` set.)
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS` and **MUST**
reject any request that would require different flags.
`SIGHASH_ALL` ensures the signature commits to the entire transaction (all inputs and all outputs).
Without it, an attacker could collect a valid signature and graft it onto a different transaction —
for example, using `SIGHASH_NONE` an attacker could replace every output to redirect funds.
Because `inputPaths` lets the dapp specify which key(s) sign each input (one or more per input for contracts), the wallet no longer
Because `inputPaths` lets the dapp specify which key signs each input, the wallet no longer
independently verifies that the key matches the UTXO's locking bytecode. This is safe **only** when
`SIGHASH_ALL` is enforced: if the dapp provides a wrong path, the resulting signature is invalid
(public key hash mismatch) and the transaction cannot broadcast. Without `SIGHASH_ALL`, a
@ -444,6 +358,12 @@ Either side may send a `disconnect` message before tearing down the relay connec
courtesy notification — the remote side treats the connection as closed immediately upon receipt
(no acknowledgement).
No acknowledgement does not mean fire-and-forget on the sender's side. `relay()` resolves only
after the publish has been settled against every configured relay, so the sender must keep the
relay connection open until then — closing it first kills the publish in flight and the peer
never learns of the disconnect. Sending a courtesy `disconnect` and immediately tearing the
transport down is the same as not sending one.
```typescript
enum DisconnectReason {
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
@ -459,7 +379,9 @@ interface DisconnectMessage {
```
**Wallet side** (`WalletConnectionManager`):
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
- `disconnect(id)` sends `UserDisconnect`, then tears the connection down once the publish
settles — bounded, so an unreachable relay cannot hold the socket open. The connection leaves
the registry synchronously. See [wallet.md § Sending disconnect](wallet.md#sending-disconnect).
- Incoming `disconnect` emits a `remoteDisconnect` event
(`connectionId`, `reason`, `message`) and removes the connection.

View file

@ -50,10 +50,12 @@ connect(): Promise<void>
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
disconnect(): Promise<void>
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
// Stop subscription, close the relay pool, mark queue not-ready, update
// lastProcessedTimestamp. Kills any in-flight publish — see below.
relay(message: ProtocolMessage): Promise<void>
// Send a message. Enqueues if relays not ready. Throws if paired key not set.
// Resolves only once the publish has settled against every configured relay.
setPairedPublicKey(key: Uint8Array): void
// Called after key exchange. Enables outbound messages and incoming peer filtering.
@ -78,6 +80,10 @@ regardless (avoiding silent message loss on slow connections).
On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are
held rather than dropped.
Because `disconnect()` closes the pool, it kills any publish still in flight — so anything
sending a final message before tearing down must await the `relay()` first. See
[wallet.md § Sending disconnect](wallet.md#sending-disconnect).
### Replay protection
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated

View file

@ -69,9 +69,11 @@ class WalletConnectionManager extends EventEmitter {
connect(uri: string): string
// Tear down a specific connection, sending a UserDisconnect courtesy message.
// Returns as soon as the connection has left the registry; the relay socket
// closes once the courtesy message is published. See § Sending disconnect.
disconnect(connectionId: string): void
// Tear down all connections.
// Tear down all connections. Each is disconnected independently.
disconnectAll(): void
// Snapshot of all connections for UI rendering.
@ -154,6 +156,28 @@ wallet_discovered=true → set dappDiscovered=true, no further action
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them.
### Sending disconnect
`disconnect(id)` and `disconnectAll()` split teardown into two phases, because the two halves
have opposite timing requirements.
**Synchronous** — the connection is removed from the registry, its pending sign sequences are
released, and `connectionsChanged` is emitted. This cannot wait: `getConnections()` is what the
UI renders, and `connect()` returns the existing connection for a URI that already has one, so a
connection left in the map during teardown would be handed back to a caller as if it were live.
**Deferred** — the relay socket closes only after the courtesy `disconnect` message has been
published. `RelayClient.relay()` resolves after the publish settles against every configured
relay, which is a real round trip; closing the socket before that kills the publish in flight and
the dapp keeps believing the wallet is connected until its own liveness timeout fires.
The deferral is bounded by `DISCONNECT_PUBLISH_TIMEOUT_MS` (5 s). A publish that never settles is
precisely the unreachable-relay case, and a socket that is never closed is a worse failure than a
courtesy message that is never delivered.
Callers do not need to await anything. The observable contract is that state is correct
immediately and delivery is best-effort within the timeout.
### Receiving disconnect
When a `disconnect` message arrives from the dapp:
@ -181,7 +205,7 @@ has already been emitted. The guard is cleared when a response is sent (`sendSig
**SIGHASH enforcement:** The wallet **MUST** sign every input with
`SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the
[SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol
docs for the security rationale. Because the dapp specifies `inputPaths` (which may list multiple keys per input for contract placeholders), the wallet trusts the
docs for the security rationale. Because the dapp specifies `inputPaths`, the wallet trusts the
dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply
invalid and cannot be repurposed).
@ -202,13 +226,6 @@ class MyAdapter implements WalletAdapter {
async signTransaction(request) {
// Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex.
// See protocol.md "SIGHASH requirement" — other sighash flags MUST be rejected.
// For each request.inputPaths entry [inputIndex, pathName, addressIndex, slot?]
// derive the key and, for a contract input, fill the slot-th sig placeholder
// (a 65-zero push) and the slot-th pubkey placeholder (a 33-zero push). slot
// defaults to 0. Only honour slot > 0 / repeated indices if you advertised the
// `multislot` extension (see below).
// Optionally: import { validateSighashFlags } from "@wizardconnect/wallet"; to verify
// after filling placeholders.
return { signedTransactionHex: "..." };
}
}
@ -240,56 +257,3 @@ manager.on("connectionsChanged", () => {
});
```
## Multiple signatures per input (the `multislot` extension)
By default a wallet contributes one signature (and at most one public key) per input. Some contracts
need the wallet to fill **several** `sig`/`pubkey` placeholders in a single input, each with a
different key. This is the `multislot` extension. Implementing it has two halves.
### 1. Advertise support in the handshake
Return the extension key from your adapter's `getExtensions()`. The manager folds whatever you return
into the wallet's `wallet_ready` message at `session["hdwalletv1"].extensions`, which is exactly where
the dapp looks:
```typescript
import { EXT_MULTISLOT } from "@wizardconnect/core";
class MyAdapter implements WalletAdapter {
// ...
getExtensions() {
return { [EXT_MULTISLOT]: {} }; // presence = support; no handshake data needed
}
}
```
A dapp will only send slotted / repeated-index requests to a wallet that advertised this. If you do
not advertise it you keep receiving ordinary single-signature requests and need do nothing else.
> Requires a `@wizardconnect/*` version whose `WalletConnectionManager` folds `getExtensions()` into
> the session (and whose `inputPaths` type carries the optional `slot`). Older `0.1.x` packages
> ignore `getExtensions()`, so the capability silently never negotiates — check your version.
### 2. Fill the slots when signing
For each `inputPaths` entry `[inputIndex, pathName, addressIndex, slot?]`, derive the key and, for a
contract input, fill the **slot-th** `sig` placeholder (a `0x41` + 65-zero push — 64-byte Schnorr sig
plus its sighash-flag byte) and the **slot-th** `pubkey` placeholder (a `0x21` + 33-zero push). `slot`
defaults to `0`. See [protocol.md § Multiple placeholders per input](protocol.md#multiple-placeholders-per-input-the-slot-element)
for the exact byte format and slot-counting rule, and `packages/wallet/src/multislot-signing.test.ts`
for a reference fill (`fillContractInput`).
Three rules are easy to get wrong:
- **Keep every entry** — do **not** build a `Map<inputIndex, key>`; that collapses the per-slot
entries (last-write-wins) and drops signatures. Group into `Map<inputIndex, entry[]>` instead.
- **Compute the sighash once per input** and reuse it for all slots — it covers
`contract.redeemScript`, not the unlocking bytecode you are mutating, so filling one slot does not
change another slot's sighash. Only the signing key differs between slots.
- **Reject if you cannot fill a requested slot** (unmappable path, or no placeholder at that slot) —
return a sign error rather than a transaction with a leftover zero placeholder, which is silently
unspendable.
If you want a post-fill safety check, `validateSighashFlags` from `@wizardconnect/wallet` verifies
that every filled signature uses `SIGHASH_ALL` (it correctly ignores filled pubkey placeholders).

134
package-lock.json generated
View file

@ -1400,15 +1400,15 @@
}
},
"node_modules/@vitest/expect": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"tinyrainbow": "^2.0.0"
},
@ -1417,13 +1417,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "3.2.4",
"@vitest/spy": "3.2.7",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.17"
},
@ -1444,9 +1444,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1457,13 +1457,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "3.2.4",
"@vitest/utils": "3.2.7",
"pathe": "^2.0.3",
"strip-literal": "^3.0.0"
},
@ -1472,13 +1472,13 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.7",
"magic-string": "^0.30.17",
"pathe": "^2.0.3"
},
@ -1487,9 +1487,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1500,13 +1500,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "3.2.4",
"@vitest/pretty-format": "3.2.7",
"loupe": "^3.1.4",
"tinyrainbow": "^2.0.0"
},
@ -1608,16 +1608,16 @@
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/cac": {
@ -2470,9 +2470,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@ -2706,9 +2706,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"funding": [
{
@ -2726,7 +2726,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.17",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@ -3147,14 +3147,14 @@
}
},
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"version": "7.3.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"esbuild": "^0.27.0 || ^0.28.0",
"fdir": "^6.5.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
@ -3246,20 +3246,20 @@
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
"@vitest/mocker": "3.2.4",
"@vitest/pretty-format": "^3.2.4",
"@vitest/runner": "3.2.4",
"@vitest/snapshot": "3.2.4",
"@vitest/spy": "3.2.4",
"@vitest/utils": "3.2.4",
"@vitest/expect": "3.2.7",
"@vitest/mocker": "3.2.7",
"@vitest/pretty-format": "^3.2.7",
"@vitest/runner": "3.2.7",
"@vitest/snapshot": "3.2.7",
"@vitest/spy": "3.2.7",
"@vitest/utils": "3.2.7",
"chai": "^5.2.0",
"debug": "^4.4.1",
"expect-type": "^1.2.1",
@ -3289,8 +3289,8 @@
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
"@vitest/browser": "3.2.4",
"@vitest/ui": "3.2.4",
"@vitest/browser": "3.2.7",
"@vitest/ui": "3.2.7",
"happy-dom": "*",
"jsdom": "*"
},
@ -3372,9 +3372,9 @@
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"peer": true,
"engines": {
@ -3408,7 +3408,7 @@
},
"packages/core": {
"name": "@wizardconnect/core",
"version": "0.1.2",
"version": "0.2.0",
"dependencies": {
"@bch-wc2/interfaces": "^0.0.8",
"@bitauth/libauth": "^3.1.0-next.2",
@ -3416,28 +3416,28 @@
"isomorphic-ws": "^5.0.0",
"lossless-json": "^4.3.0",
"nostr-tools": "^2.23.0",
"ws": "^8.18.0"
"ws": "^8.21.3"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
},
"packages/dapp": {
"name": "@wizardconnect/dapp",
"version": "0.1.2",
"version": "0.2.0",
"dependencies": {
"@wizardconnect/core": "*",
"eventemitter3": "^5.0.1"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
},
"packages/react": {
"name": "@wizardconnect/react",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@wizardconnect/core": "*",
"@wizardconnect/dapp": "*",
@ -3450,7 +3450,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
},
"peerDependencies": {
"react": ">=18.0.0",
@ -3511,7 +3511,7 @@
},
"packages/test-cli": {
"name": "@wizardconnect/test-cli",
"version": "0.1.0",
"version": "0.2.0",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
@ -3531,7 +3531,7 @@
},
"packages/wallet": {
"name": "@wizardconnect/wallet",
"version": "0.1.2",
"version": "0.2.0",
"dependencies": {
"@bitauth/libauth": "^3.1.0-next.2",
"@wizardconnect/core": "*",
@ -3539,7 +3539,7 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}
}

View file

@ -40,10 +40,10 @@
"eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0",
"lossless-json": "^4.3.0",
"ws": "^8.18.0"
"ws": "^8.21.3"
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}

View file

@ -25,45 +25,6 @@ describe("isSignTransactionRequest", () => {
expect(isSignTransactionRequest({ ...valid, inputPaths: [] })).toBe(true);
});
it("accepts 4-tuple entries carrying a slot index (multislot)", () => {
const multi = {
...valid,
inputPaths: [
[0, "receive", 0, 0], // input 0, slot 0
[0, "defi", 7, 1], // same input 0, slot 1 — a different key
[1, "change", 3], // 3-tuple still allowed alongside 4-tuples
],
};
expect(isSignTransactionRequest(multi)).toBe(true);
});
it("rejects a non-integer slot", () => {
expect(
isSignTransactionRequest({
...valid,
inputPaths: [[0, "receive", 0, 1.5]],
}),
).toBe(false);
});
it("rejects a negative slot", () => {
expect(
isSignTransactionRequest({
...valid,
inputPaths: [[0, "receive", 0, -1]],
}),
).toBe(false);
});
it("rejects a 5-element tuple", () => {
expect(
isSignTransactionRequest({
...valid,
inputPaths: [[0, "receive", 0, 1, 9]],
}),
).toBe(false);
});
it("rejects missing inputPaths", () => {
const noInputPaths = { ...valid } as Record<string, unknown>;
delete noInputPaths.inputPaths;

View file

@ -48,13 +48,6 @@ export const PATH_DEFI = "defi" as const;
export type PathName = string;
/// Extension name advertised by wallets that can fill more than one sig/pubkey
/// placeholder per input (via the optional `slot` element of inputPaths).
/// Presence in `Hdwalletv1Session.extensions` indicates support. Dapps MUST
/// only send slotted / multi-entry-per-input requests to wallets that advertise
/// it. See docs/extensions.md § multislot and docs/protocol.md.
export const EXT_MULTISLOT = "multislot" as const;
export interface PathXpub {
name: PathName;
xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally)
@ -141,49 +134,11 @@ export interface ErrorMessage extends ProtocolMessage {
error: string;
}
export interface TxSummaryTokenChange {
categoryId: string; // hex token category ID
fungibleAmount: string; // signed bigint as string (negative = spending, positive = receiving)
nftCount?: number;
symbol?: string;
decimals?: number;
}
export interface TxSummary {
netBchSats: string; // signed bigint as string (negative = spending, positive = receiving)
tokenChanges: TxSummaryTokenChange[];
}
export interface SignTransactionRequest extends ProtocolMessage {
action: RelayMsgAction.SignTransactionRequest;
transaction: WcSignTransactionRequest;
sequence: number;
/**
* List of [inputIndex, pathName, addressIndex, slot?] tuples naming the HD
* key(s) the wallet must contribute to each input.
*
* For a P2PKH input one entry suffices (slot defaults to 0): the wallet
* derives that key and produces the input's single signature.
*
* A contract input may carry several sig/pubkey placeholders, each filled by
* a different key. The dapp lists the input's index once per placeholder pair
* and sets `slot` to select which pair this key fills. `slot` counts sig
* placeholders and pubkey placeholders independently, left-to-right, starting
* at 0; the wallet fills the slot-th sig placeholder (and the slot-th pubkey
* placeholder, if one exists) with the derived key. All signatures in one
* input commit to the same sighash, so they differ only by key.
*
* `slot` is OPTIONAL and defaults to 0, so existing 3-tuples are unchanged.
* Sending any entry with slot > 0 (or more than one entry per inputIndex)
* requires the wallet to advertise the `multislot` extension (EXT_MULTISLOT)
* in its wallet_ready handshake; dapps MUST check for it first and MUST NOT
* downgrade to a single signature otherwise. See docs/protocol.md and
* docs/extensions.md.
*/
inputPaths: [number, PathName, number, number?][]; // [inputIndex, pathName, addressIndex, slot?]
/// Dapp-provided transaction summary. Wallets should use this for display
/// when present rather than parsing the raw transaction themselves.
txSummary?: TxSummary;
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex]
}
export interface SignTransactionResponse extends ProtocolMessage {
@ -244,13 +199,10 @@ export function isSignTransactionRequest(
msg.inputPaths.every(
(p: any) =>
Array.isArray(p) &&
(p.length === 3 || p.length === 4) &&
p.length === 3 &&
typeof p[0] === "number" &&
typeof p[1] === "string" &&
typeof p[2] === "number" &&
// optional slot: non-negative integer
(p.length === 3 ||
(typeof p[3] === "number" && Number.isInteger(p[3]) && p[3] >= 0)),
typeof p[2] === "number",
)
);
}

View file

@ -29,6 +29,6 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}

View file

@ -39,6 +39,6 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}

View file

@ -31,6 +31,6 @@
},
"devDependencies": {
"typescript": "^5.6.2",
"vitest": "^3.2.3"
"vitest": "^3.2.7"
}
}

View file

@ -14,4 +14,3 @@ export type {
PendingSignRequest,
WalletConnectionManagerEvents,
} from "./wallet-connection-manager.js";
export { isP2PKH, validateSighashFlags } from "./sighash-validation.js";

View file

@ -0,0 +1,98 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
/**
* Wallet-initiated disconnect must actually reach the dapp.
*
* disconnect.test.ts covers the other direction the dapp sends `disconnect`
* and the wallet reacts. Nothing covered wallet dapp, which is how this got
* shipped broken: doDisconnect fired the courtesy message without awaiting it and
* then tore the relay connection down, so the publish died mid-flight and the
* dapp went on believing the wallet was connected until its own liveness
* timeout. Downstream wallets were carrying a patch for it.
*
* These tests are worth their runtime because the failure is invisible locally
* the wallet's own state is correct either way, and only the peer notices.
*/
import { describe, it, expect, afterEach } from "vitest";
import { RelayMsgAction, DisconnectReason } from "@wizardconnect/core";
import type { DisconnectMessage, ProtocolMessage } from "@wizardconnect/core";
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
let handles: ConnectionHandles | null = null;
afterEach(() => {
handles?.cleanup();
handles = null;
});
/** Disconnect messages the dapp actually received off the relay. */
function disconnectsSeenByDapp(h: ConnectionHandles): DisconnectMessage[] {
return h.dapp.messages.filter(
(msg: ProtocolMessage) => msg.action === RelayMsgAction.Disconnect,
) as DisconnectMessage[];
}
describe("wallet-initiated disconnect", () => {
it("delivers the courtesy disconnect to the dapp", async () => {
handles = await setupConnection();
expect(disconnectsSeenByDapp(handles)).toHaveLength(0);
handles.wallet.manager.disconnect(handles.wallet.connectionId);
// The whole point: this arrives over a real relay, which it cannot do if the
// connection is torn down while the publish is still in flight.
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
timeoutMs: 20000,
what: "disconnect message received by the dapp",
});
expect(disconnectsSeenByDapp(handles)[0].reason).toBe(
DisconnectReason.UserDisconnect,
);
});
it("removes the connection immediately, without waiting for delivery", async () => {
// Teardown is deferred, but the registry must not be: a caller that
// disconnects and then inspects state should never see the dying connection.
handles = await setupConnection();
const { manager, connectionId } = handles.wallet;
expect(Object.keys(manager.getConnections())).toContain(connectionId);
manager.disconnect(connectionId);
expect(Object.keys(manager.getConnections())).not.toContain(connectionId);
});
it("delivers a disconnect for every connection in disconnectAll", async () => {
handles = await setupConnection();
handles.wallet.manager.disconnectAll();
await waitFor(() => disconnectsSeenByDapp(handles!).length > 0, {
timeoutMs: 20000,
what: "disconnect message from disconnectAll",
});
expect(Object.keys(handles.wallet.manager.getConnections())).toHaveLength(
0,
);
});
it("lets the dapp reconnect on the same URI afterwards", async () => {
// Deferring teardown must not leave the URI unusable — connect() returns an
// existing connection for a URI, so a stale entry would be handed back.
handles = await setupConnection();
const { manager, connectionId } = handles.wallet;
manager.disconnect(connectionId);
const reconnectedId = manager.connect(handles.dapp.uri);
expect(reconnectedId).not.toBe(connectionId);
expect(Object.keys(manager.getConnections())).toContain(reconnectedId);
manager.disconnect(reconnectedId);
});
});

View file

@ -205,10 +205,38 @@ export async function setupConnection(
}
});
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
// Prompt one more wallet_ready, now that the handler above is registered.
// The wallet_ready that completed key exchange was consumed by
// initiateDappRelay before this handler existed, so it is not in
// walletReadyMessages and the caller's "wallet_ready with paths" wait
// needs a fresh one. dapp_discovered=false is what makes the wallet
// re-send.
await sendDappReady(false);
});
// Re-announce until key exchange completes.
//
// The wallet sends exactly one wallet_ready per connection cycle, and it
// fires as soon as manager.connect() resolves — which can be before this
// dapp's relay subscription is live. If that single message is missed there
// is nothing to retry against: keyexchangecomplete never fires, so the
// handler above never registers and no dapp_ready is ever sent. The suite
// then sat until the 15s "key exchange" timeout. Under singleFork the
// previous file's teardown is still closing sockets while this runs, which
// is exactly when the race is won by the wrong side.
//
// dapp_ready(wallet_discovered=false) resets walletReadySentThisCycle on the
// wallet, so each retry earns another wallet_ready. This is the recovery path
// the protocol's mutual-discovery design already specifies — the harness
// simply was not using it.
const reannounce = setInterval(() => {
if (keyExchanged) {
clearInterval(reannounce);
return;
}
if (dappClient) sendDappReady(false).catch(() => {});
}, 2000);
// ---- Wallet side ----
const manager = new WalletConnectionManager(adapter);
@ -216,7 +244,16 @@ export async function setupConnection(
// ---- Wait for key exchange ----
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
try {
await waitFor(() => keyExchanged, {
timeoutMs: 15000,
what: "key exchange",
});
} finally {
// Must not outlive the wait: on timeout a leaked interval keeps publishing
// dapp_ready into later tests and holds the fork open.
clearInterval(reannounce);
}
// ---- Wait for wallet_ready with paths ----

View file

@ -137,37 +137,6 @@ describe("WalletConnectionManager — sign_transaction_request with inputPaths",
]);
}, 15000);
it("wallet receives repeated inputIndex with slots (no dedup) for a multi-placeholder contract input", async () => {
const signMsg: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
sequence: 3,
transaction: {
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
sourceOutputs: [],
userPrompt: "multi-key contract test",
broadcast: false,
},
// multislot: input 0 appears once per placeholder slot, each routed to a
// different key. The relay/manager must forward these verbatim — no dedup.
inputPaths: [
[0, "receive", 0, 0], // input 0, slot 0 -> receive/0
[0, "defi", 7, 1], // input 0, slot 1 -> defi/7
],
time: Math.floor(Date.now() / 1000),
};
await dappClient!.relay(signMsg);
await waitFor(() => pendingRequests.length >= 2, {
timeoutMs: 5000,
what: "second pendingSignRequest with repeated inputPaths",
});
expect(pendingRequests[1].request.inputPaths).toEqual([
[0, "receive", 0, 0],
[0, "defi", 7, 1],
]);
}, 15000);
it("wallet receives empty inputPaths for zero-input transaction", async () => {
const signMsg: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
@ -182,11 +151,11 @@ describe("WalletConnectionManager — sign_transaction_request with inputPaths",
};
await dappClient!.relay(signMsg);
await waitFor(() => pendingRequests.length >= 3, {
await waitFor(() => pendingRequests.length >= 2, {
timeoutMs: 5000,
what: "third pendingSignRequest event on wallet",
what: "second pendingSignRequest event on wallet",
});
expect(pendingRequests[2].request.inputPaths).toEqual([]);
expect(pendingRequests[1].request.inputPaths).toEqual([]);
}, 15000);
});

View file

@ -1,225 +0,0 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect } from "vitest";
import {
secp256k1,
sha256,
encodeTransaction,
binToHex,
hexToBin,
} from "@bitauth/libauth";
import { validateSighashFlags } from "./sighash-validation.js";
/**
* Reference implementation + tests for the `multislot` extension: a contract
* input with more than one sig/pubkey placeholder, each filled by a different
* key, routed by the `slot` element of inputPaths ([inputIndex, pathName,
* addressIndex, slot]).
*
* `fillContractInput` below mirrors what a WalletAdapter does: for each
* inputPaths entry it fills the slot-th sig placeholder (and slot-th pubkey
* placeholder) of the input. It locates placeholders in the ORIGINAL template
* so the result is independent of fill order. See docs/protocol.md §
* "Multiple placeholders per input (the slot element)".
*
* SIGHASH: all signatures within ONE input commit to the SAME sighash (same
* input, same covered contract.redeemScript, same flags) only the signing key
* differs between slots. The wallet computes that sighash ONCE per input and
* reuses it for every slot. Below, `inputSighash` is a fixed stand-in for that
* value (these tests validate slot ROUTING and sighash-flag handling via the
* production `validateSighashFlags`, not consensus signature validity).
*/
// SIGHASH_ALL | SIGHASH_FORKID. 0x61 (… | SIGHASH_UTXOS) is also valid and is
// what production wallets use; validateSighashFlags accepts both {0x41, 0x61}.
const SIGHASH = 0x41;
// Placeholder pushes the dapp embeds in the unsigned unlocking bytecode.
const SIG_PLACEHOLDER = "41" + "00".repeat(65); // push 65 zero bytes (64 sig + 1 sighash)
const PUBKEY_PLACEHOLDER = "21" + "00".repeat(33); // push 33 zero bytes
// A minimal "contract" (P2SH) lock so validateSighashFlags takes the
// template-diff path rather than the P2PKH path.
const contractLock = Uint8Array.from([0xa9, 0x14, ...new Uint8Array(20), 0x87]);
function pub(priv: Uint8Array): Uint8Array {
const p = secp256k1.derivePublicKeyCompressed(priv);
if (typeof p === "string") throw new Error(p);
return p;
}
/** Char offsets of every occurrence of `needle` in `hex`. */
function occurrences(hex: string, needle: string): number[] {
const out: number[] = [];
let from = 0;
for (;;) {
const at = hex.indexOf(needle, from);
if (at === -1) break;
out.push(at);
from = at + needle.length;
}
return out;
}
interface SlotEntry {
slot: number;
sig: Uint8Array;
pubkey?: Uint8Array;
}
/**
* Wallet-side fill of a contract input's unlocking bytecode. For each entry,
* write its signature into the slot-th sig placeholder and its public key into
* the slot-th pubkey placeholder (if present).
*
* Placeholder positions are located ONCE in the original template; because each
* replacement is the same length as the placeholder it replaces, those offsets
* stay valid as we fill so the result does not depend on entry order.
*
* Returns the filled bytecode plus how many sig/pubkey placeholders existed, so
* the caller can confirm every requested slot was filled. A real adapter MUST
* reject the request rather than return an under-filled (unspendable) tx see
* the "rejects" test below.
*/
function fillContractInput(
templateHex: string,
entries: SlotEntry[],
): { hex: string; sigCount: number; pubkeyCount: number } {
const sigAt = occurrences(templateHex, SIG_PLACEHOLDER);
const pkAt = occurrences(templateHex, PUBKEY_PLACEHOLDER);
let out = templateHex;
for (const e of entries) {
if (e.slot < sigAt.length) {
const at = sigAt[e.slot];
out =
out.slice(0, at) +
"41" +
binToHex(e.sig) +
out.slice(at + SIG_PLACEHOLDER.length);
}
if (e.pubkey && e.slot < pkAt.length) {
const at = pkAt[e.slot];
out =
out.slice(0, at) +
"21" +
binToHex(e.pubkey) +
out.slice(at + PUBKEY_PLACEHOLDER.length);
}
}
return { hex: out, sigCount: sigAt.length, pubkeyCount: pkAt.length };
}
function buildSignedTxHex(unlocking: Uint8Array): string {
const tx = encodeTransaction({
version: 2,
locktime: 0,
inputs: [
{
outpointTransactionHash: new Uint8Array(32),
outpointIndex: 0,
unlockingBytecode: unlocking,
sequenceNumber: 0xffffffff,
},
],
outputs: [{ lockingBytecode: Uint8Array.of(0x6a), valueSatoshis: 0n }],
});
return binToHex(tx);
}
describe("multislot signing", () => {
const kReceive = new Uint8Array(32).fill(0x11); // as if receive/0
const kDefi = new Uint8Array(32).fill(0x22); // as if defi/7
// One sighash for the whole input — every slot signs THIS, only the key
// differs. (A real wallet derives it once via generateSigningSerializationBCH
// over the input's covered contract.redeemScript.)
const inputSighash = sha256.hash(Uint8Array.of(0xde, 0xad, 0xbe, 0xef));
function sign(priv: Uint8Array): Uint8Array {
const sig = secp256k1.signMessageHashSchnorr(priv, inputSighash);
if (typeof sig === "string") throw new Error(sig);
return Uint8Array.from([...sig, SIGHASH]); // 64 + 1 == 65, matches placeholder
}
// Template with two (sig, pubkey) placeholder pairs.
const twoPairs =
SIG_PLACEHOLDER + PUBKEY_PLACEHOLDER + SIG_PLACEHOLDER + PUBKEY_PLACEHOLDER;
it("routes each slot to its key (sig and pubkey), independent of entry order", () => {
// inputPaths: [0,"receive",0,0] and [0,"defi",7,1]. Pass them out of slot
// order to prove the fill is order-independent.
const { hex } = fillContractInput(twoPairs, [
{ slot: 1, sig: sign(kDefi), pubkey: pub(kDefi) },
{ slot: 0, sig: sign(kReceive), pubkey: pub(kReceive) },
]);
const signed = hexToBin(hex);
// slot 0 sig (bytes 1..64) verifies against RECEIVE, not DEFI — over the
// SAME shared input sighash.
const sig0 = signed.slice(1, 65);
expect(signed[65]).toBe(SIGHASH);
expect(secp256k1.verifySignatureSchnorr(sig0, pub(kReceive), inputSighash)).toBe(true);
expect(secp256k1.verifySignatureSchnorr(sig0, pub(kDefi), inputSighash)).toBe(false);
// slot 1 sig (bytes 101..164) verifies against DEFI — same sighash.
const sig1 = signed.slice(101, 165);
expect(signed[165]).toBe(SIGHASH);
expect(secp256k1.verifySignatureSchnorr(sig1, pub(kDefi), inputSighash)).toBe(true);
// pubkey placeholders received the matching keys in slot order.
expect(binToHex(signed.slice(67, 100))).toBe(binToHex(pub(kReceive)));
expect(binToHex(signed.slice(167, 200))).toBe(binToHex(pub(kDefi)));
});
it("validateSighashFlags accepts the multi-slot signed input (repeated index)", () => {
const { hex } = fillContractInput(twoPairs, [
{ slot: 0, sig: sign(kReceive), pubkey: pub(kReceive) },
{ slot: 1, sig: sign(kDefi), pubkey: pub(kDefi) },
]);
const signedHex = buildSignedTxHex(hexToBin(hex));
// The dapp repeats input index 0, one entry per slot. The wallet MUST NOT
// deduplicate by index (that would drop a signature).
const inputPaths: [number, string, number, number][] = [
[0, "receive", 0, 0],
[0, "defi", 7, 1],
];
const walletInputIndices = inputPaths.map(([i]) => i); // [0, 0]
expect(() =>
validateSighashFlags(
signedHex,
walletInputIndices,
[contractLock],
[hexToBin(twoPairs)],
),
).not.toThrow();
});
it("slot 0 (default) fills the first placeholder — back-compatible single-sig", () => {
const { hex } = fillContractInput(twoPairs, [
{ slot: 0, sig: sign(kReceive), pubkey: pub(kReceive) },
]);
const signed = hexToBin(hex);
// first pair filled...
expect(binToHex(signed.slice(67, 100))).toBe(binToHex(pub(kReceive)));
// ...second pair untouched (still zero placeholders).
expect(Array.from(signed.slice(101, 166)).every((b) => b === 0)).toBe(true);
expect(Array.from(signed.slice(167, 200)).every((b) => b === 0)).toBe(true);
});
it("an adapter must reject a slot it cannot fill (no such placeholder)", () => {
const onePair = SIG_PLACEHOLDER + PUBKEY_PLACEHOLDER;
const { hex, sigCount } = fillContractInput(onePair, [
{ slot: 1, sig: sign(kDefi), pubkey: pub(kDefi) },
]);
// The low-level fill is a no-op for the absent slot...
expect(hex).toBe(onePair);
// ...so the adapter detects slot >= sigCount and MUST reject rather than
// return an unspendable transaction with a leftover zero placeholder.
const requestedSlot = 1;
expect(requestedSlot >= sigCount).toBe(true);
});
});

View file

@ -1,285 +0,0 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { describe, it, expect } from "vitest";
import {
isP2PKH,
validateSighashFlags,
} from "./sighash-validation.js";
import {
binToHex,
encodeTransaction,
hash160,
sha256,
} from "@bitauth/libauth";
// Minimal P2PKH locking script: OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
const p2pkhLock = Uint8Array.from([
0x76, 0xa9, 0x14,
...new Uint8Array(20), // hash160 placeholder
0x88, 0xac,
]);
// Non-P2PKH (contract) locking: something else, e.g. P2SH
const contractLock = Uint8Array.from([0xa9, 0x14, ...new Uint8Array(20), 0x87]);
describe("isP2PKH", () => {
it("recognizes exact P2PKH template", () => {
expect(isP2PKH(p2pkhLock)).toBe(true);
});
it("rejects wrong length", () => {
expect(isP2PKH(new Uint8Array(24))).toBe(false);
});
it("rejects wrong prefix", () => {
const bad = Uint8Array.from(p2pkhLock);
bad[0] = 0x00;
expect(isP2PKH(bad)).toBe(false);
});
it("rejects wrong suffix", () => {
const bad = Uint8Array.from(p2pkhLock);
bad[24] = 0x00;
expect(isP2PKH(bad)).toBe(false);
});
});
describe("validateSighashFlags", () => {
it("does nothing for empty walletInputIndices", () => {
expect(() => validateSighashFlags("00", [], [p2pkhLock])).not.toThrow();
});
it("rejects input index out of range", () => {
const prevHash = new Uint8Array(32);
const unlocking = new Uint8Array([0x00]); // minimal
const tx = encodeTransaction({
version: 2,
locktime: 0,
inputs: [
{
outpointTransactionHash: prevHash,
outpointIndex: 0,
unlockingBytecode: unlocking,
sequenceNumber: 0xffffffff,
},
],
outputs: [
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
],
});
const hex = binToHex(tx);
expect(() =>
validateSighashFlags(hex, [5], [p2pkhLock]),
).toThrow(/inputPaths references input 5/);
});
it("skips contract input when no unsignedBytecode template provided", () => {
const prevHash = new Uint8Array(32);
const unlocking = new Uint8Array([0x00]);
const tx = encodeTransaction({
version: 2,
locktime: 0,
inputs: [
{
outpointTransactionHash: prevHash,
outpointIndex: 0,
unlockingBytecode: unlocking,
sequenceNumber: 0xffffffff,
},
],
outputs: [
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
],
});
const hex = binToHex(tx);
expect(() =>
validateSighashFlags(hex, [0], [contractLock], []),
).not.toThrow();
});
it("accepts contract input with allowed sighash extracted from placeholder diff (single sig)", () => {
// Build minimal tx structure. We only care about the unlocking bytecode bytes for extraction.
// A dummy 1-in 1-out tx.
const prevHash = new Uint8Array(32); // all zero ok for test
const unsignedTemplate = new Uint8Array([
0x41, // push 65 bytes (sig placeholder length for schnorr+hash)
...new Uint8Array(65), // placeholder zeros for <sig>
]);
// "Signed" version: differ starting at first data byte (to trigger extract at push start)
const signedUnlock = new Uint8Array(unsignedTemplate.length);
signedUnlock.set(unsignedTemplate);
signedUnlock[1] = 0x01; // first data byte non-zero (real sigs start non-zero)
// Put a fake sighash in the last data byte
signedUnlock[65] = 0x41; // the sighash byte position (pushLen pos + pushLen)
const tx = encodeTransaction({
version: 2,
locktime: 0,
inputs: [
{
outpointTransactionHash: prevHash,
outpointIndex: 0,
unlockingBytecode: signedUnlock,
sequenceNumber: 0xffffffff,
},
],
outputs: [
{
lockingBytecode: new Uint8Array([0x6a]), // OP_RETURN dummy
valueSatoshis: 0n,
},
],
});
const signedHex = binToHex(tx);
// locking for input0 is contract, unsigned provided
expect(() =>
validateSighashFlags(signedHex, [0], [contractLock], [unsignedTemplate]),
).not.toThrow();
});
it("accepts contract input with multiple sig placeholders (repeated input index case)", () => {
const prevHash = new Uint8Array(32);
// Template has two sig slots: first 65-byte, second 70-byte (der-like)
const unsignedTemplate = new Uint8Array([
0x41,
...new Uint8Array(65),
0x46,
...new Uint8Array(70),
]);
const signedUnlock = new Uint8Array(unsignedTemplate.length);
signedUnlock.set(unsignedTemplate);
// Make first data bytes differ so extract detects start of each push data
signedUnlock[1] = 0x01;
signedUnlock[66 + 1] = 0x02; // after first push: 0:len1,1-65:data1,66:len2,67...:data2
// first sig's sighash at end of its data
signedUnlock[65] = 0x41;
// second sig's sighash
signedUnlock[66 + 70] = 0x61; // 0x61 also allowed
const tx = encodeTransaction({
version: 2,
locktime: 0,
inputs: [
{
outpointTransactionHash: prevHash,
outpointIndex: 0,
unlockingBytecode: signedUnlock,
sequenceNumber: 0xffffffff,
},
],
outputs: [
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
],
});
const signedHex = binToHex(tx);
expect(() =>
validateSighashFlags(signedHex, [0], [contractLock], [unsignedTemplate]),
).not.toThrow();
});
it("ignores a filled pubkey placeholder (not mis-read as a signature)", () => {
const prevHash = new Uint8Array(32);
// Template: one sig placeholder (65) + one pubkey placeholder (33).
const unsignedTemplate = new Uint8Array([
0x41,
...new Uint8Array(65),
0x21,
...new Uint8Array(33),
]);
const signedUnlock = Uint8Array.from(unsignedTemplate);
// Fill the sig: first data byte differs, sighash byte at end of its push.
signedUnlock[1] = 0x01;
signedUnlock[65] = 0x41;
// Fill the pubkey: compressed pubkeys start with 0x02/0x03; the LAST byte
// (0xaa here) must NOT be checked as a sighash flag.
signedUnlock[67] = 0x02;
signedUnlock[66 + 33] = 0xaa; // last pubkey byte
const tx = encodeTransaction({
version: 2,
locktime: 0,
inputs: [
{
outpointTransactionHash: prevHash,
outpointIndex: 0,
unlockingBytecode: signedUnlock,
sequenceNumber: 0xffffffff,
},
],
outputs: [{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n }],
});
const signedHex = binToHex(tx);
expect(() =>
validateSighashFlags(signedHex, [0], [contractLock], [unsignedTemplate]),
).not.toThrow();
});
it("accepts P2PKH input with allowed sighash 0x41", () => {
const prevHash = new Uint8Array(32);
const sigPushLen = 0x41;
const sigData = new Uint8Array(65);
sigData[64] = 0x41; // sighash at end
const pubkeyPush = new Uint8Array([0x21, ...new Uint8Array(33)]);
const unlocking = new Uint8Array([
sigPushLen,
...sigData,
...pubkeyPush,
]);
const tx = encodeTransaction({
version: 2,
locktime: 0,
inputs: [
{
outpointTransactionHash: prevHash,
outpointIndex: 0,
unlockingBytecode: unlocking,
sequenceNumber: 0xffffffff,
},
],
outputs: [
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
],
});
const signedHex = binToHex(tx);
expect(() =>
validateSighashFlags(signedHex, [0], [p2pkhLock]),
).not.toThrow();
});
it("rejects disallowed sighash in contract multi-sig slot", () => {
const prevHash = new Uint8Array(32);
const unsignedTemplate = new Uint8Array([0x41, ...new Uint8Array(65)]);
const signedUnlock = new Uint8Array(unsignedTemplate.length);
signedUnlock.set(unsignedTemplate);
signedUnlock[1] = 0x01; // first data to trigger extract at push start
signedUnlock[65] = 0x01; // bad sighash at end
const tx = encodeTransaction({
version: 2,
locktime: 0,
inputs: [
{
outpointTransactionHash: prevHash,
outpointIndex: 0,
unlockingBytecode: signedUnlock,
sequenceNumber: 0xffffffff,
},
],
outputs: [
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
],
});
const signedHex = binToHex(tx);
expect(() =>
validateSighashFlags(signedHex, [0], [contractLock], [unsignedTemplate]),
).toThrow(/disallowed sighash flag 0x01/);
});
});

View file

@ -1,189 +0,0 @@
// Copyright (C) 2026 Whiterun LLC,
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { decodeTransaction, hexToBin } from "@bitauth/libauth";
/**
* Allowed sighash flag combinations for wallet-signed inputs.
* - 0x41 = SIGHASH_ALL | SIGHASH_FORKID
* - 0x61 = SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS
*/
const ALLOWED_SIGHASH = new Set([0x41, 0x61]);
/** P2PKH locking bytecode template: OP_DUP OP_HASH160 OP_PUSH20 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG */
const P2PKH_PREFIX = Uint8Array.from([0x76, 0xa9, 0x14]);
const P2PKH_SUFFIX = Uint8Array.from([0x88, 0xac]);
const P2PKH_LENGTH = 25;
/**
* Returns true if the locking bytecode matches the P2PKH template:
* `OP_DUP OP_HASH160 OP_PUSH20 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG`
*/
export function isP2PKH(lockingBytecode: Uint8Array): boolean {
if (lockingBytecode.length !== P2PKH_LENGTH) return false;
for (let i = 0; i < P2PKH_PREFIX.length; i++) {
if (lockingBytecode[i] !== P2PKH_PREFIX[i]) return false;
}
for (let i = 0; i < P2PKH_SUFFIX.length; i++) {
if (
lockingBytecode[P2PKH_LENGTH - P2PKH_SUFFIX.length + i] !==
P2PKH_SUFFIX[i]
)
return false;
}
return true;
}
/**
* Extract the sighash byte from a P2PKH unlocking bytecode.
*
* P2PKH format: `<push_len> <DER_sig ++ sighash_byte> <push_len> <pubkey>`
*
* The sighash byte is the last byte of the first push data. Works for both
* DER signatures (7073 bytes) and Schnorr signatures (65 bytes) since both
* append the sighash byte.
*/
function extractP2PKHSighashByte(
unlockingBytecode: Uint8Array,
inputIndex: number,
): number {
if (unlockingBytecode.length < 2) {
throw new Error(
`Input ${inputIndex}: unlocking bytecode too short to contain a signature`,
);
}
const pushLen = unlockingBytecode[0];
if (pushLen === 0 || pushLen >= 0x4c) {
throw new Error(
`Input ${inputIndex}: unexpected first push length 0x${pushLen.toString(16).padStart(2, "0")}`,
);
}
if (unlockingBytecode.length < pushLen + 1) {
throw new Error(
`Input ${inputIndex}: unlocking bytecode truncated (need ${pushLen + 1} bytes, have ${unlockingBytecode.length})`,
);
}
return unlockingBytecode[pushLen];
}
/**
* Find signatures in a contract input by comparing the unsigned template to
* the signed result. The template uses a full-length placeholder (e.g. 65
* zero bytes) for each signature slot, so both bytecodes have the same length
* and push structure. We walk byte-by-byte to find regions that differ; each
* differing region is a filled signature whose last byte is the sighash flag.
*
* Supports multiple signature slots per input (common for contract inputs with
* several sig/pubkey placeholders from different keys listed via repeated
* inputIndex in inputPaths).
*/
function extractContractSighashBytes(
template: Uint8Array,
signed: Uint8Array,
inputIndex: number,
): number[] {
if (template.length !== signed.length) {
throw new Error(
`Input ${inputIndex}: template length (${template.length}) != signed length (${signed.length})`,
);
}
const sighashBytes: number[] = [];
let i = 0;
while (i < template.length) {
if (template[i] !== signed[i]) {
// We're inside a filled placeholder push. The push length byte is at
// i - 1 (same in both, since only the data content differs).
const pushLen = template[i - 1];
const sighashPos = i - 1 + pushLen;
// Only signature pushes carry a trailing sighash-flag byte: a signed
// Schnorr sig is 65 bytes (64 + sighash), DER ECDSA is 7173. Other
// filled placeholders (pubkey = 33, pubkeyhash = 20) have no sighash
// flag — skip them so we don't mis-read their last byte as one.
const isSignature = pushLen === 65 || (pushLen >= 71 && pushLen <= 73);
if (isSignature) {
sighashBytes.push(signed[sighashPos]);
}
// Skip past this push either way.
i = sighashPos + 1;
} else {
i++;
}
}
return sighashBytes;
}
function checkSighash(sighash: number, inputIndex: number): void {
if (!ALLOWED_SIGHASH.has(sighash)) {
throw new Error(
`Input ${inputIndex} uses disallowed sighash flag 0x${sighash.toString(16).padStart(2, "0")}. ` +
`Only SIGHASH_ALL|FORKID (0x41) and SIGHASH_ALL|FORKID|UTXOS (0x61) are allowed.`,
);
}
}
/**
* Validate that all wallet-signed inputs use an allowed sighash type.
*
* For P2PKH inputs, the signature is extracted from the first push of the
* signed unlocking bytecode.
*
* For contract inputs, the unsigned template (from the sign request) is
* compared byte-by-byte against the signed result to locate filled signature
* slots and check their sighash bytes. Supports multiple filled sig slots per
* input (when dapp sent repeated inputIndex entries in inputPaths for that input).
*
* @param signedTxHex - hex-encoded signed transaction
* @param walletInputIndices - distinct input indices the wallet signed (from inputPaths; dedup if your
* extraction produces duplicates, though re-validation is harmless)
* @param lockingBytecodes - locking bytecodes for each input (from sourceOutputs)
* @param unsignedBytecodes - unsigned unlocking bytecodes for each input (from the
* sign request's transaction template). Empty for P2PKH, populated for contracts.
* @throws if any wallet-signed input uses a disallowed sighash flag
*/
export function validateSighashFlags(
signedTxHex: string,
walletInputIndices: number[],
lockingBytecodes: Uint8Array[],
unsignedBytecodes?: Uint8Array[],
): void {
if (walletInputIndices.length === 0) return;
const txBin = hexToBin(signedTxHex);
const decoded = decodeTransaction(txBin);
if (typeof decoded === "string") {
throw new Error(`Failed to decode signed transaction: ${decoded}`);
}
for (const inputIndex of walletInputIndices) {
if (inputIndex >= decoded.inputs.length) {
throw new Error(
`inputPaths references input ${inputIndex}, but transaction only has ${decoded.inputs.length} inputs`,
);
}
const signedBytecode = decoded.inputs[inputIndex].unlockingBytecode;
if (isP2PKH(lockingBytecodes[inputIndex])) {
const sighash = extractP2PKHSighashByte(signedBytecode, inputIndex);
checkSighash(sighash, inputIndex);
continue;
}
// Contract input — compare template to signed result
const unsignedBytecode = unsignedBytecodes?.[inputIndex];
if (!unsignedBytecode || unsignedBytecode.length === 0) {
continue; // No template available, skip
}
const sighashBytes = extractContractSighashBytes(
unsignedBytecode,
signedBytecode,
inputIndex,
);
for (const sighash of sighashBytes) {
checkSighash(sighash, inputIndex);
}
}
}

View file

@ -45,6 +45,15 @@ export interface PendingSignRequest {
request: SignTransactionRequest;
}
/**
* How long doDisconnect waits for the courtesy `disconnect` message to be
* published before tearing the relay connection down anyway.
*
* Generous enough for a slow relay, short enough that an unreachable one cannot
* hold the socket open indefinitely.
*/
const DISCONNECT_PUBLISH_TIMEOUT_MS = 5000;
interface ActiveConnection {
id: string;
uri: string;
@ -217,22 +226,58 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
const conn = this.connections.get(connectionId);
if (!conn) return;
if (sendMessage && conn.client) {
const disconnectMsg: DisconnectMessage = {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
conn.client.relay(disconnectMsg).catch(() => {});
}
// Drop the connection from the registry synchronously, before any awaiting.
// getConnections() must reflect the disconnect immediately, and connect()
// returns an existing connection for a URI — so leaving this one in the map
// while its teardown is pending would hand a caller a dying connection.
for (const seq of conn.signSequences) {
this.activeSignSequences.delete(seq);
}
clearInterval(conn.notificationProcessor ?? undefined);
conn.cleanup();
conn.notificationProcessor = null;
this.connections.delete(connectionId);
this.emit("connectionsChanged");
if (!sendMessage || !conn.client) {
conn.cleanup();
return;
}
const disconnectMsg: DisconnectMessage = {
action: RelayMsgAction.Disconnect,
reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
// Tear down only once the courtesy message has actually gone out.
//
// relay() resolves after `Promise.allSettled(pool.publish(...))` — a real
// round trip to every configured relay. Calling conn.cleanup() straight
// after firing it closed the pool underneath the in-flight publish, so the
// disconnect usually never reached the relay and the dapp went on believing
// the wallet was connected until its own liveness timeout fired. Downstream
// wallets were patching this out of the published package.
//
// Bounded, because "the publish never settles" is exactly the case where a
// relay is unreachable, and a socket that is never closed is worse than a
// courtesy message that is never delivered.
let torndown = false;
const teardown = () => {
if (torndown) return;
torndown = true;
conn.cleanup();
};
const timer = setTimeout(teardown, DISCONNECT_PUBLISH_TIMEOUT_MS);
conn.client
.relay(disconnectMsg)
.catch(() => {
// Nothing to do: we are disconnecting either way.
})
.finally(() => {
clearTimeout(timer);
teardown();
});
}
/**

View file

@ -9,6 +9,10 @@ export default defineConfig({
include: ["src/integration/**/*.test.ts"],
testTimeout: 60000,
hookTimeout: 60000,
// These tests talk to live relays. A dropped connection or a slow publish
// is an environment failure, not a regression, and without a retry a single
// one reds the whole pipeline.
retry: 2,
// Run integration tests serially to avoid relay contention
pool: "forks",
poolOptions: {