Add multislot extension: multiple sig/pubkey placeholders per input
Extend inputPaths tuples with an optional 4th `slot` element so a single contract input can carry several sig/pubkey placeholders, each filled by a different key. Gated behind a `multislot` capability the wallet advertises in wallet_ready (session["hdwalletv1"].extensions.multislot); dapps must not send slotted/repeated-index requests otherwise. - core: widen inputPaths to [number, PathName, number, number?]; accept 3- or 4-tuples (non-negative integer slot) in isSignTransactionRequest; add EXT_MULTISLOT constant. - wallet: fix extractContractSighashBytes so a filled pubkey placeholder is no longer mis-read as a signature (only signature-length pushes carry a sighash flag); export validateSighashFlags / isP2PKH. - docs: protocol.md (slot semantics, placeholder byte format, capability negotiation, SIGHASH 0x41/0x61 reconciliation), extensions.md (multislot), wallet.md, dapp.md. - tests: multislot-signing.test.ts reference fill; sighash-validation pubkey-placeholder regression + slot cases; validator slot accept/ reject; integration repeated-index-with-slots passthrough. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c2b13102b9
commit
c8c1000ae7
11 changed files with 1012 additions and 18 deletions
|
|
@ -164,7 +164,10 @@ 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?]
|
||||||
|
// A contract input with several sig/pubkey placeholders lists its index once
|
||||||
|
// per slot, e.g. [[3, "receive", 0, 0], [3, "defi", 7, 1]]. Only do this when
|
||||||
|
// the wallet advertised the `multislot` extension — see below.
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Signed tx:", response.signedTransaction);
|
console.log("Signed tx:", response.signedTransaction);
|
||||||
|
|
@ -181,7 +184,7 @@ cancelButton.onclick = () => controller.abort("User cancelled");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await dappMgr.signTransaction(
|
const response = await dappMgr.signTransaction(
|
||||||
{ transaction: { ... }, inputPaths: [...] },
|
{ transaction: { ... }, inputPaths: [[0, "receive", 0]] },
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -202,7 +205,7 @@ const request: SignTransactionRequest = {
|
||||||
sequence: seq,
|
sequence: seq,
|
||||||
time: Math.floor(Date.now() / 1000),
|
time: Math.floor(Date.now() / 1000),
|
||||||
transaction: { ... },
|
transaction: { ... },
|
||||||
inputPaths: [[0, "receive", 0]],
|
inputPaths: [[0, "receive", 0]], // one entry per key; repeat the index for a multi-key contract input
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await dappMgr.sendSignRequest(request);
|
const response = await dappMgr.sendSignRequest(request);
|
||||||
|
|
|
||||||
|
|
@ -172,9 +172,22 @@ const adapter: WalletAdapter = {
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Whatever `getExtensions()` returns becomes `session["hdwalletv1"].extensions` verbatim in the
|
||||||
|
`wallet_ready` message — the `WalletConnectionManager` merges it in (it does not transform the keys).
|
||||||
|
That is the exact location the dapp reads (see "Discovering extensions" below). So returning
|
||||||
|
`{ multislot: {} }` is what the dapp sees as `session["hdwalletv1"].extensions.multislot`.
|
||||||
|
|
||||||
Wallets that don't implement these methods produce the same session as before (receive/change/defi
|
Wallets that don't implement these methods produce the same session as before (receive/change/defi
|
||||||
paths only, no extensions field).
|
paths only, no extensions field).
|
||||||
|
|
||||||
|
> Version note: folding `getExtensions()` into the session was added in a later `@wizardconnect`
|
||||||
|
> release, together with the `EXT_MULTISLOT` constant and the optional `slot` (4th) element of the
|
||||||
|
> `inputPaths` tuple type. Packages from the `0.1.x` line ignore `getExtensions()` and type
|
||||||
|
> `inputPaths` as a 3-tuple without `EXT_MULTISLOT`, so an extension advertised that way silently
|
||||||
|
> fails to negotiate and a TypeScript consumer won't compile the 4-tuple. Confirm your
|
||||||
|
> `@wizardconnect/core` / `@wizardconnect/wallet` version actually ships these (or read the `slot`
|
||||||
|
> positionally and use the `"multislot"` string literal) before relying on them.
|
||||||
|
|
||||||
## Discovering extensions (dapp side)
|
## Discovering extensions (dapp side)
|
||||||
|
|
||||||
Dapps check for extension support after receiving `wallet_ready`:
|
Dapps check for extension support after receiving `wallet_ready`:
|
||||||
|
|
@ -209,5 +222,35 @@ 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` | — | — | Wallet can fill **more than one** `sig`/`pubkey` placeholder per input, routed by the `slot` element of `inputPaths`. Needed for contracts that take multiple signatures from distinct keys in a single input. | Proposed |
|
||||||
|
|
||||||
See the discussions and specifications for each extension as they are formalized.
|
See the discussions and specifications for each extension as they are formalized.
|
||||||
|
|
||||||
|
### multislot
|
||||||
|
|
||||||
|
By default an `inputPaths` entry is a 3-tuple `[inputIndex, pathName, addressIndex]` and the wallet
|
||||||
|
contributes exactly one signature (and at most one public key) per input. Some contracts need the
|
||||||
|
wallet to fill several `sig`/`pubkey` placeholders in a single input, each with a different key.
|
||||||
|
|
||||||
|
A wallet that supports this advertises `multislot` in its session extensions:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"paths": [ /* ... */ ],
|
||||||
|
"extensions": { "multislot": {} }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The value is `{}` — no handshake data is needed; presence alone signals support.
|
||||||
|
|
||||||
|
When advertised, the dapp may add a fourth element, `slot`, to `inputPaths` entries and may list the
|
||||||
|
same `inputIndex` more than once (one entry per placeholder slot). The wallet fills the `slot`-th
|
||||||
|
sig placeholder and the `slot`-th pubkey placeholder of that input with the named key. See
|
||||||
|
[protocol.md § Multiple placeholders per input](protocol.md#multiple-placeholders-per-input-the-slot-element)
|
||||||
|
for the placeholder byte format and exact slot semantics.
|
||||||
|
|
||||||
|
**A dapp MUST NOT** emit `slot > 0` (or repeat an `inputIndex`) toward a wallet that has not
|
||||||
|
advertised `multislot`. A wallet that does not advertise it continues to receive only ordinary
|
||||||
|
3-tuple, single-signature requests, so it is unaffected. This negotiation is what prevents an
|
||||||
|
unaware wallet from silently filling only the first placeholder and returning an unspendable
|
||||||
|
transaction.
|
||||||
|
|
|
||||||
102
docs/protocol.md
102
docs/protocol.md
|
|
@ -268,22 +268,108 @@ 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 list of positional tuples. Each tuple has 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 (by named derivation path and address index within that path) that the
|
||||||
|
wallet must contribute to the input at position `inputIndex`. Only inputs the wallet must sign need
|
||||||
|
an entry — a contract input whose unlocking bytecode the dapp fully provides (including any
|
||||||
|
non-wallet keys, e.g. a counterparty's) is omitted. An input with **no** entry is simply not the
|
||||||
|
wallet's to sign and is left untouched; this is distinct from an entry the wallet cannot satisfy,
|
||||||
|
which is an error (see Wallet rules below). This lets the wallet derive the correct private keys
|
||||||
|
without scanning or guessing which key was used.
|
||||||
|
|
||||||
|
For an ordinary P2PKH input, one entry suffices and `slot` is omitted: the wallet derives that key
|
||||||
|
and produces the input's single signature.
|
||||||
|
|
||||||
|
##### Multiple placeholders per input (the `slot` element)
|
||||||
|
|
||||||
|
A contract input may carry **several `sig`/`pubkey` placeholders**, each to be filled by a
|
||||||
|
different key (e.g. an N-of-N agreement, or a function taking `(sig a, pubkey A, sig b, pubkey B)`).
|
||||||
|
The dapp builds the unlocking bytecode with one placeholder per slot and lists the input's index
|
||||||
|
once per slot, setting the fourth tuple element, `slot`:
|
||||||
|
|
||||||
|
```
|
||||||
|
inputPaths: [
|
||||||
|
[3, "receive", 0, 0], // input 3, placeholder slot 0 -> key receive/0
|
||||||
|
[3, "defi", 7, 1], // input 3, placeholder slot 1 -> key defi/7
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Placeholder format (the de-facto BCH-WC2 convention this builds on). Signatures **MUST** be
|
||||||
|
**Schnorr** — the fixed-length placeholder scheme has no room for variable-length DER ECDSA:
|
||||||
|
|
||||||
|
- a **signature placeholder** is a data push of **65 zero bytes** (`0x41` followed by 65 `0x00`) —
|
||||||
|
room for a 64-byte Schnorr signature plus its one trailing sighash-flag byte;
|
||||||
|
- a **public-key placeholder** is a data push of **33 zero bytes** (`0x21` followed by 33 `0x00`).
|
||||||
|
|
||||||
|
Because the real signature (65 bytes) and real compressed public key (33 bytes) are exactly the
|
||||||
|
length of their placeholders, the wallet splices the value in **value-for-value, keeping the same
|
||||||
|
push opcode** (`0x41` / `0x21`) and not changing the bytecode length.
|
||||||
|
|
||||||
|
`slot` semantics:
|
||||||
|
|
||||||
|
- `slot` counts **sig placeholders and pubkey placeholders independently**, left-to-right, starting
|
||||||
|
at 0. An input may legally have a different number of each (e.g. 2 sig placeholders but only 1
|
||||||
|
pubkey placeholder, when one key's pubkey is hard-coded in the redeem script).
|
||||||
|
- An entry `[i, path, addr, k]` tells the wallet: derive the key for `(path, addr)`, write its
|
||||||
|
signature into the **k-th sig placeholder** of input `i`, and write its compressed public key into
|
||||||
|
the **k-th pubkey placeholder** of input `i` if one is present.
|
||||||
|
- `slot` is **optional and defaults to 0**, so an entry with no `slot` fills the first sig (and
|
||||||
|
first pubkey) placeholder — identical to the single-signature behaviour. Existing 3-tuple requests
|
||||||
|
are therefore unchanged.
|
||||||
|
|
||||||
|
The dapp ships the unsigned unlocking-bytecode template per input at **`sourceOutputs[i].unlockingBytecode`**
|
||||||
|
(equivalently, the decoded `transaction.inputs[i].unlockingBytecode`). The wallet locates the k-th
|
||||||
|
placeholder by scanning that template for the k-th occurrence of the zero-filled push above, then
|
||||||
|
splices in the real signature / public key. It does **not** rely on the order of the `inputPaths`
|
||||||
|
tuples — the `slot` value is authoritative.
|
||||||
|
|
||||||
|
Wallet rules (**MUST**):
|
||||||
|
|
||||||
|
- **Do not deduplicate `inputPaths` by `inputIndex`.** Several entries may share one `inputIndex`
|
||||||
|
(one per slot); collapsing them into a map keyed by index drops signatures and yields an
|
||||||
|
unspendable transaction. Keep every entry.
|
||||||
|
- **Compute each input's sighash once.** All signatures within one input commit to the **same
|
||||||
|
sighash**: the signing serialization covers `sourceOutputs[i].contract.redeemScript` and the
|
||||||
|
transaction, *not* the unlocking bytecode being filled — so filling one slot does not invalidate
|
||||||
|
another's sighash. Compute it once per input and reuse it for every slot, varying only the key.
|
||||||
|
- **Reject, don't under-fill.** If an entry cannot be satisfied — the named path/index does not map
|
||||||
|
to a key, or the input has no placeholder at the requested `slot` — the wallet **MUST** fail the
|
||||||
|
whole request with an error. It **MUST NOT** return a transaction with a leftover zero
|
||||||
|
placeholder, which would be silently unspendable.
|
||||||
|
|
||||||
|
##### Capability negotiation (required)
|
||||||
|
|
||||||
|
Filling more than one placeholder per input is gated behind the **`multislot` extension**. A wallet
|
||||||
|
that supports it advertises the key in its `wallet_ready` handshake:
|
||||||
|
`session["hdwalletv1"].extensions.multislot` (see [extensions.md](extensions.md#multislot)). A dapp
|
||||||
|
**MUST NOT** send any entry with `slot > 0` (nor more than one entry for the same `inputIndex`)
|
||||||
|
unless the connected wallet advertised `multislot`. A wallet that does not advertise it only ever
|
||||||
|
receives single-signature (`slot` 0 / 3-tuple) requests. This prevents the silent failure mode
|
||||||
|
where a wallet unaware of slots fills only the first placeholder and returns an unspendable
|
||||||
|
transaction.
|
||||||
|
|
||||||
#### SIGHASH requirement (security-critical)
|
#### SIGHASH requirement (security-critical)
|
||||||
|
|
||||||
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS` and **MUST**
|
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID` and **SHOULD** additionally set
|
||||||
reject any request that would require different flags.
|
`SIGHASH_UTXOS`. Concretely, the sighash-flag byte — the last byte of each signature push — **MUST**
|
||||||
|
be either `0x41` (`SIGHASH_ALL | SIGHASH_FORKID`) or `0x61` (`SIGHASH_ALL | SIGHASH_FORKID |
|
||||||
|
SIGHASH_UTXOS`); the wallet **MUST** reject any request that would require any other flags. The bit
|
||||||
|
values are `SIGHASH_ALL = 0x01`, `SIGHASH_UTXOS = 0x20`, `SIGHASH_FORKID = 0x40`. (`validateSighashFlags`
|
||||||
|
in `@wizardconnect/wallet` enforces exactly this `{0x41, 0x61}` set.)
|
||||||
|
|
||||||
`SIGHASH_ALL` ensures the signature commits to the entire transaction (all inputs and all outputs).
|
`SIGHASH_ALL` ensures the signature commits to the entire transaction (all inputs and all outputs).
|
||||||
Without it, an attacker could collect a valid signature and graft it onto a different transaction —
|
Without it, an attacker could collect a valid signature and graft it onto a different transaction —
|
||||||
for example, using `SIGHASH_NONE` an attacker could replace every output to redirect funds.
|
for example, using `SIGHASH_NONE` an attacker could replace every output to redirect funds.
|
||||||
|
|
||||||
Because `inputPaths` lets the dapp specify which key signs each input, the wallet no longer
|
Because `inputPaths` lets the dapp specify which key(s) sign each input (one or more per input for contracts), the wallet no longer
|
||||||
independently verifies that the key matches the UTXO's locking bytecode. This is safe **only** when
|
independently verifies that the key matches the UTXO's locking bytecode. This is safe **only** when
|
||||||
`SIGHASH_ALL` is enforced: if the dapp provides a wrong path, the resulting signature is invalid
|
`SIGHASH_ALL` is enforced: if the dapp provides a wrong path, the resulting signature is invalid
|
||||||
(public key hash mismatch) and the transaction cannot broadcast. Without `SIGHASH_ALL`, a
|
(public key hash mismatch) and the transaction cannot broadcast. Without `SIGHASH_ALL`, a
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ has already been emitted. The guard is cleared when a response is sent (`sendSig
|
||||||
**SIGHASH enforcement:** The wallet **MUST** sign every input with
|
**SIGHASH 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
|
||||||
docs for the security rationale. Because the dapp specifies `inputPaths`, the wallet trusts the
|
docs for the security rationale. Because the dapp specifies `inputPaths` (which may list multiple keys per input for contract placeholders), the wallet trusts the
|
||||||
dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply
|
dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply
|
||||||
invalid and cannot be repurposed).
|
invalid and cannot be repurposed).
|
||||||
|
|
||||||
|
|
@ -202,6 +202,13 @@ class MyAdapter implements WalletAdapter {
|
||||||
async signTransaction(request) {
|
async signTransaction(request) {
|
||||||
// Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex.
|
// Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex.
|
||||||
// See protocol.md "SIGHASH requirement" — other sighash flags MUST be rejected.
|
// See protocol.md "SIGHASH requirement" — other sighash flags MUST be rejected.
|
||||||
|
// For each request.inputPaths entry [inputIndex, pathName, addressIndex, slot?]
|
||||||
|
// derive the key and, for a contract input, fill the slot-th sig placeholder
|
||||||
|
// (a 65-zero push) and the slot-th pubkey placeholder (a 33-zero push). slot
|
||||||
|
// defaults to 0. Only honour slot > 0 / repeated indices if you advertised the
|
||||||
|
// `multislot` extension (see below).
|
||||||
|
// Optionally: import { validateSighashFlags } from "@wizardconnect/wallet"; to verify
|
||||||
|
// after filling placeholders.
|
||||||
return { signedTransactionHex: "..." };
|
return { signedTransactionHex: "..." };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -233,3 +240,56 @@ manager.on("connectionsChanged", () => {
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Multiple signatures per input (the `multislot` extension)
|
||||||
|
|
||||||
|
By default a wallet contributes one signature (and at most one public key) per input. Some contracts
|
||||||
|
need the wallet to fill **several** `sig`/`pubkey` placeholders in a single input, each with a
|
||||||
|
different key. This is the `multislot` extension. Implementing it has two halves.
|
||||||
|
|
||||||
|
### 1. Advertise support in the handshake
|
||||||
|
|
||||||
|
Return the extension key from your adapter's `getExtensions()`. The manager folds whatever you return
|
||||||
|
into the wallet's `wallet_ready` message at `session["hdwalletv1"].extensions`, which is exactly where
|
||||||
|
the dapp looks:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { EXT_MULTISLOT } from "@wizardconnect/core";
|
||||||
|
|
||||||
|
class MyAdapter implements WalletAdapter {
|
||||||
|
// ...
|
||||||
|
getExtensions() {
|
||||||
|
return { [EXT_MULTISLOT]: {} }; // presence = support; no handshake data needed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A dapp will only send slotted / repeated-index requests to a wallet that advertised this. If you do
|
||||||
|
not advertise it you keep receiving ordinary single-signature requests and need do nothing else.
|
||||||
|
|
||||||
|
> Requires a `@wizardconnect/*` version whose `WalletConnectionManager` folds `getExtensions()` into
|
||||||
|
> the session (and whose `inputPaths` type carries the optional `slot`). Older `0.1.x` packages
|
||||||
|
> ignore `getExtensions()`, so the capability silently never negotiates — check your version.
|
||||||
|
|
||||||
|
### 2. Fill the slots when signing
|
||||||
|
|
||||||
|
For each `inputPaths` entry `[inputIndex, pathName, addressIndex, slot?]`, derive the key and, for a
|
||||||
|
contract input, fill the **slot-th** `sig` placeholder (a `0x41` + 65-zero push — 64-byte Schnorr sig
|
||||||
|
plus its sighash-flag byte) and the **slot-th** `pubkey` placeholder (a `0x21` + 33-zero push). `slot`
|
||||||
|
defaults to `0`. See [protocol.md § Multiple placeholders per input](protocol.md#multiple-placeholders-per-input-the-slot-element)
|
||||||
|
for the exact byte format and slot-counting rule, and `packages/wallet/src/multislot-signing.test.ts`
|
||||||
|
for a reference fill (`fillContractInput`).
|
||||||
|
|
||||||
|
Three rules are easy to get wrong:
|
||||||
|
|
||||||
|
- **Keep every entry** — do **not** build a `Map<inputIndex, key>`; that collapses the per-slot
|
||||||
|
entries (last-write-wins) and drops signatures. Group into `Map<inputIndex, entry[]>` instead.
|
||||||
|
- **Compute the sighash once per input** and reuse it for all slots — it covers
|
||||||
|
`contract.redeemScript`, not the unlocking bytecode you are mutating, so filling one slot does not
|
||||||
|
change another slot's sighash. Only the signing key differs between slots.
|
||||||
|
- **Reject if you cannot fill a requested slot** (unmappable path, or no placeholder at that slot) —
|
||||||
|
return a sign error rather than a transaction with a leftover zero placeholder, which is silently
|
||||||
|
unspendable.
|
||||||
|
|
||||||
|
If you want a post-fill safety check, `validateSighashFlags` from `@wizardconnect/wallet` verifies
|
||||||
|
that every filled signature uses `SIGHASH_ALL` (it correctly ignores filled pubkey placeholders).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,45 @@ describe("isSignTransactionRequest", () => {
|
||||||
expect(isSignTransactionRequest({ ...valid, inputPaths: [] })).toBe(true);
|
expect(isSignTransactionRequest({ ...valid, inputPaths: [] })).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts 4-tuple entries carrying a slot index (multislot)", () => {
|
||||||
|
const multi = {
|
||||||
|
...valid,
|
||||||
|
inputPaths: [
|
||||||
|
[0, "receive", 0, 0], // input 0, slot 0
|
||||||
|
[0, "defi", 7, 1], // same input 0, slot 1 — a different key
|
||||||
|
[1, "change", 3], // 3-tuple still allowed alongside 4-tuples
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(isSignTransactionRequest(multi)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-integer slot", () => {
|
||||||
|
expect(
|
||||||
|
isSignTransactionRequest({
|
||||||
|
...valid,
|
||||||
|
inputPaths: [[0, "receive", 0, 1.5]],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a negative slot", () => {
|
||||||
|
expect(
|
||||||
|
isSignTransactionRequest({
|
||||||
|
...valid,
|
||||||
|
inputPaths: [[0, "receive", 0, -1]],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a 5-element tuple", () => {
|
||||||
|
expect(
|
||||||
|
isSignTransactionRequest({
|
||||||
|
...valid,
|
||||||
|
inputPaths: [[0, "receive", 0, 1, 9]],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects missing inputPaths", () => {
|
it("rejects missing inputPaths", () => {
|
||||||
const noInputPaths = { ...valid } as Record<string, unknown>;
|
const noInputPaths = { ...valid } as Record<string, unknown>;
|
||||||
delete noInputPaths.inputPaths;
|
delete noInputPaths.inputPaths;
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,13 @@ export const PATH_DEFI = "defi" as const;
|
||||||
|
|
||||||
export type PathName = string;
|
export type PathName = string;
|
||||||
|
|
||||||
|
/// Extension name advertised by wallets that can fill more than one sig/pubkey
|
||||||
|
/// placeholder per input (via the optional `slot` element of inputPaths).
|
||||||
|
/// Presence in `Hdwalletv1Session.extensions` indicates support. Dapps MUST
|
||||||
|
/// only send slotted / multi-entry-per-input requests to wallets that advertise
|
||||||
|
/// it. See docs/extensions.md § multislot and docs/protocol.md.
|
||||||
|
export const EXT_MULTISLOT = "multislot" as const;
|
||||||
|
|
||||||
export interface PathXpub {
|
export interface PathXpub {
|
||||||
name: PathName;
|
name: PathName;
|
||||||
xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally)
|
xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally)
|
||||||
|
|
@ -151,7 +158,29 @@ 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]
|
/**
|
||||||
|
* List of [inputIndex, pathName, addressIndex, slot?] tuples naming the HD
|
||||||
|
* key(s) the wallet must contribute to each input.
|
||||||
|
*
|
||||||
|
* For a P2PKH input one entry suffices (slot defaults to 0): the wallet
|
||||||
|
* derives that key and produces the input's single signature.
|
||||||
|
*
|
||||||
|
* A contract input may carry several sig/pubkey placeholders, each filled by
|
||||||
|
* a different key. The dapp lists the input's index once per placeholder pair
|
||||||
|
* and sets `slot` to select which pair this key fills. `slot` counts sig
|
||||||
|
* placeholders and pubkey placeholders independently, left-to-right, starting
|
||||||
|
* at 0; the wallet fills the slot-th sig placeholder (and the slot-th pubkey
|
||||||
|
* placeholder, if one exists) with the derived key. All signatures in one
|
||||||
|
* input commit to the same sighash, so they differ only by key.
|
||||||
|
*
|
||||||
|
* `slot` is OPTIONAL and defaults to 0, so existing 3-tuples are unchanged.
|
||||||
|
* Sending any entry with slot > 0 (or more than one entry per inputIndex)
|
||||||
|
* requires the wallet to advertise the `multislot` extension (EXT_MULTISLOT)
|
||||||
|
* in its wallet_ready handshake; dapps MUST check for it first and MUST NOT
|
||||||
|
* downgrade to a single signature otherwise. See docs/protocol.md and
|
||||||
|
* docs/extensions.md.
|
||||||
|
*/
|
||||||
|
inputPaths: [number, PathName, number, number?][]; // [inputIndex, pathName, addressIndex, slot?]
|
||||||
/// Dapp-provided transaction summary. Wallets should use this for display
|
/// Dapp-provided transaction summary. Wallets should use this for display
|
||||||
/// when present rather than parsing the raw transaction themselves.
|
/// when present rather than parsing the raw transaction themselves.
|
||||||
txSummary?: TxSummary;
|
txSummary?: TxSummary;
|
||||||
|
|
@ -215,10 +244,13 @@ 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" &&
|
||||||
|
// optional slot: non-negative integer
|
||||||
|
(p.length === 3 ||
|
||||||
|
(typeof p[3] === "number" && Number.isInteger(p[3]) && p[3] >= 0)),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,3 +14,4 @@ export type {
|
||||||
PendingSignRequest,
|
PendingSignRequest,
|
||||||
WalletConnectionManagerEvents,
|
WalletConnectionManagerEvents,
|
||||||
} from "./wallet-connection-manager.js";
|
} from "./wallet-connection-manager.js";
|
||||||
|
export { isP2PKH, validateSighashFlags } from "./sighash-validation.js";
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,37 @@ describe("WalletConnectionManager — sign_transaction_request with inputPaths",
|
||||||
]);
|
]);
|
||||||
}, 15000);
|
}, 15000);
|
||||||
|
|
||||||
|
it("wallet receives repeated inputIndex with slots (no dedup) for a multi-placeholder contract input", async () => {
|
||||||
|
const signMsg: SignTransactionRequest = {
|
||||||
|
action: RelayMsgAction.SignTransactionRequest,
|
||||||
|
sequence: 3,
|
||||||
|
transaction: {
|
||||||
|
transaction: { inputs: [], outputs: [], version: 2, locktime: 0 },
|
||||||
|
sourceOutputs: [],
|
||||||
|
userPrompt: "multi-key contract test",
|
||||||
|
broadcast: false,
|
||||||
|
},
|
||||||
|
// multislot: input 0 appears once per placeholder slot, each routed to a
|
||||||
|
// different key. The relay/manager must forward these verbatim — no dedup.
|
||||||
|
inputPaths: [
|
||||||
|
[0, "receive", 0, 0], // input 0, slot 0 -> receive/0
|
||||||
|
[0, "defi", 7, 1], // input 0, slot 1 -> defi/7
|
||||||
|
],
|
||||||
|
time: Math.floor(Date.now() / 1000),
|
||||||
|
};
|
||||||
|
await dappClient!.relay(signMsg);
|
||||||
|
|
||||||
|
await waitFor(() => pendingRequests.length >= 2, {
|
||||||
|
timeoutMs: 5000,
|
||||||
|
what: "second pendingSignRequest with repeated inputPaths",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(pendingRequests[1].request.inputPaths).toEqual([
|
||||||
|
[0, "receive", 0, 0],
|
||||||
|
[0, "defi", 7, 1],
|
||||||
|
]);
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
it("wallet receives empty inputPaths for zero-input transaction", async () => {
|
it("wallet receives empty inputPaths for zero-input transaction", async () => {
|
||||||
const signMsg: SignTransactionRequest = {
|
const signMsg: SignTransactionRequest = {
|
||||||
action: RelayMsgAction.SignTransactionRequest,
|
action: RelayMsgAction.SignTransactionRequest,
|
||||||
|
|
@ -151,11 +182,11 @@ describe("WalletConnectionManager — sign_transaction_request with inputPaths",
|
||||||
};
|
};
|
||||||
await dappClient!.relay(signMsg);
|
await dappClient!.relay(signMsg);
|
||||||
|
|
||||||
await waitFor(() => pendingRequests.length >= 2, {
|
await waitFor(() => pendingRequests.length >= 3, {
|
||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
what: "second pendingSignRequest event on wallet",
|
what: "third pendingSignRequest event on wallet",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(pendingRequests[1].request.inputPaths).toEqual([]);
|
expect(pendingRequests[2].request.inputPaths).toEqual([]);
|
||||||
}, 15000);
|
}, 15000);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
225
packages/wallet/src/multislot-signing.test.ts
Normal file
225
packages/wallet/src/multislot-signing.test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
// Copyright (C) 2026 Whiterun LLC,
|
||||||
|
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||||
|
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
secp256k1,
|
||||||
|
sha256,
|
||||||
|
encodeTransaction,
|
||||||
|
binToHex,
|
||||||
|
hexToBin,
|
||||||
|
} from "@bitauth/libauth";
|
||||||
|
import { validateSighashFlags } from "./sighash-validation.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference implementation + tests for the `multislot` extension: a contract
|
||||||
|
* input with more than one sig/pubkey placeholder, each filled by a different
|
||||||
|
* key, routed by the `slot` element of inputPaths ([inputIndex, pathName,
|
||||||
|
* addressIndex, slot]).
|
||||||
|
*
|
||||||
|
* `fillContractInput` below mirrors what a WalletAdapter does: for each
|
||||||
|
* inputPaths entry it fills the slot-th sig placeholder (and slot-th pubkey
|
||||||
|
* placeholder) of the input. It locates placeholders in the ORIGINAL template
|
||||||
|
* so the result is independent of fill order. See docs/protocol.md §
|
||||||
|
* "Multiple placeholders per input (the slot element)".
|
||||||
|
*
|
||||||
|
* SIGHASH: all signatures within ONE input commit to the SAME sighash (same
|
||||||
|
* input, same covered contract.redeemScript, same flags) — only the signing key
|
||||||
|
* differs between slots. The wallet computes that sighash ONCE per input and
|
||||||
|
* reuses it for every slot. Below, `inputSighash` is a fixed stand-in for that
|
||||||
|
* value (these tests validate slot ROUTING and sighash-flag handling via the
|
||||||
|
* production `validateSighashFlags`, not consensus signature validity).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// SIGHASH_ALL | SIGHASH_FORKID. 0x61 (… | SIGHASH_UTXOS) is also valid and is
|
||||||
|
// what production wallets use; validateSighashFlags accepts both {0x41, 0x61}.
|
||||||
|
const SIGHASH = 0x41;
|
||||||
|
|
||||||
|
// Placeholder pushes the dapp embeds in the unsigned unlocking bytecode.
|
||||||
|
const SIG_PLACEHOLDER = "41" + "00".repeat(65); // push 65 zero bytes (64 sig + 1 sighash)
|
||||||
|
const PUBKEY_PLACEHOLDER = "21" + "00".repeat(33); // push 33 zero bytes
|
||||||
|
|
||||||
|
// A minimal "contract" (P2SH) lock so validateSighashFlags takes the
|
||||||
|
// template-diff path rather than the P2PKH path.
|
||||||
|
const contractLock = Uint8Array.from([0xa9, 0x14, ...new Uint8Array(20), 0x87]);
|
||||||
|
|
||||||
|
function pub(priv: Uint8Array): Uint8Array {
|
||||||
|
const p = secp256k1.derivePublicKeyCompressed(priv);
|
||||||
|
if (typeof p === "string") throw new Error(p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Char offsets of every occurrence of `needle` in `hex`. */
|
||||||
|
function occurrences(hex: string, needle: string): number[] {
|
||||||
|
const out: number[] = [];
|
||||||
|
let from = 0;
|
||||||
|
for (;;) {
|
||||||
|
const at = hex.indexOf(needle, from);
|
||||||
|
if (at === -1) break;
|
||||||
|
out.push(at);
|
||||||
|
from = at + needle.length;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlotEntry {
|
||||||
|
slot: number;
|
||||||
|
sig: Uint8Array;
|
||||||
|
pubkey?: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wallet-side fill of a contract input's unlocking bytecode. For each entry,
|
||||||
|
* write its signature into the slot-th sig placeholder and its public key into
|
||||||
|
* the slot-th pubkey placeholder (if present).
|
||||||
|
*
|
||||||
|
* Placeholder positions are located ONCE in the original template; because each
|
||||||
|
* replacement is the same length as the placeholder it replaces, those offsets
|
||||||
|
* stay valid as we fill — so the result does not depend on entry order.
|
||||||
|
*
|
||||||
|
* Returns the filled bytecode plus how many sig/pubkey placeholders existed, so
|
||||||
|
* the caller can confirm every requested slot was filled. A real adapter MUST
|
||||||
|
* reject the request rather than return an under-filled (unspendable) tx — see
|
||||||
|
* the "rejects" test below.
|
||||||
|
*/
|
||||||
|
function fillContractInput(
|
||||||
|
templateHex: string,
|
||||||
|
entries: SlotEntry[],
|
||||||
|
): { hex: string; sigCount: number; pubkeyCount: number } {
|
||||||
|
const sigAt = occurrences(templateHex, SIG_PLACEHOLDER);
|
||||||
|
const pkAt = occurrences(templateHex, PUBKEY_PLACEHOLDER);
|
||||||
|
let out = templateHex;
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.slot < sigAt.length) {
|
||||||
|
const at = sigAt[e.slot];
|
||||||
|
out =
|
||||||
|
out.slice(0, at) +
|
||||||
|
"41" +
|
||||||
|
binToHex(e.sig) +
|
||||||
|
out.slice(at + SIG_PLACEHOLDER.length);
|
||||||
|
}
|
||||||
|
if (e.pubkey && e.slot < pkAt.length) {
|
||||||
|
const at = pkAt[e.slot];
|
||||||
|
out =
|
||||||
|
out.slice(0, at) +
|
||||||
|
"21" +
|
||||||
|
binToHex(e.pubkey) +
|
||||||
|
out.slice(at + PUBKEY_PLACEHOLDER.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hex: out, sigCount: sigAt.length, pubkeyCount: pkAt.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSignedTxHex(unlocking: Uint8Array): string {
|
||||||
|
const tx = encodeTransaction({
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash: new Uint8Array(32),
|
||||||
|
outpointIndex: 0,
|
||||||
|
unlockingBytecode: unlocking,
|
||||||
|
sequenceNumber: 0xffffffff,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [{ lockingBytecode: Uint8Array.of(0x6a), valueSatoshis: 0n }],
|
||||||
|
});
|
||||||
|
return binToHex(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("multislot signing", () => {
|
||||||
|
const kReceive = new Uint8Array(32).fill(0x11); // as if receive/0
|
||||||
|
const kDefi = new Uint8Array(32).fill(0x22); // as if defi/7
|
||||||
|
|
||||||
|
// One sighash for the whole input — every slot signs THIS, only the key
|
||||||
|
// differs. (A real wallet derives it once via generateSigningSerializationBCH
|
||||||
|
// over the input's covered contract.redeemScript.)
|
||||||
|
const inputSighash = sha256.hash(Uint8Array.of(0xde, 0xad, 0xbe, 0xef));
|
||||||
|
|
||||||
|
function sign(priv: Uint8Array): Uint8Array {
|
||||||
|
const sig = secp256k1.signMessageHashSchnorr(priv, inputSighash);
|
||||||
|
if (typeof sig === "string") throw new Error(sig);
|
||||||
|
return Uint8Array.from([...sig, SIGHASH]); // 64 + 1 == 65, matches placeholder
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template with two (sig, pubkey) placeholder pairs.
|
||||||
|
const twoPairs =
|
||||||
|
SIG_PLACEHOLDER + PUBKEY_PLACEHOLDER + SIG_PLACEHOLDER + PUBKEY_PLACEHOLDER;
|
||||||
|
|
||||||
|
it("routes each slot to its key (sig and pubkey), independent of entry order", () => {
|
||||||
|
// inputPaths: [0,"receive",0,0] and [0,"defi",7,1]. Pass them out of slot
|
||||||
|
// order to prove the fill is order-independent.
|
||||||
|
const { hex } = fillContractInput(twoPairs, [
|
||||||
|
{ slot: 1, sig: sign(kDefi), pubkey: pub(kDefi) },
|
||||||
|
{ slot: 0, sig: sign(kReceive), pubkey: pub(kReceive) },
|
||||||
|
]);
|
||||||
|
const signed = hexToBin(hex);
|
||||||
|
|
||||||
|
// slot 0 sig (bytes 1..64) verifies against RECEIVE, not DEFI — over the
|
||||||
|
// SAME shared input sighash.
|
||||||
|
const sig0 = signed.slice(1, 65);
|
||||||
|
expect(signed[65]).toBe(SIGHASH);
|
||||||
|
expect(secp256k1.verifySignatureSchnorr(sig0, pub(kReceive), inputSighash)).toBe(true);
|
||||||
|
expect(secp256k1.verifySignatureSchnorr(sig0, pub(kDefi), inputSighash)).toBe(false);
|
||||||
|
|
||||||
|
// slot 1 sig (bytes 101..164) verifies against DEFI — same sighash.
|
||||||
|
const sig1 = signed.slice(101, 165);
|
||||||
|
expect(signed[165]).toBe(SIGHASH);
|
||||||
|
expect(secp256k1.verifySignatureSchnorr(sig1, pub(kDefi), inputSighash)).toBe(true);
|
||||||
|
|
||||||
|
// pubkey placeholders received the matching keys in slot order.
|
||||||
|
expect(binToHex(signed.slice(67, 100))).toBe(binToHex(pub(kReceive)));
|
||||||
|
expect(binToHex(signed.slice(167, 200))).toBe(binToHex(pub(kDefi)));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validateSighashFlags accepts the multi-slot signed input (repeated index)", () => {
|
||||||
|
const { hex } = fillContractInput(twoPairs, [
|
||||||
|
{ slot: 0, sig: sign(kReceive), pubkey: pub(kReceive) },
|
||||||
|
{ slot: 1, sig: sign(kDefi), pubkey: pub(kDefi) },
|
||||||
|
]);
|
||||||
|
const signedHex = buildSignedTxHex(hexToBin(hex));
|
||||||
|
|
||||||
|
// The dapp repeats input index 0, one entry per slot. The wallet MUST NOT
|
||||||
|
// deduplicate by index (that would drop a signature).
|
||||||
|
const inputPaths: [number, string, number, number][] = [
|
||||||
|
[0, "receive", 0, 0],
|
||||||
|
[0, "defi", 7, 1],
|
||||||
|
];
|
||||||
|
const walletInputIndices = inputPaths.map(([i]) => i); // [0, 0]
|
||||||
|
expect(() =>
|
||||||
|
validateSighashFlags(
|
||||||
|
signedHex,
|
||||||
|
walletInputIndices,
|
||||||
|
[contractLock],
|
||||||
|
[hexToBin(twoPairs)],
|
||||||
|
),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slot 0 (default) fills the first placeholder — back-compatible single-sig", () => {
|
||||||
|
const { hex } = fillContractInput(twoPairs, [
|
||||||
|
{ slot: 0, sig: sign(kReceive), pubkey: pub(kReceive) },
|
||||||
|
]);
|
||||||
|
const signed = hexToBin(hex);
|
||||||
|
|
||||||
|
// first pair filled...
|
||||||
|
expect(binToHex(signed.slice(67, 100))).toBe(binToHex(pub(kReceive)));
|
||||||
|
// ...second pair untouched (still zero placeholders).
|
||||||
|
expect(Array.from(signed.slice(101, 166)).every((b) => b === 0)).toBe(true);
|
||||||
|
expect(Array.from(signed.slice(167, 200)).every((b) => b === 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an adapter must reject a slot it cannot fill (no such placeholder)", () => {
|
||||||
|
const onePair = SIG_PLACEHOLDER + PUBKEY_PLACEHOLDER;
|
||||||
|
const { hex, sigCount } = fillContractInput(onePair, [
|
||||||
|
{ slot: 1, sig: sign(kDefi), pubkey: pub(kDefi) },
|
||||||
|
]);
|
||||||
|
// The low-level fill is a no-op for the absent slot...
|
||||||
|
expect(hex).toBe(onePair);
|
||||||
|
// ...so the adapter detects slot >= sigCount and MUST reject rather than
|
||||||
|
// return an unspendable transaction with a leftover zero placeholder.
|
||||||
|
const requestedSlot = 1;
|
||||||
|
expect(requestedSlot >= sigCount).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
285
packages/wallet/src/sighash-validation.test.ts
Normal file
285
packages/wallet/src/sighash-validation.test.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
// Copyright (C) 2026 Whiterun LLC,
|
||||||
|
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||||
|
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
isP2PKH,
|
||||||
|
validateSighashFlags,
|
||||||
|
} from "./sighash-validation.js";
|
||||||
|
import {
|
||||||
|
binToHex,
|
||||||
|
encodeTransaction,
|
||||||
|
hash160,
|
||||||
|
sha256,
|
||||||
|
} from "@bitauth/libauth";
|
||||||
|
|
||||||
|
// Minimal P2PKH locking script: OP_DUP OP_HASH160 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG
|
||||||
|
const p2pkhLock = Uint8Array.from([
|
||||||
|
0x76, 0xa9, 0x14,
|
||||||
|
...new Uint8Array(20), // hash160 placeholder
|
||||||
|
0x88, 0xac,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Non-P2PKH (contract) locking: something else, e.g. P2SH
|
||||||
|
const contractLock = Uint8Array.from([0xa9, 0x14, ...new Uint8Array(20), 0x87]);
|
||||||
|
|
||||||
|
describe("isP2PKH", () => {
|
||||||
|
it("recognizes exact P2PKH template", () => {
|
||||||
|
expect(isP2PKH(p2pkhLock)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wrong length", () => {
|
||||||
|
expect(isP2PKH(new Uint8Array(24))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wrong prefix", () => {
|
||||||
|
const bad = Uint8Array.from(p2pkhLock);
|
||||||
|
bad[0] = 0x00;
|
||||||
|
expect(isP2PKH(bad)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects wrong suffix", () => {
|
||||||
|
const bad = Uint8Array.from(p2pkhLock);
|
||||||
|
bad[24] = 0x00;
|
||||||
|
expect(isP2PKH(bad)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateSighashFlags", () => {
|
||||||
|
it("does nothing for empty walletInputIndices", () => {
|
||||||
|
expect(() => validateSighashFlags("00", [], [p2pkhLock])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects input index out of range", () => {
|
||||||
|
const prevHash = new Uint8Array(32);
|
||||||
|
const unlocking = new Uint8Array([0x00]); // minimal
|
||||||
|
const tx = encodeTransaction({
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash: prevHash,
|
||||||
|
outpointIndex: 0,
|
||||||
|
unlockingBytecode: unlocking,
|
||||||
|
sequenceNumber: 0xffffffff,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const hex = binToHex(tx);
|
||||||
|
expect(() =>
|
||||||
|
validateSighashFlags(hex, [5], [p2pkhLock]),
|
||||||
|
).toThrow(/inputPaths references input 5/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips contract input when no unsignedBytecode template provided", () => {
|
||||||
|
const prevHash = new Uint8Array(32);
|
||||||
|
const unlocking = new Uint8Array([0x00]);
|
||||||
|
const tx = encodeTransaction({
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash: prevHash,
|
||||||
|
outpointIndex: 0,
|
||||||
|
unlockingBytecode: unlocking,
|
||||||
|
sequenceNumber: 0xffffffff,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const hex = binToHex(tx);
|
||||||
|
expect(() =>
|
||||||
|
validateSighashFlags(hex, [0], [contractLock], []),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts contract input with allowed sighash extracted from placeholder diff (single sig)", () => {
|
||||||
|
// Build minimal tx structure. We only care about the unlocking bytecode bytes for extraction.
|
||||||
|
// A dummy 1-in 1-out tx.
|
||||||
|
const prevHash = new Uint8Array(32); // all zero ok for test
|
||||||
|
const unsignedTemplate = new Uint8Array([
|
||||||
|
0x41, // push 65 bytes (sig placeholder length for schnorr+hash)
|
||||||
|
...new Uint8Array(65), // placeholder zeros for <sig>
|
||||||
|
]);
|
||||||
|
// "Signed" version: differ starting at first data byte (to trigger extract at push start)
|
||||||
|
const signedUnlock = new Uint8Array(unsignedTemplate.length);
|
||||||
|
signedUnlock.set(unsignedTemplate);
|
||||||
|
signedUnlock[1] = 0x01; // first data byte non-zero (real sigs start non-zero)
|
||||||
|
// Put a fake sighash in the last data byte
|
||||||
|
signedUnlock[65] = 0x41; // the sighash byte position (pushLen pos + pushLen)
|
||||||
|
|
||||||
|
const tx = encodeTransaction({
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash: prevHash,
|
||||||
|
outpointIndex: 0,
|
||||||
|
unlockingBytecode: signedUnlock,
|
||||||
|
sequenceNumber: 0xffffffff,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{
|
||||||
|
lockingBytecode: new Uint8Array([0x6a]), // OP_RETURN dummy
|
||||||
|
valueSatoshis: 0n,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedHex = binToHex(tx);
|
||||||
|
// locking for input0 is contract, unsigned provided
|
||||||
|
expect(() =>
|
||||||
|
validateSighashFlags(signedHex, [0], [contractLock], [unsignedTemplate]),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts contract input with multiple sig placeholders (repeated input index case)", () => {
|
||||||
|
const prevHash = new Uint8Array(32);
|
||||||
|
// Template has two sig slots: first 65-byte, second 70-byte (der-like)
|
||||||
|
const unsignedTemplate = new Uint8Array([
|
||||||
|
0x41,
|
||||||
|
...new Uint8Array(65),
|
||||||
|
0x46,
|
||||||
|
...new Uint8Array(70),
|
||||||
|
]);
|
||||||
|
const signedUnlock = new Uint8Array(unsignedTemplate.length);
|
||||||
|
signedUnlock.set(unsignedTemplate);
|
||||||
|
// Make first data bytes differ so extract detects start of each push data
|
||||||
|
signedUnlock[1] = 0x01;
|
||||||
|
signedUnlock[66 + 1] = 0x02; // after first push: 0:len1,1-65:data1,66:len2,67...:data2
|
||||||
|
// first sig's sighash at end of its data
|
||||||
|
signedUnlock[65] = 0x41;
|
||||||
|
// second sig's sighash
|
||||||
|
signedUnlock[66 + 70] = 0x61; // 0x61 also allowed
|
||||||
|
|
||||||
|
const tx = encodeTransaction({
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash: prevHash,
|
||||||
|
outpointIndex: 0,
|
||||||
|
unlockingBytecode: signedUnlock,
|
||||||
|
sequenceNumber: 0xffffffff,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedHex = binToHex(tx);
|
||||||
|
expect(() =>
|
||||||
|
validateSighashFlags(signedHex, [0], [contractLock], [unsignedTemplate]),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a filled pubkey placeholder (not mis-read as a signature)", () => {
|
||||||
|
const prevHash = new Uint8Array(32);
|
||||||
|
// Template: one sig placeholder (65) + one pubkey placeholder (33).
|
||||||
|
const unsignedTemplate = new Uint8Array([
|
||||||
|
0x41,
|
||||||
|
...new Uint8Array(65),
|
||||||
|
0x21,
|
||||||
|
...new Uint8Array(33),
|
||||||
|
]);
|
||||||
|
const signedUnlock = Uint8Array.from(unsignedTemplate);
|
||||||
|
// Fill the sig: first data byte differs, sighash byte at end of its push.
|
||||||
|
signedUnlock[1] = 0x01;
|
||||||
|
signedUnlock[65] = 0x41;
|
||||||
|
// Fill the pubkey: compressed pubkeys start with 0x02/0x03; the LAST byte
|
||||||
|
// (0xaa here) must NOT be checked as a sighash flag.
|
||||||
|
signedUnlock[67] = 0x02;
|
||||||
|
signedUnlock[66 + 33] = 0xaa; // last pubkey byte
|
||||||
|
|
||||||
|
const tx = encodeTransaction({
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash: prevHash,
|
||||||
|
outpointIndex: 0,
|
||||||
|
unlockingBytecode: signedUnlock,
|
||||||
|
sequenceNumber: 0xffffffff,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedHex = binToHex(tx);
|
||||||
|
expect(() =>
|
||||||
|
validateSighashFlags(signedHex, [0], [contractLock], [unsignedTemplate]),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts P2PKH input with allowed sighash 0x41", () => {
|
||||||
|
const prevHash = new Uint8Array(32);
|
||||||
|
const sigPushLen = 0x41;
|
||||||
|
const sigData = new Uint8Array(65);
|
||||||
|
sigData[64] = 0x41; // sighash at end
|
||||||
|
const pubkeyPush = new Uint8Array([0x21, ...new Uint8Array(33)]);
|
||||||
|
const unlocking = new Uint8Array([
|
||||||
|
sigPushLen,
|
||||||
|
...sigData,
|
||||||
|
...pubkeyPush,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tx = encodeTransaction({
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash: prevHash,
|
||||||
|
outpointIndex: 0,
|
||||||
|
unlockingBytecode: unlocking,
|
||||||
|
sequenceNumber: 0xffffffff,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedHex = binToHex(tx);
|
||||||
|
expect(() =>
|
||||||
|
validateSighashFlags(signedHex, [0], [p2pkhLock]),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects disallowed sighash in contract multi-sig slot", () => {
|
||||||
|
const prevHash = new Uint8Array(32);
|
||||||
|
const unsignedTemplate = new Uint8Array([0x41, ...new Uint8Array(65)]);
|
||||||
|
const signedUnlock = new Uint8Array(unsignedTemplate.length);
|
||||||
|
signedUnlock.set(unsignedTemplate);
|
||||||
|
signedUnlock[1] = 0x01; // first data to trigger extract at push start
|
||||||
|
signedUnlock[65] = 0x01; // bad sighash at end
|
||||||
|
|
||||||
|
const tx = encodeTransaction({
|
||||||
|
version: 2,
|
||||||
|
locktime: 0,
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
outpointTransactionHash: prevHash,
|
||||||
|
outpointIndex: 0,
|
||||||
|
unlockingBytecode: signedUnlock,
|
||||||
|
sequenceNumber: 0xffffffff,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ lockingBytecode: new Uint8Array([0x6a]), valueSatoshis: 0n },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const signedHex = binToHex(tx);
|
||||||
|
expect(() =>
|
||||||
|
validateSighashFlags(signedHex, [0], [contractLock], [unsignedTemplate]),
|
||||||
|
).toThrow(/disallowed sighash flag 0x01/);
|
||||||
|
});
|
||||||
|
});
|
||||||
189
packages/wallet/src/sighash-validation.ts
Normal file
189
packages/wallet/src/sighash-validation.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
// Copyright (C) 2026 Whiterun LLC,
|
||||||
|
// This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
|
||||||
|
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
|
||||||
|
|
||||||
|
import { decodeTransaction, hexToBin } from "@bitauth/libauth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Allowed sighash flag combinations for wallet-signed inputs.
|
||||||
|
* - 0x41 = SIGHASH_ALL | SIGHASH_FORKID
|
||||||
|
* - 0x61 = SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS
|
||||||
|
*/
|
||||||
|
const ALLOWED_SIGHASH = new Set([0x41, 0x61]);
|
||||||
|
|
||||||
|
/** P2PKH locking bytecode template: OP_DUP OP_HASH160 OP_PUSH20 <20 bytes> OP_EQUALVERIFY OP_CHECKSIG */
|
||||||
|
const P2PKH_PREFIX = Uint8Array.from([0x76, 0xa9, 0x14]);
|
||||||
|
const P2PKH_SUFFIX = Uint8Array.from([0x88, 0xac]);
|
||||||
|
const P2PKH_LENGTH = 25;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the locking bytecode matches the P2PKH template:
|
||||||
|
* `OP_DUP OP_HASH160 OP_PUSH20 <20-byte hash> OP_EQUALVERIFY OP_CHECKSIG`
|
||||||
|
*/
|
||||||
|
export function isP2PKH(lockingBytecode: Uint8Array): boolean {
|
||||||
|
if (lockingBytecode.length !== P2PKH_LENGTH) return false;
|
||||||
|
for (let i = 0; i < P2PKH_PREFIX.length; i++) {
|
||||||
|
if (lockingBytecode[i] !== P2PKH_PREFIX[i]) return false;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < P2PKH_SUFFIX.length; i++) {
|
||||||
|
if (
|
||||||
|
lockingBytecode[P2PKH_LENGTH - P2PKH_SUFFIX.length + i] !==
|
||||||
|
P2PKH_SUFFIX[i]
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the sighash byte from a P2PKH unlocking bytecode.
|
||||||
|
*
|
||||||
|
* P2PKH format: `<push_len> <DER_sig ++ sighash_byte> <push_len> <pubkey>`
|
||||||
|
*
|
||||||
|
* The sighash byte is the last byte of the first push data. Works for both
|
||||||
|
* DER signatures (70–73 bytes) and Schnorr signatures (65 bytes) since both
|
||||||
|
* append the sighash byte.
|
||||||
|
*/
|
||||||
|
function extractP2PKHSighashByte(
|
||||||
|
unlockingBytecode: Uint8Array,
|
||||||
|
inputIndex: number,
|
||||||
|
): number {
|
||||||
|
if (unlockingBytecode.length < 2) {
|
||||||
|
throw new Error(
|
||||||
|
`Input ${inputIndex}: unlocking bytecode too short to contain a signature`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const pushLen = unlockingBytecode[0];
|
||||||
|
if (pushLen === 0 || pushLen >= 0x4c) {
|
||||||
|
throw new Error(
|
||||||
|
`Input ${inputIndex}: unexpected first push length 0x${pushLen.toString(16).padStart(2, "0")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (unlockingBytecode.length < pushLen + 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Input ${inputIndex}: unlocking bytecode truncated (need ${pushLen + 1} bytes, have ${unlockingBytecode.length})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return unlockingBytecode[pushLen];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find signatures in a contract input by comparing the unsigned template to
|
||||||
|
* the signed result. The template uses a full-length placeholder (e.g. 65
|
||||||
|
* zero bytes) for each signature slot, so both bytecodes have the same length
|
||||||
|
* and push structure. We walk byte-by-byte to find regions that differ; each
|
||||||
|
* differing region is a filled signature whose last byte is the sighash flag.
|
||||||
|
*
|
||||||
|
* Supports multiple signature slots per input (common for contract inputs with
|
||||||
|
* several sig/pubkey placeholders from different keys listed via repeated
|
||||||
|
* inputIndex in inputPaths).
|
||||||
|
*/
|
||||||
|
function extractContractSighashBytes(
|
||||||
|
template: Uint8Array,
|
||||||
|
signed: Uint8Array,
|
||||||
|
inputIndex: number,
|
||||||
|
): number[] {
|
||||||
|
if (template.length !== signed.length) {
|
||||||
|
throw new Error(
|
||||||
|
`Input ${inputIndex}: template length (${template.length}) != signed length (${signed.length})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sighashBytes: number[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < template.length) {
|
||||||
|
if (template[i] !== signed[i]) {
|
||||||
|
// We're inside a filled placeholder push. The push length byte is at
|
||||||
|
// i - 1 (same in both, since only the data content differs).
|
||||||
|
const pushLen = template[i - 1];
|
||||||
|
const sighashPos = i - 1 + pushLen;
|
||||||
|
// Only signature pushes carry a trailing sighash-flag byte: a signed
|
||||||
|
// Schnorr sig is 65 bytes (64 + sighash), DER ECDSA is 71–73. Other
|
||||||
|
// filled placeholders (pubkey = 33, pubkeyhash = 20) have no sighash
|
||||||
|
// flag — skip them so we don't mis-read their last byte as one.
|
||||||
|
const isSignature = pushLen === 65 || (pushLen >= 71 && pushLen <= 73);
|
||||||
|
if (isSignature) {
|
||||||
|
sighashBytes.push(signed[sighashPos]);
|
||||||
|
}
|
||||||
|
// Skip past this push either way.
|
||||||
|
i = sighashPos + 1;
|
||||||
|
} else {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sighashBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSighash(sighash: number, inputIndex: number): void {
|
||||||
|
if (!ALLOWED_SIGHASH.has(sighash)) {
|
||||||
|
throw new Error(
|
||||||
|
`Input ${inputIndex} uses disallowed sighash flag 0x${sighash.toString(16).padStart(2, "0")}. ` +
|
||||||
|
`Only SIGHASH_ALL|FORKID (0x41) and SIGHASH_ALL|FORKID|UTXOS (0x61) are allowed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that all wallet-signed inputs use an allowed sighash type.
|
||||||
|
*
|
||||||
|
* For P2PKH inputs, the signature is extracted from the first push of the
|
||||||
|
* signed unlocking bytecode.
|
||||||
|
*
|
||||||
|
* For contract inputs, the unsigned template (from the sign request) is
|
||||||
|
* compared byte-by-byte against the signed result to locate filled signature
|
||||||
|
* slots and check their sighash bytes. Supports multiple filled sig slots per
|
||||||
|
* input (when dapp sent repeated inputIndex entries in inputPaths for that input).
|
||||||
|
*
|
||||||
|
* @param signedTxHex - hex-encoded signed transaction
|
||||||
|
* @param walletInputIndices - distinct input indices the wallet signed (from inputPaths; dedup if your
|
||||||
|
* extraction produces duplicates, though re-validation is harmless)
|
||||||
|
* @param lockingBytecodes - locking bytecodes for each input (from sourceOutputs)
|
||||||
|
* @param unsignedBytecodes - unsigned unlocking bytecodes for each input (from the
|
||||||
|
* sign request's transaction template). Empty for P2PKH, populated for contracts.
|
||||||
|
* @throws if any wallet-signed input uses a disallowed sighash flag
|
||||||
|
*/
|
||||||
|
export function validateSighashFlags(
|
||||||
|
signedTxHex: string,
|
||||||
|
walletInputIndices: number[],
|
||||||
|
lockingBytecodes: Uint8Array[],
|
||||||
|
unsignedBytecodes?: Uint8Array[],
|
||||||
|
): void {
|
||||||
|
if (walletInputIndices.length === 0) return;
|
||||||
|
|
||||||
|
const txBin = hexToBin(signedTxHex);
|
||||||
|
const decoded = decodeTransaction(txBin);
|
||||||
|
if (typeof decoded === "string") {
|
||||||
|
throw new Error(`Failed to decode signed transaction: ${decoded}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const inputIndex of walletInputIndices) {
|
||||||
|
if (inputIndex >= decoded.inputs.length) {
|
||||||
|
throw new Error(
|
||||||
|
`inputPaths references input ${inputIndex}, but transaction only has ${decoded.inputs.length} inputs`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedBytecode = decoded.inputs[inputIndex].unlockingBytecode;
|
||||||
|
|
||||||
|
if (isP2PKH(lockingBytecodes[inputIndex])) {
|
||||||
|
const sighash = extractP2PKHSighashByte(signedBytecode, inputIndex);
|
||||||
|
checkSighash(sighash, inputIndex);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contract input — compare template to signed result
|
||||||
|
const unsignedBytecode = unsignedBytecodes?.[inputIndex];
|
||||||
|
if (!unsignedBytecode || unsignedBytecode.length === 0) {
|
||||||
|
continue; // No template available, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
const sighashBytes = extractContractSighashBytes(
|
||||||
|
unsignedBytecode,
|
||||||
|
signedBytecode,
|
||||||
|
inputIndex,
|
||||||
|
);
|
||||||
|
for (const sighash of sighashBytes) {
|
||||||
|
checkSighash(sighash, inputIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue