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>
This commit is contained in:
parent
167ec21474
commit
460e113e75
8 changed files with 861 additions and 10 deletions
24
docs/dapp.md
24
docs/dapp.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
106
docs/protocol.md
106
docs/protocol.md
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -178,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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue