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:
Dagur Valberg Johannsson 2026-06-22 09:21:24 +02:00
parent c2b13102b9
commit c8c1000ae7
11 changed files with 1012 additions and 18 deletions

View file

@ -164,7 +164,10 @@ const response = await dappMgr.signTransaction({
userPrompt: "Confirm swap",
broadcast: true,
},
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex]
inputPaths: [[0, "receive", 0], [1, "defi", 5]], // [inputIndex, pathName, addressIndex, slot?]
// 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);
@ -181,7 +184,7 @@ cancelButton.onclick = () => controller.abort("User cancelled");
try {
const response = await dappMgr.signTransaction(
{ transaction: { ... }, inputPaths: [...] },
{ transaction: { ... }, inputPaths: [[0, "receive", 0]] },
{ signal: controller.signal },
);
} catch (err) {
@ -202,7 +205,7 @@ const request: SignTransactionRequest = {
sequence: seq,
time: Math.floor(Date.now() / 1000),
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);

View file

@ -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
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)
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) |
| `rpa_bip47` | `rpa_spend`, `rpa_scan` | `m/47'/145'/0'/0'`, `m/47'/145'/0'/1'` | BIP47 reusable payment addresses. Wallet exports xpubs at hardened gates; dapp derives `/0` child locally. | Standard — [BCR post](https://bitcoincashresearch.org/t/ecdh-stealth-addresses-on-bitcoin-cash-implementation-code/1773/5) |
| `decrypt` | — | — | Dapp-side encrypted storage. Wallet provides a public key; dapp encrypts data for storage and sends `decrypt_request` messages when the data is needed. | Proposed |
| `multislot` | — | — | 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.
### 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.

View file

@ -268,22 +268,108 @@ to match responses to requests.
(for signing), version, locktime, and an optional `userPrompt` string shown to the user in the
wallet UI.
`inputPaths` is a sparse array of `[inputIndex, PathName, addressIndex]` tuples. Each entry identifies
the HD derivation path name and address index the dapp used to derive the locking script for the input
at position `inputIndex`. Only inputs that require wallet signing need an entry — contract inputs with
pre-set unlocking bytecode can be omitted. This allows the wallet to sign each input without scanning
or guessing which key was used.
`inputPaths` is a list of positional tuples. Each tuple has 3 or 4 elements:
| index | field | type | meaning |
|-------|-------|------|---------|
| `[0]` | `inputIndex` | integer | which transaction input this entry is for |
| `[1]` | `PathName` | string | named derivation path (`"receive"`, `"change"`, `"defi"`, …) |
| `[2]` | `addressIndex` | integer | address index within that path |
| `[3]` | `slot` | integer, optional | placeholder slot within the input (default `0`) — see below |
Each entry names one HD key (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)
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS` and **MUST**
reject any request that would require different flags.
Wallets **MUST** sign every input with `SIGHASH_ALL | SIGHASH_FORKID` and **SHOULD** additionally set
`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).
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.
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
`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

View file

@ -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_ALL | SIGHASH_FORKID | SIGHASH_UTXOS`. See the
[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
invalid and cannot be repurposed).
@ -202,6 +202,13 @@ class MyAdapter implements WalletAdapter {
async signTransaction(request) {
// Show approval UI, sign with SIGHASH_ALL | SIGHASH_FORKID | SIGHASH_UTXOS, return hex.
// 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: "..." };
}
}
@ -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).

View file

@ -25,6 +25,45 @@ describe("isSignTransactionRequest", () => {
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", () => {
const noInputPaths = { ...valid } as Record<string, unknown>;
delete noInputPaths.inputPaths;

View file

@ -48,6 +48,13 @@ export const PATH_DEFI = "defi" as const;
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 {
name: PathName;
xpub: string; // BIP32 base58 xpub (wallet chooses the derivation path internally)
@ -151,7 +158,29 @@ export interface SignTransactionRequest extends ProtocolMessage {
action: RelayMsgAction.SignTransactionRequest;
transaction: WcSignTransactionRequest;
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
/// when present rather than parsing the raw transaction themselves.
txSummary?: TxSummary;
@ -215,10 +244,13 @@ export function isSignTransactionRequest(
msg.inputPaths.every(
(p: any) =>
Array.isArray(p) &&
p.length === 3 &&
(p.length === 3 || p.length === 4) &&
typeof p[0] === "number" &&
typeof p[1] === "string" &&
typeof p[2] === "number",
typeof p[2] === "number" &&
// optional slot: non-negative integer
(p.length === 3 ||
(typeof p[3] === "number" && Number.isInteger(p[3]) && p[3] >= 0)),
)
);
}

View file

@ -14,3 +14,4 @@ export type {
PendingSignRequest,
WalletConnectionManagerEvents,
} from "./wallet-connection-manager.js";
export { isP2PKH, validateSighashFlags } from "./sighash-validation.js";

View file

@ -137,6 +137,37 @@ describe("WalletConnectionManager — sign_transaction_request with inputPaths",
]);
}, 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 () => {
const signMsg: SignTransactionRequest = {
action: RelayMsgAction.SignTransactionRequest,
@ -151,11 +182,11 @@ describe("WalletConnectionManager — sign_transaction_request with inputPaths",
};
await dappClient!.relay(signMsg);
await waitFor(() => pendingRequests.length >= 2, {
await waitFor(() => pendingRequests.length >= 3, {
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);
});

View 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);
});
});

View 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/);
});
});

View 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 (7073 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 7173. 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);
}
}
}