Compare commits

..

1 commit

Author SHA1 Message Date
Håvard Kittelsen
460e113e75 feat: multislot — more than one wallet key per transaction input
`inputPaths` names one HD key per entry, which is the whole story for a P2PKH
input: one input, one signature. A contract input is not like that. Its unlocking
bytecode may carry several sig/pubkey placeholders — an N-of-N agreement, or a
function taking (sig a, pubkey A, sig b, pubkey B) — and nothing in a 3-tuple can
say which placeholder a key fills.

So entries gain an optional fourth element, `slot`, and an input's index is listed
once per placeholder. `slot` defaults to 0, so every existing 3-tuple keeps
meaning exactly what it meant and no current dapp needs a capability check.
Sending `slot > 0`, or repeating an inputIndex, requires the wallet to advertise
`multislot` — an older wallet keeps one key per input and would return a
transaction missing signatures with nothing to say why.

This supersedes the `multislot` branch, which was cut before the
randomTradeSummary revert and still carries the reverted `txSummary` field. The
protocol design there is good and is kept: the placeholder format (65-byte
Schnorr sig push, 33-byte compressed pubkey push, spliced value-for-value so
offsets survive), the capability gate, and the three wallet rules — don't
deduplicate by inputIndex, compute each input's sighash once, reject rather than
under-fill. Two things are changed.

SLOTS ARE POSITIONAL, NOT BY VACANCY

The earlier definition numbered slots by scanning the template for zero-filled
pushes. That renumbers them as they fill, and a template is not always all
zeroes: in the N-of-N case the docs give as motivation, the dapp may have already
written the counterparty's signature into the first position. Verified against
that implementation's own reference fill, on a two-slot input with slot 0
pre-filled:

  asked for slot 1 -> not filled at all (under-fill)
  asked for slot 0 -> writes into the SECOND slot, silently

Here a push already holding a value still occupies its slot, so `slot` means the
same thing to the dapp that built the template and the wallet filling it,
whatever order things happen in. Overwriting a filled slot is an error rather
than a no-op, because discarding a counterparty's signature is not recoverable.

THE SCAN PARSES PUSHES INSTEAD OF SEARCHING FOR BYTES

A hex-substring search for the placeholder pattern can match bytes that merely
sit inside a larger push, splicing a signature into the middle of unrelated data.
findPlaceholders walks the script's push structure (direct pushes and
OP_PUSHDATA1/2/4) and throws on a truncated template rather than guessing at one
it cannot parse.

WHY THE HELPERS ARE IN THE LIBRARY

The placeholder layout is part of the wire contract — the dapp builds the
template, the wallet splices into it, and they must agree byte for byte or the
transaction is silently unspendable. Leaving every wallet to implement the scan
is how that goes wrong, and both failure modes above come from a reasonable
implementation of a reasonable-sounding rule.

fillPlaceholder throws on a missing slot, a wrong-length value, or an already
filled slot; unfilledPlaceholders is what "reject, don't under-fill" checks. That
makes the rule enforceable rather than something to remember.

Deliberately NOT included: the sighash-validation module from the earlier branch.
It is a separate concern — the library does no transaction signing today, so
adding a validator for it is new surface that deserves its own review, and its
signature-detection heuristic needs work (a 65-byte push is treated as a
signature, which an uncompressed public key also is).

23 core tests, including both misplacement cases above, the inside-a-larger-push
false positive, OP_PUSHDATA1 headers, truncated templates, and fill-order
independence.

Docs: protocol.md, extensions.md, wallet.md, dapp.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 16:37:42 +02:00
19 changed files with 950 additions and 335 deletions

View file

@ -21,12 +21,8 @@ This codebase communicates over a live relay with timing-sensitive handshakes an
**Integration tests** (`npm run test:integration` in a package): **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` - Hit the real relays at `wss://relay.riften.net:443` and `wss://relay.cauldron.quest:443`
- Test the full protocol handshake end-to-end - Test the full protocol handshake end-to-end
- Located in `src/integration/*.test.ts` (wallet is the only package with them today) - Located in `src/__tests__/*.integration.test.ts`
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`, serially - Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
(`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 - Must pass before any release
### Running tests ### Running tests
@ -91,13 +87,3 @@ npm run build # builds all packages in dependency order
``` ```
Packages must be built before integration tests run (tests import from `dist/`). 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,12 +164,34 @@ const response = await dappMgr.signTransaction({
userPrompt: "Confirm swap", userPrompt: "Confirm swap",
broadcast: true, broadcast: true,
}, },
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex] inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex, slot?]
}); });
console.log("Signed tx:", response.signedTransaction); console.log("Signed tx:", response.signedTransaction);
``` ```
#### Contract inputs needing more than one key
An `inputPaths` entry takes an optional fourth element, `slot`, so one contract input can be filled by
several keys — list the input's index once per placeholder. Check the wallet supports it first; a
wallet that predates the extension returns a transaction missing signatures:
```typescript
import { peerSupportsMultislot, requiresMultislot } from "@wizardconnect/core";
const inputPaths = [
[3, "receive", 0, 0], // input 3, slot 0
[3, "defi", 7, 1], // input 3, slot 1
];
const session = /* wallet_ready session["hdwalletv1"] */;
if (requiresMultislot(inputPaths) && !peerSupportsMultislot(session.extensions)) {
// Do NOT downgrade to a single signature — the result would be unspendable.
}
```
See [protocol.md § Multiple placeholders per input](protocol.md#multiple-placeholders-per-input-slot).
#### Cancellation via AbortSignal #### Cancellation via AbortSignal
Pass an `AbortSignal` to automatically cancel the request when aborted. This sends Pass an `AbortSignal` to automatically cancel the request when aborted. This sends

View file

@ -209,5 +209,70 @@ 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) | | `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) | | `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 | | `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` | — | — | Fill more than one sig/pubkey placeholder per transaction input, routed by the `slot` element of `inputPaths`. Needed for contract inputs requiring several keys. | Implemented — see below |
See the discussions and specifications for each extension as they are formalized. See the discussions and specifications for each extension as they are formalized.
---
## `multislot`
Lets one transaction input carry several sig/pubkey placeholders, each filled by a different key.
Without it, `inputPaths` names one key per input and a contract requiring two signatures cannot be
expressed.
Advertised with no payload — support is the whole message:
```json
{ "extensions": { "multislot": {} } }
```
This extension adds **no new actions**. It widens an existing field: `inputPaths` entries gain an
optional fourth element, `slot`. Existing 3-tuples are unchanged and mean exactly what they meant, so
a dapp that never sets `slot` needs no capability check.
### Wallet side
```typescript
import {
multislotExtensionAdvertisement,
fillPlaceholder,
unfilledPlaceholders,
} from "@wizardconnect/core";
const adapter: WalletAdapter = {
getExtensions: () => multislotExtensionAdvertisement(),
async signTransaction(request) {
// One sighash per input, reused for every slot — see protocol.md.
for (const [inputIndex, pathName, addressIndex, slot = 0] of request.inputPaths) {
const signature = signSchnorr(sighashFor(inputIndex), keyFor(pathName, addressIndex));
bytecode = fillPlaceholder(bytecode, "signature", slot, signature);
// ...and the matching public key, if the contract has a placeholder for it.
}
if (unfilledPlaceholders(bytecode).length > 0) {
throw new Error("refusing to return an under-filled transaction");
}
},
};
```
`fillPlaceholder` throws rather than returning a partial result when the slot does not exist, the
value is the wrong length, or the slot is already filled. Those are the three ways to produce a
transaction that looks fine and is unspendable.
### Dapp side
Check before sending — a request needing slots is not something an older wallet can partially serve:
```typescript
import { peerSupportsMultislot, requiresMultislot } from "@wizardconnect/core";
if (requiresMultislot(inputPaths) && !peerSupportsMultislot(session.extensions)) {
// Tell the user their wallet cannot do this. Do NOT fall back to one signature.
}
```
See [protocol.md § Multiple placeholders per input](protocol.md#multiple-placeholders-per-input-slot)
for the placeholder byte format, the positional definition of `slot`, and the wallet rules.

View file

@ -255,7 +255,7 @@ interface SignTransactionRequest {
action: "sign_transaction_request"; action: "sign_transaction_request";
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
sequence: number; sequence: number;
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex] inputPaths: [number, PathName, number, number?][]; // [inputIndex, pathName, addressIndex, slot?]
time: number; time: number;
} }
``` ```
@ -268,11 +268,105 @@ to match responses to requests.
(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the (for signing), version, locktime, and an optional `userPrompt` string shown to the user in the
wallet UI. wallet UI.
`inputPaths` is a sparse array of `[inputIndex, PathName, addressIndex]` tuples. Each entry identifies `inputPaths` is a sparse array of positional tuples with 3 or 4 elements:
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 | index | field | type | meaning |
pre-set unlocking bytecode can be omitted. This allows the wallet to sign each input without scanning |-------|-------|------|---------|
or guessing which key was used. | `[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 the wallet must contribute to the input at position `inputIndex`. Only
inputs that require wallet signing need an entry — contract inputs whose unlocking bytecode the dapp
fully provides can be omitted. This allows the wallet to sign each input without scanning or guessing
which key was used.
An input with **no** entry is not the wallet's to sign and is left untouched. That is distinct from an
entry the wallet cannot satisfy, which is an error — see the wallet rules below.
#### Multiple placeholders per input (`slot`)
A contract input may carry several `sig`/`pubkey` placeholders, each filled by a different key — an
N-of-N agreement, or a contract function taking `(sig a, pubkey A, sig b, pubkey B)`. The dapp builds
the unlocking bytecode with one placeholder per position, lists the input's index once per
placeholder, and sets the fourth element:
```
inputPaths: [
[3, "receive", 0, 0], // input 3, slot 0 -> key receive/0
[3, "defi", 7, 1], // input 3, slot 1 -> key defi/7
]
```
**Placeholder format.** Signatures **MUST** be Schnorr — the scheme depends on the real value being
exactly as long as the placeholder it replaces, and DER ECDSA signatures are variable-length.
- a **signature placeholder** is a data push of **65 zero bytes** (`0x41` then 65 × `0x00`) — a
64-byte Schnorr signature plus its trailing sighash-flag byte;
- a **public-key placeholder** is a data push of **33 zero bytes** (`0x21` then 33 × `0x00`) — a
compressed public key.
Because the real value is exactly the placeholder's length, the wallet splices it in value-for-value,
keeping the push opcode and the total bytecode length unchanged. Every other placeholder's offset
therefore survives, and fills may be applied in any order.
**`slot` is positional.** It counts signature-sized and public-key-sized pushes independently,
left-to-right from 0 — *whether or not a push currently holds a value*. A push already carrying a
counterparty's signature still occupies its slot.
This matters because a template is not always all zeroes. In an N-of-N where the dapp has already
written the counterparty's signature into the first position, `slot` must still mean "the second
position" to both sides. An implementation that numbered slots by scanning for *empty* pushes would
renumber them as they fill, and write the wallet's signature into the wrong position — producing a
transaction that serialises, broadcasts, and is rejected by consensus.
An entry `[i, path, addr, k]` tells the wallet: derive the key for `(path, addr)`, write its signature
into the k-th signature slot of input `i`, and write its compressed public key into the k-th
public-key slot of input `i` if one is present. An input may legally have a different number of each
(2 signature slots but 1 public-key slot, when one key is hard-coded in the redeem script).
`slot` is optional and defaults to 0, so an entry without one fills the first signature (and first
public-key) slot — identical to single-signature behaviour. **Existing 3-tuple requests are
unchanged.**
The dapp ships the unsigned template at `sourceOutputs[i].unlockingBytecode` (equivalently, the
decoded `transaction.inputs[i].unlockingBytecode`). Ordering of the `inputPaths` tuples is not
significant; the `slot` value is authoritative.
**Wallet rules (MUST):**
- **Do not deduplicate `inputPaths` by `inputIndex`.** Several entries may share one index, one per
slot. Collapsing them into a map keyed by index drops signatures and yields an unspendable
transaction.
- **Compute each input's sighash once.** All signatures within one input commit to the *same* sighash:
the signing serialization covers the redeem script and the transaction, not the unlocking bytecode
being filled. Filling one slot does not invalidate another's sighash — compute it once per input and
vary only the key.
- **Reject, don't under-fill.** If an entry cannot be satisfied — the path/index maps to no key, or the
input has no placeholder at that slot — fail the whole request with an error. Never return a
transaction with a leftover zero placeholder, which is silently unspendable.
`@wizardconnect/core` provides `findPlaceholders`, `fillPlaceholder` and `unfilledPlaceholders` so
wallets do not each reimplement the scan; `fillPlaceholder` throws on a missing slot, a wrong-length
value, or an attempt to overwrite a filled slot, which makes "reject, don't under-fill" the default
rather than a rule to remember. See [extensions.md § multislot](extensions.md#multislot).
#### Capability negotiation (required)
Sending any entry with `slot > 0`, or more than one entry for the same `inputIndex`, requires the
wallet to advertise the `multislot` extension in `wallet_ready`. A wallet that predates the extension
keeps one key per input and would return a transaction missing signatures, with nothing to say why.
```typescript
import { peerSupportsMultislot, requiresMultislot } from "@wizardconnect/core";
const session = walletReady.session["hdwalletv1"];
if (requiresMultislot(inputPaths) && !peerSupportsMultislot(session.extensions)) {
// This wallet cannot serve the request. Tell the user; do NOT downgrade to a
// single signature, which would produce an unspendable transaction.
}
```
#### SIGHASH requirement (security-critical) #### SIGHASH requirement (security-critical)
@ -358,12 +452,6 @@ 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 courtesy notification — the remote side treats the connection as closed immediately upon receipt
(no acknowledgement). (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 ```typescript
enum DisconnectReason { enum DisconnectReason {
ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake ProtocolMismatch = "protocol_mismatch", // no common protocol found during handshake
@ -379,9 +467,7 @@ interface DisconnectMessage {
``` ```
**Wallet side** (`WalletConnectionManager`): **Wallet side** (`WalletConnectionManager`):
- `disconnect(id)` sends `UserDisconnect`, then tears the connection down once the publish - `disconnect(id)` sends `UserDisconnect` before cleaning up.
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 - Incoming `disconnect` emits a `remoteDisconnect` event
(`connectionId`, `reason`, `message`) and removes the connection. (`connectionId`, `reason`, `message`) and removes the connection.

View file

@ -50,12 +50,10 @@ connect(): Promise<void>
// NDK connect, subscribe to GiftWrap events, start waiting for relays. // NDK connect, subscribe to GiftWrap events, start waiting for relays.
disconnect(): Promise<void> disconnect(): Promise<void>
// Stop subscription, close the relay pool, mark queue not-ready, update // Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
// lastProcessedTimestamp. Kills any in-flight publish — see below.
relay(message: ProtocolMessage): Promise<void> relay(message: ProtocolMessage): Promise<void>
// Send a message. Enqueues if relays not ready. Throws if paired key not set. // 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 setPairedPublicKey(key: Uint8Array): void
// Called after key exchange. Enables outbound messages and incoming peer filtering. // Called after key exchange. Enables outbound messages and incoming peer filtering.
@ -80,10 +78,6 @@ regardless (avoiding silent message loss on slow connections).
On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are On `disconnect()`, the queue is marked not-ready so messages sent during a reconnect gap are
held rather than dropped. 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 ### Replay protection
`lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated `lastProcessedTimestamp` is set to `now - 2` on the first connection. On reconnect it is updated

View file

@ -69,11 +69,9 @@ class WalletConnectionManager extends EventEmitter {
connect(uri: string): string connect(uri: string): string
// Tear down a specific connection, sending a UserDisconnect courtesy message. // 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 disconnect(connectionId: string): void
// Tear down all connections. Each is disconnected independently. // Tear down all connections.
disconnectAll(): void disconnectAll(): void
// Snapshot of all connections for UI rendering. // Snapshot of all connections for UI rendering.
@ -156,28 +154,6 @@ wallet_discovered=true → set dappDiscovered=true, no further action
`dapp_name` and `dapp_icon` are captured from the first `dapp_ready` that includes them. `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 ### Receiving disconnect
When a `disconnect` message arrives from the dapp: When a `disconnect` message arrives from the dapp:
@ -202,6 +178,14 @@ The wallet library does not auto-sign or auto-reject anything.
has already been emitted. The guard is cleared when a response is sent (`sendSignResponse` / has already been emitted. The guard is cleared when a response is sent (`sendSignResponse` /
`sendSignError`) or a `sign_cancel` is received. `sendSignError`) or a `sign_cancel` is received.
**Multiple keys per input:** an `inputPaths` entry may carry a fourth element, `slot`, and several
entries may share one `inputIndex` — one per sig/pubkey placeholder in a contract input. Do **not**
deduplicate by `inputIndex`: collapsing them drops signatures and yields an unspendable transaction.
Compute each input's sighash once and reuse it for every slot, varying only the key. Use
`fillPlaceholder()` / `unfilledPlaceholders()` from `@wizardconnect/core` rather than scanning for
placeholders yourself, and advertise `multislot` via `getExtensions()` if you support it. See
[extensions.md § multislot](extensions.md#multislot).
**SIGHASH enforcement:** The wallet **MUST** sign every input with **SIGHASH enforcement:** The wallet **MUST** sign every input with
`SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the
[SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol [SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol

134
package-lock.json generated
View file

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

View file

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

View file

@ -30,6 +30,7 @@ export type {
KeyExchangeURIResult, KeyExchangeURIResult,
} from "./key-exchange.js"; } from "./key-exchange.js";
export * from "./protocols/hdwalletv1.js"; export * from "./protocols/hdwalletv1.js";
export * from "./protocols/multislot.js";
export * from "./protocols/base.js"; export * from "./protocols/base.js";
export { export {
CHUNK_EXTENSION_NAME, CHUNK_EXTENSION_NAME,

View file

@ -138,7 +138,24 @@ export interface SignTransactionRequest extends ProtocolMessage {
action: RelayMsgAction.SignTransactionRequest; action: RelayMsgAction.SignTransactionRequest;
transaction: WcSignTransactionRequest; transaction: WcSignTransactionRequest;
sequence: number; sequence: number;
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex] /**
* Which HD key the wallet must contribute where:
* `[inputIndex, pathName, addressIndex, slot?]`.
*
* Only inputs the wallet must sign need an entry. An input with no entry is
* not the wallet's to sign and is left untouched distinct from an entry the
* wallet cannot satisfy, which is an error.
*
* For a P2PKH input one entry suffices and `slot` is omitted. A contract input
* may carry several sig/pubkey placeholders filled by different keys; the dapp
* lists that input's index once per placeholder and sets `slot` to say which.
* `slot` defaults to 0, so existing 3-tuples are unchanged.
*
* Sending any entry with `slot > 0`, or more than one entry for one input,
* requires the wallet to advertise `multislot` (EXT_MULTISLOT) see
* multislot.ts and docs/protocol.md.
*/
inputPaths: [number, PathName, number, number?][]; // [inputIndex, pathName, addressIndex, slot?]
} }
export interface SignTransactionResponse extends ProtocolMessage { export interface SignTransactionResponse extends ProtocolMessage {
@ -199,10 +216,15 @@ export function isSignTransactionRequest(
msg.inputPaths.every( msg.inputPaths.every(
(p: any) => (p: any) =>
Array.isArray(p) && Array.isArray(p) &&
p.length === 3 && (p.length === 3 || p.length === 4) &&
typeof p[0] === "number" && typeof p[0] === "number" &&
typeof p[1] === "string" && typeof p[1] === "string" &&
typeof p[2] === "number", typeof p[2] === "number" &&
// slot, when present: a non-negative integer. A fractional or negative
// slot cannot address a placeholder, so it is malformed rather than
// something to round or clamp.
(p.length === 3 ||
(typeof p[3] === "number" && Number.isInteger(p[3]) && p[3] >= 0)),
) )
); );
} }

View file

@ -0,0 +1,321 @@
// 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
/**
* Multislot tests.
*
* The failure this feature can produce is a transaction that serialises fine,
* broadcasts fine, and is rejected by consensus because a key went into the
* wrong placeholder or a placeholder was left as zeroes. None of that is visible
* to the wallet that produced it, so the tests below are mostly about slot
* numbering staying stable and about refusing to produce a half-filled result.
*/
import { describe, expect, it } from "vitest";
import { hexToBin, binToHex } from "@bitauth/libauth";
import {
EXT_MULTISLOT,
PUBKEY_PLACEHOLDER_LENGTH,
SIG_PLACEHOLDER_LENGTH,
fillPlaceholder,
findPlaceholder,
findPlaceholders,
multislotExtensionAdvertisement,
peerSupportsMultislot,
requiresMultislot,
slotOfInputPath,
unfilledPlaceholders,
} from "./multislot.js";
import { isSignTransactionRequest, RelayMsgAction } from "./hdwalletv1.js";
/** A push of `length` zero bytes, as a dapp writes a placeholder. */
function placeholder(length: number): string {
return length.toString(16).padStart(2, "0") + "00".repeat(length);
}
const SIG = placeholder(SIG_PLACEHOLDER_LENGTH);
const PUBKEY = placeholder(PUBKEY_PLACEHOLDER_LENGTH);
/** A filled 65-byte signature push, distinguishable from a placeholder. */
function signature(fill: number): Uint8Array {
return new Uint8Array(SIG_PLACEHOLDER_LENGTH).fill(fill);
}
function publicKey(fill: number): Uint8Array {
const key = new Uint8Array(PUBKEY_PLACEHOLDER_LENGTH).fill(fill);
key[0] = 0x02;
return key;
}
describe("capability negotiation", () => {
it("advertises and detects the extension", () => {
const advert = multislotExtensionAdvertisement();
expect(advert).toEqual({ [EXT_MULTISLOT]: {} });
expect(peerSupportsMultislot(advert)).toBe(true);
});
it("reports no support for a wallet that does not advertise it", () => {
expect(peerSupportsMultislot(undefined)).toBe(false);
expect(peerSupportsMultislot({})).toBe(false);
expect(peerSupportsMultislot({ chunk: {} })).toBe(false);
});
it("knows which requests need the extension", () => {
// Plain single-signature work must not demand it, or every existing dapp
// would suddenly require a wallet upgrade.
expect(requiresMultislot([[0, "receive", 0]])).toBe(false);
expect(
requiresMultislot([
[0, "receive", 0],
[1, "change", 2],
]),
).toBe(false);
expect(requiresMultislot([[0, "receive", 0, 0]])).toBe(false);
// A slot above 0, or two entries for one input, cannot be served by a
// wallet that keeps one key per input.
expect(requiresMultislot([[0, "receive", 0, 1]])).toBe(true);
expect(
requiresMultislot([
[3, "receive", 0, 0],
[3, "defi", 7, 1],
]),
).toBe(true);
});
it("defaults a missing slot to 0", () => {
expect(slotOfInputPath([0, "receive", 5])).toBe(0);
expect(slotOfInputPath([0, "receive", 5, 0])).toBe(0);
expect(slotOfInputPath([0, "receive", 5, 2])).toBe(2);
});
});
describe("isSignTransactionRequest with slots", () => {
const base = {
action: RelayMsgAction.SignTransactionRequest,
sequence: 1,
time: 0,
transaction: {},
};
it("accepts 3-tuples and 4-tuples, mixed", () => {
expect(
isSignTransactionRequest({
...base,
inputPaths: [
[0, "receive", 0],
[3, "receive", 0, 0],
[3, "defi", 7, 1],
],
}),
).toBe(true);
});
it("rejects a slot that cannot address a placeholder", () => {
for (const slot of [-1, 1.5, NaN, "0", null]) {
expect(
isSignTransactionRequest({
...base,
inputPaths: [[0, "receive", 0, slot]],
}),
).toBe(false);
}
});
it("rejects tuples of the wrong length", () => {
expect(
isSignTransactionRequest({ ...base, inputPaths: [[0, "receive"]] }),
).toBe(false);
expect(
isSignTransactionRequest({
...base,
inputPaths: [[0, "receive", 0, 1, 9]],
}),
).toBe(false);
});
});
describe("findPlaceholders", () => {
it("numbers signature and public-key slots independently", () => {
// A contract taking (sig a, pubkey A, sig b, pubkey B).
const found = findPlaceholders(hexToBin(SIG + PUBKEY + SIG + PUBKEY));
expect(found.map((p) => [p.kind, p.slot])).toEqual([
["signature", 0],
["publicKey", 0],
["signature", 1],
["publicKey", 1],
]);
expect(found.every((p) => p.empty)).toBe(true);
});
it("handles an input with more signatures than public keys", () => {
// Legal: one key's pubkey is hard-coded in the redeem script.
const found = findPlaceholders(hexToBin(SIG + SIG + PUBKEY));
expect(found.filter((p) => p.kind === "signature")).toHaveLength(2);
expect(found.filter((p) => p.kind === "publicKey")).toHaveLength(1);
});
it("ignores pushes that are not placeholder-sized", () => {
const other = placeholder(20); // a pubkey hash, say
expect(findPlaceholders(hexToBin(other + SIG + other))).toHaveLength(1);
});
// The bug a hex-substring search has: placeholder bytes can occur INSIDE a
// larger push, and a search would splice a signature into the middle of it.
it("does not match placeholder bytes inside a larger push", () => {
const carrier = "4c" + "50" + "00".repeat(0x50); // OP_PUSHDATA1, 80 zero bytes
const found = findPlaceholders(hexToBin(carrier));
expect(found).toHaveLength(0);
});
it("walks past non-push opcodes", () => {
const opCheckSig = "ac";
const opDup = "76";
const found = findPlaceholders(hexToBin(opDup + SIG + opCheckSig + PUBKEY));
expect(found.map((p) => p.kind)).toEqual(["signature", "publicKey"]);
});
it("parses OP_PUSHDATA1 headers", () => {
// 65 bytes pushed the long way round is still a signature slot.
const viaPushdata1 = "4c" + "41" + "00".repeat(65);
const found = findPlaceholders(hexToBin(viaPushdata1));
expect(found).toHaveLength(1);
expect(found[0].kind).toBe("signature");
expect(found[0].offset).toBe(2);
});
it("throws on a truncated template rather than guessing", () => {
// Claims 65 bytes, supplies 3.
expect(() => findPlaceholders(hexToBin("41" + "000000"))).toThrow(
/truncated/,
);
expect(() => findPlaceholders(hexToBin("4c"))).toThrow(/truncated/);
});
it("reports a filled push as not empty, without renumbering", () => {
// The case a zero-scan gets wrong: the dapp has already written a
// counterparty's signature into slot 0.
const template = fillPlaceholder(
hexToBin(SIG + SIG),
"signature",
0,
signature(0xab),
);
const found = findPlaceholders(template);
expect(found.map((p) => [p.slot, p.empty])).toEqual([
[0, false],
[1, true],
]);
});
});
describe("fillPlaceholder", () => {
it("splices a value in without changing the length", () => {
const template = hexToBin(SIG + PUBKEY);
const filled = fillPlaceholder(template, "signature", 0, signature(0x11));
expect(filled.length).toBe(template.length);
expect(binToHex(filled)).toBe("41" + "11".repeat(65) + PUBKEY);
// The input is untouched.
expect(binToHex(template)).toBe(SIG + PUBKEY);
});
it("fills the right slot regardless of order", () => {
const template = hexToBin(SIG + SIG);
const forwards = fillPlaceholder(
fillPlaceholder(template, "signature", 0, signature(0xaa)),
"signature",
1,
signature(0xbb),
);
const backwards = fillPlaceholder(
fillPlaceholder(template, "signature", 1, signature(0xbb)),
"signature",
0,
signature(0xaa),
);
expect(binToHex(forwards)).toBe(binToHex(backwards));
});
it("fills slot 1 correctly when the dapp pre-filled slot 0", () => {
// The N-of-N case the docs give as motivation. Numbering by position rather
// than by vacancy is what makes this land in the right place: a scan for
// zero-filled pushes would see one placeholder, call it slot 0, and write
// the wallet's signature over the counterparty's position.
const counterparty = signature(0xcc);
const template = fillPlaceholder(
hexToBin(SIG + PUBKEY + SIG + PUBKEY),
"signature",
0,
counterparty,
);
const ours = signature(0x99);
const filled = fillPlaceholder(template, "signature", 1, ours);
const positions = findPlaceholders(filled).filter(
(p) => p.kind === "signature",
);
expect(
binToHex(filled.slice(positions[0].offset, positions[0].offset + 65)),
).toBe(binToHex(counterparty));
expect(
binToHex(filled.slice(positions[1].offset, positions[1].offset + 65)),
).toBe(binToHex(ours));
});
it("refuses a slot the input does not have", () => {
expect(() =>
fillPlaceholder(hexToBin(SIG), "signature", 1, signature(1)),
).toThrow(/slot 1: this input has 1/);
expect(() =>
fillPlaceholder(hexToBin(SIG), "publicKey", 0, publicKey(1)),
).toThrow(/this input has 0/);
});
it("refuses a value of the wrong length", () => {
// Anything shorter or longer would shift every later placeholder.
expect(() =>
fillPlaceholder(hexToBin(SIG), "signature", 0, new Uint8Array(64)),
).toThrow(/exactly 65 bytes, got 64/);
expect(() =>
fillPlaceholder(hexToBin(PUBKEY), "publicKey", 0, new Uint8Array(65)),
).toThrow(/exactly 33 bytes/);
});
it("refuses to overwrite a filled slot", () => {
const filled = fillPlaceholder(
hexToBin(SIG),
"signature",
0,
signature(0xaa),
);
expect(() =>
fillPlaceholder(filled, "signature", 0, signature(0xbb)),
).toThrow(/already filled/);
});
it("fills public keys as well as signatures", () => {
const template = hexToBin(SIG + PUBKEY);
const filled = fillPlaceholder(template, "publicKey", 0, publicKey(0x77));
expect(findPlaceholder(filled, "publicKey", 0)!.empty).toBe(false);
expect(findPlaceholder(filled, "signature", 0)!.empty).toBe(true);
});
});
describe("unfilledPlaceholders", () => {
it("is what 'reject, don't under-fill' checks", () => {
const template = hexToBin(SIG + PUBKEY + SIG);
expect(unfilledPlaceholders(template)).toHaveLength(3);
let filled = fillPlaceholder(template, "signature", 0, signature(1));
filled = fillPlaceholder(filled, "signature", 1, signature(2));
expect(unfilledPlaceholders(filled).map((p) => p.kind)).toEqual([
"publicKey",
]);
filled = fillPlaceholder(filled, "publicKey", 0, publicKey(3));
expect(unfilledPlaceholders(filled)).toHaveLength(0);
});
});

View file

@ -0,0 +1,318 @@
// 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
/**
* Multislot more than one wallet key per transaction input.
*
* WHY THIS EXISTS
*
* `inputPaths` names one HD key per entry, and for an ordinary P2PKH input one
* entry is the whole story: one input, one signature. A contract input is not
* like that. Its unlocking bytecode may carry several `sig`/`pubkey`
* placeholders an N-of-N agreement, or a contract function taking
* `(sig a, pubkey A, sig b, pubkey B)` and the wallet has to know *which*
* placeholder each key fills. Nothing in a 3-tuple can say that.
*
* So `inputPaths` entries gain an optional fourth element, `slot`, and the
* input's index is listed once per placeholder. See docs/protocol.md.
*
* WHY THE FILL HELPERS ARE HERE
*
* The placeholder layout is part of the wire contract: the dapp builds the
* template, the wallet splices into it, and they must agree byte for byte or
* the transaction is silently unspendable. Leaving every wallet to scan for
* placeholders itself is how that goes wrong and the obvious implementation,
* searching the bytecode for a run of zero bytes, is wrong in two ways this
* module avoids:
*
* - a scan that only finds ZERO-filled pushes renumbers the slots as they are
* filled, so a template where the dapp has already written a counterparty's
* signature into slot 0 makes the wallet write its own into the wrong place.
* Slots here are positional the k-th push of signature size whether or
* not it currently holds a value.
*
* - a substring search can match bytes that merely happen to sit INSIDE a
* larger push. This walks the script's push structure instead.
*/
import { PathName } from "./hdwalletv1.js";
/** Extension name advertised in `wallet_ready` by wallets that can fill more
* than one placeholder per input. */
export const EXT_MULTISLOT = "multislot" as const;
/**
* A signature placeholder is a push of 65 zero bytes: 64 for a Schnorr
* signature plus its trailing sighash-flag byte.
*
* Schnorr only. The scheme depends on the real value being exactly as long as
* the placeholder it replaces, and DER ECDSA signatures are variable-length.
*/
export const SIG_PLACEHOLDER_LENGTH = 65;
/** A public-key placeholder is a push of 33 zero bytes — a compressed key. */
export const PUBKEY_PLACEHOLDER_LENGTH = 33;
export type PlaceholderKind = "signature" | "publicKey";
/** Advertisement a wallet publishes to say it honours `slot`. */
export function multislotExtensionAdvertisement(): Record<
string,
Record<string, never>
> {
return { [EXT_MULTISLOT]: {} };
}
/**
* Whether a peer advertised `multislot`.
*
* A dapp MUST check this before sending any entry with `slot > 0`, or more than
* one entry for the same `inputIndex`. A wallet that predates the extension
* would keep only one of them and return a transaction missing signatures.
*/
export function peerSupportsMultislot(
extensions: Record<string, unknown> | undefined,
): boolean {
return !!extensions && EXT_MULTISLOT in extensions;
}
/** One placeholder-sized push found in an unlocking bytecode. */
export interface PlaceholderPosition {
kind: PlaceholderKind;
/** 0-based index among pushes of this kind, left to right. This is `slot`. */
slot: number;
/** Offset of the push DATA (the byte after the push opcode). */
offset: number;
/** Data length: 65 for a signature, 33 for a public key. */
length: number;
/** Whether the push is still all zeroes, i.e. not yet filled. */
empty: boolean;
}
/** One push parsed out of a script. */
interface ParsedPush {
offset: number;
length: number;
}
/**
* Walk a script and return every data push.
*
* Parses the push structure rather than searching for byte patterns, so a run
* of bytes inside a larger push can never be mistaken for a placeholder.
*
* Throws on a truncated script: a template we cannot parse is one we must not
* guess at.
*/
function parsePushes(bytecode: Uint8Array): ParsedPush[] {
const pushes: ParsedPush[] = [];
let i = 0;
while (i < bytecode.length) {
const opcode = bytecode[i];
let dataLength: number;
let headerLength: number;
if (opcode >= 0x01 && opcode <= 0x4b) {
dataLength = opcode;
headerLength = 1;
} else if (opcode === 0x4c) {
// OP_PUSHDATA1
if (i + 1 >= bytecode.length) {
throw new Error("Unlocking bytecode truncated in OP_PUSHDATA1 length");
}
dataLength = bytecode[i + 1];
headerLength = 2;
} else if (opcode === 0x4d) {
// OP_PUSHDATA2, little-endian
if (i + 2 >= bytecode.length) {
throw new Error("Unlocking bytecode truncated in OP_PUSHDATA2 length");
}
dataLength = bytecode[i + 1] | (bytecode[i + 2] << 8);
headerLength = 3;
} else if (opcode === 0x4e) {
// OP_PUSHDATA4, little-endian
if (i + 4 >= bytecode.length) {
throw new Error("Unlocking bytecode truncated in OP_PUSHDATA4 length");
}
dataLength =
(bytecode[i + 1] |
(bytecode[i + 2] << 8) |
(bytecode[i + 3] << 16) |
(bytecode[i + 4] << 24)) >>>
0;
headerLength = 5;
} else {
// OP_0 (0x00) pushes nothing; everything from 0x4f up is an opcode with
// no attached data.
i += 1;
continue;
}
const offset = i + headerLength;
if (offset + dataLength > bytecode.length) {
throw new Error(
`Unlocking bytecode truncated: push at ${i} claims ${dataLength} bytes, ` +
`only ${bytecode.length - offset} remain`,
);
}
pushes.push({ offset, length: dataLength });
i = offset + dataLength;
}
return pushes;
}
function isAllZero(
bytecode: Uint8Array,
offset: number,
length: number,
): boolean {
for (let i = offset; i < offset + length; i++) {
if (bytecode[i] !== 0) return false;
}
return true;
}
/**
* Every signature- and public-key-sized push in an unlocking bytecode, numbered
* by kind.
*
* Numbering counts positions, not vacancies: a push already holding a
* counterparty's signature still occupies its slot. That is what makes `slot`
* mean the same thing to the dapp that built the template and to the wallet
* filling it, no matter what order things get filled in.
*/
export function findPlaceholders(
unlockingBytecode: Uint8Array,
): PlaceholderPosition[] {
const positions: PlaceholderPosition[] = [];
let sigSlot = 0;
let pubkeySlot = 0;
for (const push of parsePushes(unlockingBytecode)) {
let kind: PlaceholderKind;
let slot: number;
if (push.length === SIG_PLACEHOLDER_LENGTH) {
kind = "signature";
slot = sigSlot++;
} else if (push.length === PUBKEY_PLACEHOLDER_LENGTH) {
kind = "publicKey";
slot = pubkeySlot++;
} else {
continue;
}
positions.push({
kind,
slot,
offset: push.offset,
length: push.length,
empty: isAllZero(unlockingBytecode, push.offset, push.length),
});
}
return positions;
}
/** Locate one placeholder, or undefined if the input has no such slot. */
export function findPlaceholder(
unlockingBytecode: Uint8Array,
kind: PlaceholderKind,
slot: number,
): PlaceholderPosition | undefined {
return findPlaceholders(unlockingBytecode).find(
(p) => p.kind === kind && p.slot === slot,
);
}
/**
* Splice a value into the slot-th placeholder of the given kind.
*
* Returns a new bytecode; the input is not modified. The replacement is exactly
* as long as the placeholder, so every other placeholder's offset survives and
* fills can be applied in any order.
*
* Throws rather than returning a partially filled result, because the failure
* this guards against is a transaction that looks fine and is unspendable:
*
* - no placeholder at that slot the dapp and wallet disagree about the
* template, and signing anyway puts a key somewhere arbitrary;
* - the value is the wrong length it would shift every later placeholder;
* - the slot is already filled either a double-fill or a slot mix-up, and
* overwriting a counterparty's signature is not a recoverable mistake.
*/
export function fillPlaceholder(
unlockingBytecode: Uint8Array,
kind: PlaceholderKind,
slot: number,
value: Uint8Array,
): Uint8Array {
const target = findPlaceholder(unlockingBytecode, kind, slot);
if (!target) {
const available = findPlaceholders(unlockingBytecode).filter(
(p) => p.kind === kind,
).length;
throw new Error(
`No ${kind} placeholder at slot ${slot}: this input has ${available}. ` +
`Reject the request rather than returning an under-filled transaction.`,
);
}
if (value.length !== target.length) {
throw new Error(
`A ${kind} for slot ${slot} must be exactly ${target.length} bytes, got ` +
`${value.length}. Signatures must be Schnorr (64 bytes + 1 sighash-flag ` +
`byte); public keys must be compressed.`,
);
}
if (!target.empty) {
throw new Error(
`The ${kind} placeholder at slot ${slot} is already filled — overwriting ` +
`it would discard a signature or public key someone else supplied.`,
);
}
const filled = new Uint8Array(unlockingBytecode);
filled.set(value, target.offset);
return filled;
}
/**
* Every slot an input still needs filled.
*
* The point of "reject, don't under-fill": a leftover zero placeholder produces
* a transaction that serialises, broadcasts and fails, so a wallet should ask
* this before returning and refuse if anything is outstanding.
*/
export function unfilledPlaceholders(
unlockingBytecode: Uint8Array,
): PlaceholderPosition[] {
return findPlaceholders(unlockingBytecode).filter((p) => p.empty);
}
/**
* The slot an `inputPaths` entry refers to. Absent means 0, so every existing
* 3-tuple keeps meaning exactly what it meant.
*/
export function slotOfInputPath(
entry: [number, PathName, number] | [number, PathName, number, number?],
): number {
return entry[3] ?? 0;
}
/**
* Whether a request needs the wallet to support `multislot`.
*
* True when any entry names a slot above 0, or when one input has more than one
* entry. A dapp checks this against `peerSupportsMultislot()` before sending;
* sending it regardless to an older wallet yields a transaction missing
* signatures, with nothing to indicate why.
*/
export function requiresMultislot(
inputPaths: readonly (readonly [number, PathName, number, number?])[],
): boolean {
const seen = new Set<number>();
for (const entry of inputPaths) {
if ((entry[3] ?? 0) > 0) return true;
if (seen.has(entry[0])) return true;
seen.add(entry[0]);
}
return false;
}

View file

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

View file

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

View file

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

View file

@ -1,98 +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
/**
* 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,38 +205,10 @@ export async function setupConnection(
} }
}); });
// Prompt one more wallet_ready, now that the handler above is registered. // Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
// 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); 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 ---- // ---- Wallet side ----
const manager = new WalletConnectionManager(adapter); const manager = new WalletConnectionManager(adapter);
@ -244,16 +216,7 @@ export async function setupConnection(
// ---- Wait for key exchange ---- // ---- Wait for key exchange ----
try { await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
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 ---- // ---- Wait for wallet_ready with paths ----

View file

@ -45,15 +45,6 @@ export interface PendingSignRequest {
request: SignTransactionRequest; 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 { interface ActiveConnection {
id: string; id: string;
uri: string; uri: string;
@ -226,58 +217,22 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
const conn = this.connections.get(connectionId); const conn = this.connections.get(connectionId);
if (!conn) return; if (!conn) return;
// Drop the connection from the registry synchronously, before any awaiting. if (sendMessage && conn.client) {
// getConnections() must reflect the disconnect immediately, and connect() const disconnectMsg: DisconnectMessage = {
// returns an existing connection for a URI — so leaving this one in the map action: RelayMsgAction.Disconnect,
// while its teardown is pending would hand a caller a dying connection. reason: DisconnectReason.UserDisconnect,
time: Math.floor(Date.now() / 1000),
};
conn.client.relay(disconnectMsg).catch(() => {});
}
for (const seq of conn.signSequences) { for (const seq of conn.signSequences) {
this.activeSignSequences.delete(seq); this.activeSignSequences.delete(seq);
} }
clearInterval(conn.notificationProcessor ?? undefined); clearInterval(conn.notificationProcessor ?? undefined);
conn.notificationProcessor = null; conn.cleanup();
this.connections.delete(connectionId); this.connections.delete(connectionId);
this.emit("connectionsChanged"); 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,10 +9,6 @@ export default defineConfig({
include: ["src/integration/**/*.test.ts"], include: ["src/integration/**/*.test.ts"],
testTimeout: 60000, testTimeout: 60000,
hookTimeout: 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 // Run integration tests serially to avoid relay contention
pool: "forks", pool: "forks",
poolOptions: { poolOptions: {