Compare commits
1 commit
master
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
460e113e75 |
19 changed files with 950 additions and 335 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -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):
|
||||
- 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/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
|
||||
- Located in `src/__tests__/*.integration.test.ts`
|
||||
- Run with generous timeouts (60s per test) via `vitest.integration.config.ts`
|
||||
- Must pass before any release
|
||||
|
||||
### 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/`).
|
||||
|
||||
## 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`.
|
||||
|
|
|
|||
24
docs/dapp.md
24
docs/dapp.md
|
|
@ -164,12 +164,34 @@ const response = await dappMgr.signTransaction({
|
|||
userPrompt: "Confirm swap",
|
||||
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);
|
||||
```
|
||||
|
||||
#### 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
|
||||
|
||||
Pass an `AbortSignal` to automatically cancel the request when aborted. This sends
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
| `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` | — | — | 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.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## `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.
|
||||
|
|
|
|||
116
docs/protocol.md
116
docs/protocol.md
|
|
@ -255,7 +255,7 @@ interface SignTransactionRequest {
|
|||
action: "sign_transaction_request";
|
||||
transaction: WcSignTransactionRequest; // from @bch-wc2/interfaces
|
||||
sequence: number;
|
||||
inputPaths: [number, PathName, number][]; // [inputIndex, pathName, addressIndex]
|
||||
inputPaths: [number, PathName, number, number?][]; // [inputIndex, pathName, addressIndex, slot?]
|
||||
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
|
||||
wallet UI.
|
||||
|
||||
`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.
|
||||
`inputPaths` is a sparse array of positional tuples with 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 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)
|
||||
|
||||
|
|
@ -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
|
||||
(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
|
||||
|
|
@ -379,9 +467,7 @@ interface DisconnectMessage {
|
|||
```
|
||||
|
||||
**Wallet side** (`WalletConnectionManager`):
|
||||
- `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).
|
||||
- `disconnect(id)` sends `UserDisconnect` before cleaning up.
|
||||
- Incoming `disconnect` emits a `remoteDisconnect` event
|
||||
(`connectionId`, `reason`, `message`) and removes the connection.
|
||||
|
||||
|
|
|
|||
|
|
@ -50,12 +50,10 @@ connect(): Promise<void>
|
|||
// NDK connect, subscribe to GiftWrap events, start waiting for relays.
|
||||
|
||||
disconnect(): Promise<void>
|
||||
// Stop subscription, close the relay pool, mark queue not-ready, update
|
||||
// lastProcessedTimestamp. Kills any in-flight publish — see below.
|
||||
// Stop subscription, mark queue not-ready, update lastProcessedTimestamp.
|
||||
|
||||
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.
|
||||
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -69,11 +69,9 @@ 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. Each is disconnected independently.
|
||||
// Tear down all connections.
|
||||
disconnectAll(): void
|
||||
|
||||
// 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.
|
||||
|
||||
### 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:
|
||||
|
|
@ -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` /
|
||||
`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_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the
|
||||
[SIGHASH requirement](protocol.md#sighash-requirement-security-critical) section in the protocol
|
||||
|
|
|
|||
134
package-lock.json
generated
134
package-lock.json
generated
|
|
@ -1400,15 +1400,15 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz",
|
||||
"integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/utils": "3.2.7",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
|
|
@ -1417,13 +1417,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz",
|
||||
"integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.7",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
|
|
@ -1444,9 +1444,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz",
|
||||
"integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1457,13 +1457,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz",
|
||||
"integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.7",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
|
|
@ -1472,13 +1472,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz",
|
||||
"integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
|
|
@ -1487,9 +1487,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz",
|
||||
"integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -1500,13 +1500,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz",
|
||||
"integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.7",
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
|
|
@ -1608,16 +1608,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
|
|
@ -2470,9 +2470,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"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==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2706,9 +2706,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
|
@ -2726,7 +2726,7 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.17",
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
|
|
@ -3147,14 +3147,14 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "7.3.6",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",
|
||||
"integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==",
|
||||
"version": "7.3.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.3",
|
||||
"postcss": "^8.5.6",
|
||||
|
|
@ -3246,20 +3246,20 @@
|
|||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz",
|
||||
"integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@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",
|
||||
"@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",
|
||||
"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.7",
|
||||
"@vitest/ui": "3.2.7",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
|
|
@ -3372,9 +3372,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
|
|
@ -3408,7 +3408,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@wizardconnect/core",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"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.21.3"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"packages/dapp": {
|
||||
"name": "@wizardconnect/dapp",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@wizardconnect/core": "*",
|
||||
"eventemitter3": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
},
|
||||
"packages/react": {
|
||||
"name": "@wizardconnect/react",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.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.7"
|
||||
"vitest": "^3.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
|
|
@ -3511,7 +3511,7 @@
|
|||
},
|
||||
"packages/test-cli": {
|
||||
"name": "@wizardconnect/test-cli",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"@wizardconnect/core": "*",
|
||||
|
|
@ -3531,7 +3531,7 @@
|
|||
},
|
||||
"packages/wallet": {
|
||||
"name": "@wizardconnect/wallet",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.2",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "^3.1.0-next.2",
|
||||
"@wizardconnect/core": "*",
|
||||
|
|
@ -3539,7 +3539,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,10 +40,10 @@
|
|||
"eventemitter3": "^5.0.1",
|
||||
"isomorphic-ws": "^5.0.0",
|
||||
"lossless-json": "^4.3.0",
|
||||
"ws": "^8.21.3"
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export type {
|
|||
KeyExchangeURIResult,
|
||||
} from "./key-exchange.js";
|
||||
export * from "./protocols/hdwalletv1.js";
|
||||
export * from "./protocols/multislot.js";
|
||||
export * from "./protocols/base.js";
|
||||
export {
|
||||
CHUNK_EXTENSION_NAME,
|
||||
|
|
|
|||
|
|
@ -138,7 +138,24 @@ export interface SignTransactionRequest extends ProtocolMessage {
|
|||
action: RelayMsgAction.SignTransactionRequest;
|
||||
transaction: WcSignTransactionRequest;
|
||||
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 {
|
||||
|
|
@ -199,10 +216,15 @@ export function isSignTransactionRequest(
|
|||
msg.inputPaths.every(
|
||||
(p: any) =>
|
||||
Array.isArray(p) &&
|
||||
p.length === 3 &&
|
||||
(p.length === 3 || p.length === 4) &&
|
||||
typeof p[0] === "number" &&
|
||||
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)),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
321
packages/core/src/protocols/multislot.test.ts
Normal file
321
packages/core/src/protocols/multislot.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
318
packages/core/src/protocols/multislot.ts
Normal file
318
packages/core/src/protocols/multislot.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -29,6 +29,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,6 @@
|
|||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.6.2",
|
||||
"vitest": "^3.2.7"
|
||||
"vitest": "^3.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -205,38 +205,10 @@ export async function setupConnection(
|
|||
}
|
||||
});
|
||||
|
||||
// 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.
|
||||
// Initial dapp_ready — tells wallet we're here (wallet not yet discovered)
|
||||
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);
|
||||
|
|
@ -244,16 +216,7 @@ export async function setupConnection(
|
|||
|
||||
// ---- Wait for 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);
|
||||
}
|
||||
await waitFor(() => keyExchanged, { timeoutMs: 15000, what: "key exchange" });
|
||||
|
||||
// ---- Wait for wallet_ready with paths ----
|
||||
|
||||
|
|
|
|||
|
|
@ -45,15 +45,6 @@ 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;
|
||||
|
|
@ -226,58 +217,22 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
|
|||
const conn = this.connections.get(connectionId);
|
||||
if (!conn) return;
|
||||
|
||||
// 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.notificationProcessor = null;
|
||||
this.connections.delete(connectionId);
|
||||
this.emit("connectionsChanged");
|
||||
|
||||
if (!sendMessage || !conn.client) {
|
||||
conn.cleanup();
|
||||
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(() => {});
|
||||
}
|
||||
|
||||
// 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;
|
||||
for (const seq of conn.signSequences) {
|
||||
this.activeSignSequences.delete(seq);
|
||||
}
|
||||
clearInterval(conn.notificationProcessor ?? undefined);
|
||||
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();
|
||||
});
|
||||
this.connections.delete(connectionId);
|
||||
this.emit("connectionsChanged");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -9,10 +9,6 @@ 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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue