Compare commits
1 commit
master
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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