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>
256 lines
10 KiB
Markdown
256 lines
10 KiB
Markdown
# Extensions
|
|
|
|
This document covers `hdwalletv1` protocol-level extensions — optional capabilities that extend
|
|
the application protocol (extra path names, custom message actions, per-wallet features). For
|
|
transport-level capabilities that apply below the application protocol regardless of which
|
|
protocol is in use (chunking, future: compression), see
|
|
[transport.md § Transport-level extensions](transport.md#transport-level-extensions).
|
|
|
|
The hdwalletv1 protocol supports optional extensions that let wallets and dapps negotiate
|
|
additional capabilities beyond the core sign-transaction flow. Extensions are backward-compatible:
|
|
existing wallets and dapps that don't know about extensions continue to work unchanged.
|
|
|
|
## How extensions work
|
|
|
|
Extensions use three mechanisms, all of which are optional and additive:
|
|
|
|
### 1. Session extensions field
|
|
|
|
Wallets advertise supported extensions in the `wallet_ready` handshake via an `extensions` field
|
|
on the `Hdwalletv1Session` object:
|
|
|
|
```typescript
|
|
interface Hdwalletv1Session {
|
|
paths: PathXpub[];
|
|
extensions?: Record<string, unknown>;
|
|
}
|
|
```
|
|
|
|
Each key in `extensions` is an extension name. Its **presence** indicates the wallet supports that
|
|
extension. The value carries extension-specific handshake data (derivation paths to the hardened
|
|
gate where the wallet exports xpubs), or `{}` if no data is needed.
|
|
|
|
Example:
|
|
```json
|
|
{
|
|
"paths": [
|
|
{ "name": "receive", "xpub": "xpub6..." },
|
|
{ "name": "change", "xpub": "xpub6..." },
|
|
{ "name": "defi", "xpub": "xpub6..." },
|
|
{ "name": "stealth_spend", "xpub": "xpub6..." },
|
|
{ "name": "stealth_scan", "xpub": "xpub6..." },
|
|
{ "name": "rpa_spend", "xpub": "xpub6..." },
|
|
{ "name": "rpa_scan", "xpub": "xpub6..." }
|
|
],
|
|
"extensions": {
|
|
"bch_stealth_bip352": {
|
|
"spend_path": "m/352'/145'/0'/0'",
|
|
"scan_path": "m/352'/145'/0'/1'"
|
|
},
|
|
"rpa_bip47": {
|
|
"spend_path": "m/47'/145'/0'/0'",
|
|
"scan_path": "m/47'/145'/0'/1'"
|
|
},
|
|
"decrypt": { "public_key": "02abc...", "scheme": "ecies" }
|
|
}
|
|
}
|
|
```
|
|
|
|
### 2. Additional path names
|
|
|
|
`PathName` is an open string type. Wallets may include paths beyond the well-known
|
|
`receive`/`change`/`defi` set. Extension-defined paths are carried in the standard `paths` array:
|
|
|
|
```json
|
|
{
|
|
"paths": [
|
|
{ "name": "receive", "xpub": "xpub6..." },
|
|
{ "name": "change", "xpub": "xpub6..." },
|
|
{ "name": "stealth_spend", "xpub": "xpub6..." },
|
|
{ "name": "stealth_scan", "xpub": "xpub6..." },
|
|
{ "name": "rpa_spend", "xpub": "xpub6..." },
|
|
{ "name": "rpa_scan", "xpub": "xpub6..." }
|
|
],
|
|
"extensions": {
|
|
"bch_stealth_bip352": {
|
|
"spend_path": "m/352'/145'/0'/0'",
|
|
"scan_path": "m/352'/145'/0'/1'"
|
|
},
|
|
"rpa_bip47": {
|
|
"spend_path": "m/47'/145'/0'/0'",
|
|
"scan_path": "m/47'/145'/0'/1'"
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
Dapps should ignore path names they do not recognize. The standard pubkey derivation logic
|
|
(`DappPubkeyStateManager`) automatically skips unknown paths.
|
|
|
|
### 3. Custom message actions
|
|
|
|
Extensions may define new message action strings beyond the well-known set. Custom messages follow
|
|
the standard `ProtocolMessage` shape (`action` + `time`) and use the existing relay transport.
|
|
|
|
Convention for request/response operations:
|
|
|
|
```typescript
|
|
// Request (dapp → wallet):
|
|
{ action: "<operation>_request", sequence: number, time: number, ...params }
|
|
|
|
// Response (wallet → dapp):
|
|
{ action: "<operation>_response", sequence: number, time: number, ...result }
|
|
```
|
|
|
|
The `sequence` field ties responses to requests, matching the pattern used by
|
|
`sign_transaction_request`/`sign_transaction_response`.
|
|
|
|
**Wallet side**: custom messages are emitted via the `"message"` event on
|
|
`WalletConnectionManager`:
|
|
|
|
```typescript
|
|
manager.on("message", (connectionId, msg) => {
|
|
if (msg.action === "decrypt_request") {
|
|
// handle decrypt request
|
|
}
|
|
});
|
|
```
|
|
|
|
**Dapp side**: all messages (including custom ones) are emitted via the `"messagereceived"` event
|
|
on `DappConnectionManager`:
|
|
|
|
```typescript
|
|
manager.on("messagereceived", (msg) => {
|
|
if (msg.action === "decrypt_response") {
|
|
// handle decrypt response
|
|
}
|
|
});
|
|
```
|
|
|
|
## Implementing an extension (wallet side)
|
|
|
|
Wallets advertise extensions by implementing optional methods on `WalletAdapter`:
|
|
|
|
```typescript
|
|
interface WalletAdapter {
|
|
// ... core methods ...
|
|
|
|
/** Additional paths to include in the session (e.g. stealth_scan). */
|
|
getAdditionalPaths?(): PathXpub[];
|
|
|
|
/** Extension data for the session handshake. */
|
|
getExtensions?(): Record<string, unknown>;
|
|
}
|
|
```
|
|
|
|
Example:
|
|
```typescript
|
|
const adapter: WalletAdapter = {
|
|
// ... core implementation ...
|
|
|
|
getAdditionalPaths() {
|
|
return [
|
|
{ name: "stealth_spend", xpub: deriveXpub("m/352'/145'/0'/0'") },
|
|
{ name: "stealth_scan", xpub: deriveXpub("m/352'/145'/0'/1'") },
|
|
{ name: "rpa_spend", xpub: deriveXpub("m/47'/145'/0'/0'") },
|
|
{ name: "rpa_scan", xpub: deriveXpub("m/47'/145'/0'/1'") },
|
|
];
|
|
},
|
|
|
|
getExtensions() {
|
|
return {
|
|
"bch_stealth_bip352": {
|
|
"spend_path": "m/352'/145'/0'/0'",
|
|
"scan_path": "m/352'/145'/0'/1'",
|
|
},
|
|
"rpa_bip47": {
|
|
"spend_path": "m/47'/145'/0'/0'",
|
|
"scan_path": "m/47'/145'/0'/1'",
|
|
},
|
|
};
|
|
},
|
|
};
|
|
```
|
|
|
|
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`:
|
|
|
|
```typescript
|
|
manager.on("walletready", (msg) => {
|
|
const session = msg.session["hdwalletv1"] as Hdwalletv1Session;
|
|
|
|
if (session.extensions?.bch_stealth_bip352) {
|
|
const scanPath = session.paths.find(p => p.name === "stealth_scan");
|
|
const spendPath = session.paths.find(p => p.name === "stealth_spend");
|
|
// enable stealth address features
|
|
} else {
|
|
// show: "Stealth payments require a wallet that supports BCH Stealth (BIP352)"
|
|
}
|
|
|
|
if (session.extensions?.rpa_bip47) {
|
|
const rpaSpend = session.paths.find(p => p.name === "rpa_spend");
|
|
const rpaScan = session.paths.find(p => p.name === "rpa_scan");
|
|
// enable RPA features
|
|
}
|
|
});
|
|
```
|
|
|
|
Dapps should degrade gracefully when an extension is absent. If a feature requires an extension the
|
|
wallet doesn't support, inform the user rather than failing silently.
|
|
|
|
## Known extensions
|
|
|
|
| Extension name | Path names | Hardened gate paths | Purpose | Status |
|
|
|---------------|-----------|---------------------|---------|--------|
|
|
| `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.
|