Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
Håvard Kittelsen
b9a0e16893 feat(core): login challenge helpers, so replay protection is not optional
The sign_message extension leaves replay to the dapp, because a signature proves
key control over an exact string and carries no freshness or audience. The
previous commit said so in three places in the docs. That is exactly the failure
this repository should not ship: a login built on a bare signMessage call works in
every manual test and is a password that never expires, so documenting the
requirement mostly relocates the blame.

So the two failures that matter are structural here rather than advisory:

  verifyLoginChallenge cannot be called without `domain` and `consumeNonce`. There
  is no overload that omits them. Verifying a login without single-use enforcement
  and audience binding is not something this API can express — if you want plain
  signature verification, verifyMessageSignatureForAddress is right there and is
  honestly named.

  createLoginChallenge refuses a nonce under MIN_NONCE_LENGTH and refuses a line
  break in any field, so neither a guessable nonce nor an injected `Nonce:` line
  can reach a signed message.

Check order is deliberate: parse, domain, expiry, signature, THEN consume the
nonce. Consuming earlier would let anyone who sniffs a nonce burn it with a
garbage signature before the real user finishes signing; there is a test asserting
the nonce survives a bad signature and the genuine login still completes.

consumeNonce is a caller-supplied callback rather than a store this module owns,
because single-use enforcement is a property of the caller's database — two
replays arriving together both reach that point and only one may be told true. The
docstring says it must be atomic. createInMemoryNonceStore exists for development
and says plainly that it is per-process, so two servers behind a load balancer
would each honour the same signature once.

parseLoginChallenge is strict: unknown fields, duplicate fields, out-of-order
fields and stray lines are rejected rather than skipped, so exactly one byte
sequence parses to a given challenge. A lenient parser is where field injection
lives.

Address is optional in the message because under wallet_choice the dapp does not
yet know which key will answer. When present the proof is self-describing — a
third party reading the message alone sees which address was claimed — and
verification then requires the recovered address to match it.

NOT SIWE. The layout is deliberately similar to Sign-In With Ethereum so it reads
familiarly, but it does not claim EIP-4361 or CAIP-122 compatibility: there is no
agreed SIWX profile for Bitcoin Cash to conform to. If one lands it belongs beside
this as a second format, not as a silent change to this one.

Also adds addressesEqual() to message-signing, which compares decoded public key
hashes so prefixed CashAddr, bare CashAddr, the token-aware form and legacy base58
all compare equal for the same key.

27 tests, mostly about what must be refused: the replay, the wrong site, the stale
and future-dated challenge, four field-injection attempts, the nonce-burning
attack, the wrong key, and a cross-encoding address match.

test-cli now builds its challenge with these helpers rather than hand-rolled text,
since that is what integrators copy, and verifies the response twice — once as a
third party would and once as the server would, printing proof that replaying the
identical signature is rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:33:11 +02:00
Håvard Kittelsen
b40da7230a feat: add the sign_message hdwalletv1 extension
Wires the Bitcoin Signed Message primitives into the protocol: a dapp can ask a
wallet to prove control of a key, and gets back a signature any third party can
check from the message and an address alone.

WIRE FORMAT

SignMessageRequest extends WcSignMessageRequest from @bch-wc2/interfaces — the
interface wallets already implement for WalletConnect — so the request object can
be handed straight to an existing WC2 signMessage handler. hdwalletv1 adds only
the optional key selection, mirroring how SignTransactionRequest wraps
WcSignTransactionRequest and adds inputPaths. That also brings `userPrompt` along,
which is dapp-supplied and unsigned; docs/wallet.md says to render it as
subordinate to the message, because presented as equal it lets a dapp caption
hostile text reassuringly.

Two key-selection modes, advertised separately from `schemes` because they are
independent capabilities — a wallet may sign with a dapp-named path yet have no
notion of a stable identity key, and a dapp that checked only for the extension
would find that out after the user clicked a login button:

  dapp_path      dapp sends path + addressIndex; needs that path's xpub
  wallet_choice  dapp sends neither; wallet picks and returns the address

wallet_choice exists because requiring an xpub to prove control of one key means
sharing the user's whole address history. It is the privacy-preserving option for
identity, and the one the WC2 interface already implies. A wallet advertising it
must choose deterministically or a returning user is unrecognisable.

The response is a discriminated union on `error`, so a caller cannot read
`.address` off a rejection and treat an empty string as an identity. publicKey and
address are required on success: under wallet_choice they are the dapp's only way
to learn which key answered.

WALLET SIDE

signMessage is optional; implementing it is what advertises the extension, so the
handshake cannot claim support an adapter does not have. An adapter that declares
the key itself wins — the automatic advertisement never overwrites it.

SignMessageResult carries only the signature. The public key and address are
recoverable from it and the manager derives them that way, so the three values
cannot disagree and an adapter cannot claim a proof about an address it did not
prove. The manager then compares the recovered key against the adapter's own key
for the path. Recovery alone cannot catch a signature over the wrong text — it
succeeds and yields some other key — so that comparison is what turns a wallet-side
derivation or encoding bug into an error at the call site rather than an opaque
rejection across the relay.

Requests are answered rather than dropped: an unsupported scheme, an unsupported
mode, a malformed request or a wallet with no signMessage all produce an error
response, checked before the user is prompted so nobody approves a signature we
cannot produce.

Dedup shares the sequence set with transaction signing. That is correct rather
than convenient: every sequence comes from one per-connection counter
(RelayClient.nextSequence), so a sequence identifies a request regardless of kind
— which is also what lets one sign_cancel cancel either.

DAPP SIDE

signMessage() resolves only after this library has verified the result: the
signature recovers over the message that was sent, publicKey is the key that
signed, address is that key's address, and — when the dapp named a path it can
derive — the signer is exactly the key it asked for. Anything inconsistent
rejects. Without that last check a wallet could answer with a signature from any
key and a naive dapp would accept it as the identity it asked about.

keyBinding reports whether that comparison happened, because "the wallet chose a
key" and "this is the key I asked for" are different claims and only one is an
identity the dapp selected. A derivable path with no xpub available is an error,
not an unchecked result.

No default timeout: cancellation is explicit via AbortSignal, matching
signTransaction. Picking a deadline for a user approving on a phone is worse than
letting the dapp decide.

EXTENSION SHAPE

Actions live in RelayMsgAction and are handled by the managers, rather than riding
the generic message events described in docs/extensions.md § 3. That is a new
pattern, not an existing convention — the only prior enum-plus-advertisement
capability is `chunk`, which is transport-level and outside the hdwalletv1
extension system entirely. It is documented as new under § First-party
extensions: third-party extensions define their own actions and are handled by the
host app; capabilities this library ships get manager support, because otherwise
every consumer hand-rolls the plumbing for a feature we already implement.

TESTS

24 wallet, 26 dapp, and 8 over a live relay. The integration test matters most:
NIP-17 gift wrapping, JSON encoding, relay storage and replay all sit between the
two sides, and it asserts the message arrives byte-identical, that a multi-byte
message is not re-encoded in transit, that a replayed request prompts once, and
that the resulting signature verifies from the address alone. makeTestAdapter
gained a real signMessage — it already holds HD keys, so there was nothing to
fake.

test-cli gains `--sign-message [dapp_path|wallet_choice]` and a wallet-side
approval path, so the flow can be driven by hand against a real wallet. It signs
a plain test message, not a login: a login needs a single-use nonce, a domain and
an expiry, and signing something that merely looks like one would be a bad
pattern to copy.

Docs: protocol.md, extensions.md, wallet.md, dapp.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:32:02 +02:00
Håvard Kittelsen
763754b30e feat(core): Bitcoin Signed Message primitives, checked against Electron Cash
Dapps have been asking for message signing to prove key control — identity
verification, SIWX-style login, and publishing a signed statement on chain. This
adds the construction and the verification; the protocol wiring follows
separately.

The known workaround is a dummy transaction: one input with a null outpoint, its
prevout script set to the P2PKH of the key being proven, one OP_RETURN output
carrying a server nonce, signed but never broadcast. It covers login, but not the
rest. A signature made that way is bound to a transaction, so verifying it means
rebuilding that exact transaction and knowing how it was serialised — it cannot
be published in an OP_RETURN and checked later by a third party holding only the
message, the signature and an address. It also asks a wallet to sign a real
transaction preimage, which is one bug away from signing a genuine spend.

So this produces the portable form: the standard "Bitcoin Signed Message"
construction. BCH wallets kept Bitcoin's magic string verbatim, so a signature
made here verifies in Electron Cash, Electrum and `bitcoin-cli verifymessage`.
The magic prefix also guarantees the digest can never coincide with a transaction
sighash, which makes signing a message categorically safer to approve than
signing a dummy transaction.

Address-level API, because that is what verification actually looks like
elsewhere: Electron Cash exposes only `verify_message(address, sig, message)`,
and a third party pulling a proof off an explorer has an address, not a public
key. verifyMessageSignatureForAddress accepts CashAddr with or without a prefix
and legacy base58, and rejects P2SH — no message signature can prove control of a
script hash.

recoverMessageSigner returns { publicKey, compressed } rather than a bare key.
The header byte declares which serialisation was used, and a key's compressed and
uncompressed forms hash to DIFFERENT addresses. Dropping that bit is how a
signature proving control of one address gets accepted as proof of another;
message-signing.test.ts pins the case in both directions.

signBitcoinMessage takes the private key as an argument and never retains it. It
exists so a wallet calls one function instead of reassembling the magic string,
both compactSize prefixes and the header byte — the parts third-party verifiers
check, and the parts covered by the tests here.

Testing: the byte layout is not asserted against our own reimplementation of the
spec, because that catches a coding mistake but not a misreading of it.

  message-signing.vectors.json holds 32 vectors generated by a real Electron Cash
  4.4.5 install (contrib/generate-message-signing-vectors.py) — two keys, both
  compression forms, eight messages including empty, multi-byte UTF-8, multi-line
  and the 252/253-byte compactSize boundary. Every preimage hash must match byte
  for byte, and every signature must verify. Signature bytes are NOT portable
  across implementations — Electron Cash and libauth derive the ECDSA nonce
  differently — so the reproducible quantity is the hash.

  message-signing.compat.test.ts drives `electron-cash verifymessage` directly,
  closing the loop that vectors cannot: that our OUTPUT is accepted. Not part of
  `npm test` (each assertion spawns a full Electron Cash process); run
  `npm run test:compat -w @wizardconnect/core`. Skips when Electron Cash is
  absent, so CI is unaffected.

77 tests, 426 in core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:28:41 +02:00
29 changed files with 6112 additions and 14 deletions

View file

@ -0,0 +1,103 @@
#!/usr/bin/env python3
# Copyright (C) 2026 Whiterun LLC,
# This software is licensed under the GNU Lesser General Public License (LGPL), version 3.0 or later.
#
# Regenerates packages/core/src/protocols/message-signing.vectors.json.
#
# The `sign_message` extension exists to produce signatures that OTHER software
# accepts. Asserting our own construction against our own reimplementation of it
# proves nothing, so the vectors these tests run against are generated by a real
# Electron Cash install — its msg_magic, its signer, its address encoder.
#
# Usage:
# contrib/generate-message-signing-vectors.py [EC_SITE_PACKAGES] > \
# packages/core/src/protocols/message-signing.vectors.json
#
# EC_SITE_PACKAGES defaults to the AppImage-style layout. Electron Cash bundles
# its own interpreter, so run this with that interpreter, not the system one:
#
# APPDIR=/opt/electron-cash \
# LD_LIBRARY_PATH=/opt/electron-cash/usr/lib/:/opt/electron-cash/usr/lib/x86_64-linux-gnu \
# /opt/electron-cash/usr/bin/python3.11 -s contrib/generate-message-signing-vectors.py
import binascii
import json
import sys
import base64
DEFAULT_SITE_PACKAGES = "/opt/electron-cash/usr/lib/python3.11/site-packages"
site_packages = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_SITE_PACKAGES
sys.path.insert(0, site_packages)
from electroncash.bitcoin import regenerate_key, msg_magic, Hash # noqa: E402
from electroncash.address import Address # noqa: E402
from electroncash.version import PACKAGE_VERSION # noqa: E402
# Fixed test keys. Key 1 is the secp256k1 generator's scalar — convenient and
# stable; the second is arbitrary, to catch anything that only works for key 1.
KEYS = {
"one": "00" * 31 + "01",
"arbitrary": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
}
MESSAGES = [
("simple", "hello"),
("login", "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z"),
("empty", ""),
("multibyte", "Straße 日本語 \U0001f345"),
("multiline", "line one\nline two\n"),
("whitespace", " padded "),
# The compactSize length prefix widens from one byte to three at 253.
("compactsize_252", "a" * 252),
("compactsize_253", "a" * 253),
]
vectors = []
for key_name, key_hex in KEYS.items():
key = regenerate_key(binascii.unhexlify(key_hex))
for compressed in (True, False):
pubkey = key.get_public_key(compressed)
address = Address.from_pubkey(pubkey)
for message_name, message in MESSAGES:
signature = key.sign_message(message, compressed)
vectors.append(
{
"name": f"{key_name}/{message_name}/{'compressed' if compressed else 'uncompressed'}",
"privateKey": key_hex,
"message": message,
"compressed": compressed,
"publicKey": pubkey if isinstance(pubkey, str) else binascii.hexlify(pubkey).decode(),
"signature": base64.b64encode(signature).decode(),
"cashaddr": address.to_full_string(Address.FMT_CASHADDR),
"legacy": address.to_full_string(Address.FMT_LEGACY),
"preimageHash": binascii.hexlify(
Hash(msg_magic(message.encode("utf8")))
).decode(),
"header": signature[0],
}
)
print(
json.dumps(
{
"_provenance": {
"generatedBy": "Electron Cash",
"electronCashVersion": PACKAGE_VERSION,
"regenerate": "contrib/generate-message-signing-vectors.py",
"note": (
"Signatures, addresses and preimage hashes here are produced by "
"Electron Cash, not by this repository. They are the external "
"reference the sign_message extension must match. Signature bytes "
"are NOT portable across implementations (Electron Cash and libauth "
"derive the ECDSA nonce differently), so a conforming implementation "
"must reproduce preimageHash exactly and must VERIFY these "
"signatures — it will not reproduce them byte for byte."
),
},
"vectors": vectors,
},
indent=2,
ensure_ascii=False,
)
)

View file

@ -38,9 +38,24 @@ class DappConnectionManager extends EventEmitter {
/** Low-level: send a fully constructed sign request. */
sendSignRequest(request: SignTransactionRequest): Promise<SignTransactionResponse>
/** Cancel an in-flight sign request by sequence number. */
/** Cancel an in-flight sign request by sequence number. Works for both
* sign_transaction_request and sign_message_request. */
sendSignCancel(sequence: number, reason?: string): Promise<void>
/** Ask the wallet to sign a plain message, proving key control. Resolves only
* after the result is verified. See "signMessage" below. */
signMessage(
request: { message: string; path?: PathName; addressIndex?: number;
userPrompt?: string; scheme?: MessageSignatureScheme },
options?: { signal?: AbortSignal },
): Promise<VerifiedMessageSignature>
/** Whether the wallet advertised sign_message for this mode and scheme. */
walletSupportsSignMessage(mode?: MessageSigningMode, scheme?: MessageSignatureScheme): boolean
/** Low-level: send a sign_message request and get the raw, UNVERIFIED response. */
sendSignMessageRequest(request: SignMessageRequest): Promise<SignMessageSuccess>
/** Get the next sequence number (for manual request construction). */
nextSequence(): number
@ -327,6 +342,184 @@ dapp before the wallet app is open — the relay's time filter would otherwise d
original request. Dapps do not need to handle this manually; `sendSignRequest` promises remain
valid across reconnects.
## signMessage
Proves the user controls a key, without a transaction. The signature is a standard "Bitcoin Signed
Message" signature, so anyone can verify it from the message, the signature and the address alone —
including from an OP_RETURN via a block explorer, or by pasting it into Electron Cash's Verify
Message.
```typescript
import { MODE_WALLET_CHOICE } from "@wizardconnect/core";
// 1. Check support before offering the feature.
if (!manager.walletSupportsSignMessage(MODE_WALLET_CHOICE)) {
return; // hide the login button rather than offer one that fails
}
// 2. Get a single-use nonce from your server. See "Replay" below — this is not optional.
const nonce = await fetch("/auth/nonce").then((r) => r.text());
// 3. Ask the wallet. Omitting path/addressIndex lets the wallet pick the key,
// so no xpub is needed.
const result = await manager.signMessage({
message: `${location.host} wants you to sign in.\nnonce=${nonce}`,
userPrompt: "Sign in",
});
// 4. result is already verified. Send it to your server, which retires the nonce.
await fetch("/auth/verify", { method: "POST", body: JSON.stringify(result) });
```
### What is verified before it resolves
`signMessage()` never hands back what the wallet said unchecked. It rejects unless:
1. the signature recovers over the message that was actually sent;
2. `publicKey` is the key that signed it;
3. `address` is that key's address;
4. and, when the dapp named a path it can derive, the signer is **exactly** the key it asked for.
Without (4) a wallet could answer with a signature from any key it liked and a naive dapp would
accept it as the identity it asked about. It is the library's job, not each integrator's.
### keyBinding
```typescript
type MessageKeyBinding =
| { checked: true; path: PathName; addressIndex: number }
| { checked: false; reason: "wallet_chose_key" | "path_not_derivable" };
```
`checked: false` does **not** mean unverified — the signature always is. It means there was no
dapp-chosen key to compare against, so the proven address is the wallet's choice rather than the
dapp's selection:
- `wallet_chose_key` — the dapp omitted `path`/`addressIndex`. Normal for identity flows; the address
in the result *is* the identity.
- `path_not_derivable` — the dapp named an extension path (`stealth_scan`, say) that it has no
derivation rule for.
A dapp storing an identity should care about the difference. Naming a derivable path with no xpub
available is an error, not an unchecked result — call `signMessage` after `wallet_ready`.
### Replay — use the login challenge helpers
A signature proves key control over that exact text. It has **no freshness and no audience**: it is
valid forever, to everyone, and a captured one is replayable indefinitely. A login built on a bare
`signMessage` call works perfectly in every manual test and is a password that never expires.
So don't hand-roll the message. `@wizardconnect/core` provides a format that closes the three holes
that matter — single-use nonce, domain binding, expiry — and an API shaped so you cannot skip them.
**Server, issuing a challenge:**
```typescript
import { createLoginNonce } from "@wizardconnect/core";
const nonce = createLoginNonce(); // 16 random bytes, hex
await db.nonces.insert({ nonce, expiresAt: Date.now() + 300_000 });
return { nonce };
```
**Dapp, asking for the signature:**
```typescript
import { createLoginChallenge } from "@wizardconnect/core";
const message = createLoginChallenge({
domain: location.host,
nonce, // from the server — never generated here
expiresInSeconds: 300,
statement: "Sign in to view your positions.",
// address: known only in dapp_path mode; include it when you have it
});
const result = await manager.signMessage({ message, userPrompt: "Sign in" });
```
**Server, verifying:**
```typescript
import { verifyLoginChallenge } from "@wizardconnect/core";
const verification = await verifyLoginChallenge(result.message, result.signature, {
domain: "app.example.com",
// MUST be atomic — two replays arrive together and only one may be told true.
consumeNonce: (nonce) => db.nonces.deleteAndReport(nonce),
});
if (!verification.ok) return unauthorized(verification.reason);
session.user = verification.address; // only reachable through the ok branch
```
`domain` and `consumeNonce` are **required parameters**. There is no overload without them, because
verifying a login without single-use enforcement and audience binding is not a thing this API can
express. `createLoginChallenge` likewise refuses a nonce under 16 characters, and refuses a line
break in any field so no value can inject its own `Nonce:` line.
Checks run in a deliberate order — parse, domain, expiry, signature, **then** consume the nonce. A
bad signature therefore cannot burn a nonce the real user is still using.
The message format is deliberately similar to Sign-In With Ethereum, but it is **not** SIWE or
CAIP-122 and does not claim compatibility; there is no agreed SIWX profile for Bitcoin Cash. It is
plain text, so any wallet that can sign a message can sign it:
```
app.example.com wants you to sign in.
Address: bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h
Nonce: 8f3a21c0d4b57e69
Issued At: 2026-08-06T12:00:00Z
Expires At: 2026-08-06T12:05:00Z
Statement: Sign in to view your positions.
```
`Address` is omitted under `wallet_choice`, where the dapp does not yet know which key will answer —
pass the address from the result to `verifyLoginChallenge` instead. When the line *is* present, the
proof is self-describing (a third party reading the message alone sees which address was claimed) and
verification requires the recovered address to match it.
`createInMemoryNonceStore()` exists for development. It is per-process, so two servers behind a load
balancer will each honour the same signature once — use your database in production.
If you need a different message format, build it yourself and verify with
`verifyMessageSignatureForAddress` — but then the nonce, the domain and the expiry are all yours to
get right.
### Cancellation
Pass an `AbortSignal`, exactly as with `signTransaction`. There is no default timeout: a user
approving on a phone may take a while, and picking an arbitrary deadline for them is worse than
letting the dapp decide.
```typescript
const controller = new AbortController();
const promise = manager.signMessage({ message }, { signal: controller.signal });
// user closes the dialog:
controller.abort("User cancelled");
```
### Verifying elsewhere
`@wizardconnect/core` exports the verification helpers, so a server (or any third party) can check a
signature without a session:
```typescript
import {
verifyMessageSignatureForAddress,
messageSignatureAddress,
} from "@wizardconnect/core";
verifyMessageSignatureForAddress(message, signature, address); // the Electron Cash-equivalent check
messageSignatureAddress(message, signature); // recover the address instead
```
Accepts CashAddr with or without a prefix, and legacy base58. Rejects P2SH — no message signature can
prove control of a script hash.
---
## Using initiateDappRelay without DappConnectionManager
If you need lower-level control (e.g., in the test-cli), you can work directly with the

View file

@ -209,5 +209,111 @@ 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 |
| `sign_message` | — | — | Sign a plain message to prove key control, without a transaction. Portable "Bitcoin Signed Message" signature — verifiable in Electron Cash, Electrum and `bitcoin-cli verifymessage`. | Implemented — see below |
See the discussions and specifications for each extension as they are formalized.
---
## First-party extensions
`sign_message` does not follow § 3 above. Its actions live in the `RelayMsgAction` enum and are
handled by `WalletConnectionManager` and `DappConnectionManager` directly, rather than riding the
generic `message` / `messagereceived` events.
That is a **new pattern**, not an existing convention — the only prior enum-plus-advertisement
capability is `chunk`, which is transport-level and so lives outside the hdwalletv1 extension system
entirely. The distinction is about who implements the extension:
- **Third-party extensions** define their own action strings and are handled by the host app through
the generic message events (§ 3). This library never needs to know they exist.
- **First-party extensions** ship in this library with manager support, verification helpers and
tests. Routing those through the generic events would force every consumer to hand-roll the
request/response plumbing for a capability the library already implements.
Support is still advertised through the same `session.extensions` mechanism (§ 1), so discovery is
uniform and a wallet that does not implement the capability is unaffected.
---
## `sign_message`
Advertised under the `sign_message` key:
```json
{
"extensions": {
"sign_message": {
"schemes": ["bitcoin_signed_message"],
"modes": ["dapp_path", "wallet_choice"]
}
}
}
```
`schemes` and `modes` are advertised separately because they are independent capabilities. A wallet
may be able to sign with a dapp-named path but have no notion of a stable identity key, and a dapp
that checked only for the extension's presence would discover that at request time — after the user
had already clicked a login button.
| Mode | Meaning |
|------|---------|
| `dapp_path` | The dapp sends `path` + `addressIndex`. Requires the dapp to hold that path's xpub. |
| `wallet_choice` | The dapp sends neither; the wallet picks the key and returns its address. No xpub needed. |
An older wallet that advertised `"sign_message": {}` before these fields existed is read as
`{schemes: ["bitcoin_signed_message"], modes: ["dapp_path"]}` — the original behaviour.
### Wallet side
Implement `WalletAdapter.signMessage` — that alone is what advertises the extension, so the handshake
cannot claim support an adapter does not have. Declare `signMessageModes()` if the wallet supports
`wallet_choice`. An adapter that returns its own `sign_message` entry from `getExtensions()` wins;
the automatic advertisement never overwrites an explicit one.
```typescript
import { signBitcoinMessage, MODE_DAPP_PATH, MODE_WALLET_CHOICE } from "@wizardconnect/core";
const adapter: WalletAdapter = {
// ... core implementation ...
signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
async signMessage(request) {
const index = request.addressIndex ?? 0;
const privateKey = derivePrivateKey(request.path ?? "receive", index);
return {
signature: signBitcoinMessage(request.message, privateKey),
path: request.path ?? "receive",
addressIndex: index,
};
},
};
```
Use `signBitcoinMessage()` rather than assembling the construction. The magic string and both
compactSize length prefixes are what third-party verifiers check, and they are covered by this
repository's conformance tests against a real Electron Cash install; a reimplementation is not.
### Dapp side
```typescript
import { createLoginChallenge, MODE_WALLET_CHOICE } from "@wizardconnect/core";
if (!manager.walletSupportsSignMessage(MODE_WALLET_CHOICE)) {
// Hide the login button rather than offering one that fails.
}
// Build the message with the login-challenge helper, not by hand — it carries the
// single-use nonce, the domain and the expiry that stop a signature being replayed.
const result = await manager.signMessage({
message: createLoginChallenge({ domain: location.host, nonce, expiresInSeconds: 300 }),
userPrompt: "Sign in",
});
// result is already verified; result.address is the proven identity. The server then
// calls verifyLoginChallenge(), which retires the nonce.
```
See [protocol.md § sign_message](protocol.md#sign_message) for the wire format,
[wallet.md](wallet.md#signmessage) for the adapter contract and display requirements, and
[dapp.md](dapp.md#signmessage) for what is verified and why replay is the dapp's job.

View file

@ -35,6 +35,11 @@ wallet_ready — wallet → dapp, signals wallet is alive + deliver
sign_transaction_request — dapp → wallet, asks wallet to sign a transaction
sign_transaction_response — wallet → dapp, returns signed tx or error
sign_cancel — dapp → wallet only, cancels an in-flight sign_transaction_request
or sign_message_request
sign_message_request — dapp → wallet, asks wallet to sign a plain message, proving key
control without a transaction. Gated on the `sign_message`
extension. See extensions.md.
sign_message_response — wallet → dapp, returns the signature, the signing key and its address
disconnect — either → either, courtesy notification before tearing down
chunk — either → either, transport-level. Carries one slice of a larger message
that exceeds NIP-44's 65,535-byte plaintext ceiling. Not tied to
@ -335,12 +340,20 @@ interface SignCancelMessage {
}
```
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request`. The wallet should
dismiss the corresponding sign dialog immediately upon receipt.
Sent by the **dapp only** to cancel an in-flight `sign_transaction_request` **or
`sign_message_request`**. The wallet should dismiss the corresponding dialog immediately upon
receipt.
One `sign_cancel` unambiguously names one request of either kind, because both draw their
`sequence` from a single per-connection counter (`RelayClient.nextSequence`). That shared sequence
space is also why the wallet's dedup guard is shared: a sequence identifies a request regardless of
its action.
Use cases:
- User presses cancel on the dapp side while waiting for the wallet to sign.
- Dapp replaces a stale request with a new one (e.g., trade price has changed).
- A login prompt the user never answered. `sign_message` has no timeout by design — cancellation is
explicit, matching `sign_transaction_request`.
**Dapp side** (`DappConnectionManager`):
- `sendSignCancel(sequence, reason?)` — immediately rejects the pending Promise for that sequence,
@ -352,6 +365,117 @@ Use cases:
---
## sign_message
Proves control of a key without a transaction: identity verification, SIWX-style login, or a signed
statement that can be published on chain. Gated on the `sign_message` extension — see
[extensions.md](extensions.md#sign_message).
The signature is the standard **"Bitcoin Signed Message"** construction, so it verifies in Electron
Cash, Electrum and `bitcoin-cli verifymessage`. That portability is the point: a third party holding
only the message, the signature and an address can check it, with no knowledge of this protocol.
### sign_message_request
```typescript
interface SignMessageRequest {
action: "sign_message_request";
sequence: number;
message: string; // exact UTF-8 to sign — never trimmed or normalised
userPrompt?: string; // dapp-supplied context for the wallet's prompt; NOT signed
path?: PathName; // omit both to let the wallet choose the key
addressIndex?: number;
scheme?: "bitcoin_signed_message"; // default when absent
time: number;
}
```
`SignMessageRequest` extends `WcSignMessageRequest` from `@bch-wc2/interfaces` — the interface
wallets already implement for WalletConnect — so the object can be passed straight to an existing
WC2 `signMessage` handler. hdwalletv1 adds only the optional key selection, mirroring how
`SignTransactionRequest` wraps `WcSignTransactionRequest` and adds `inputPaths`.
**Key selection is all-or-nothing.** `path` and `addressIndex` must both be present or both absent;
half of one is ambiguous between the two modes and is rejected.
| Mode | Request | Who picks the key | Needs an xpub? |
|------|---------|-------------------|----------------|
| `dapp_path` | `path` + `addressIndex` set | dapp | yes |
| `wallet_choice` | both omitted | wallet | no |
`wallet_choice` exists for pure identity checks, where requiring an xpub would mean sharing the
user's whole address history to prove control of one key. A wallet advertising it **must** choose
deterministically: a dapp treats the returned address as a durable identity, so a fresh key per
connection makes a returning user unrecognisable.
### sign_message_response
```typescript
// Success
interface SignMessageSuccess {
action: "sign_message_response";
sequence: number;
signature: string; // base64, 65 bytes decoded — a WcSignMessageResponse
publicKey: string; // hex, in the serialisation the signature's header declares
address: string; // CashAddr of publicKey
scheme: "bitcoin_signed_message";
path?: PathName; // echoed: what the wallet actually used
addressIndex?: number;
time: number;
}
// Failure
interface SignMessageFailure {
action: "sign_message_response";
sequence: number;
error: string;
time: number;
}
```
`publicKey` and `address` are required on success. Under `wallet_choice` they are the dapp's only
way to learn which key answered, and requiring them means a dapp can always verify rather than
sometimes.
**The compression bit is load-bearing.** The signature's header byte declares whether the public key
is compressed, and a key's compressed and uncompressed forms hash to **two different addresses**. A
response must report the form its header declares, or it is claiming a proof about an address it did
not prove. `recoverMessageSigner()` returns `{ publicKey, compressed }` together for this reason.
### What the wallet checks
`WalletConnectionManager.sendSignMessageResponse()` derives `publicKey` and `address` from the
signature by recovery rather than accepting them from the adapter, so the three can never disagree.
It then compares the recovered key against the adapter's own key for the path, and throws on
mismatch. Recovery alone cannot detect a signature over the wrong text — it succeeds and yields some
other key — so this comparison is what turns a wallet-side bug into an error at the call site
instead of an opaque rejection across the relay.
### What the dapp checks
`DappConnectionManager.signMessage()` resolves only after verifying that the signature recovers over
the message it sent, that `publicKey` is the key that signed, that `address` is that key's address,
and — when the dapp named a path it can derive — that the signer is exactly the key it asked for.
Anything inconsistent rejects. See [dapp.md](dapp.md#signmessage).
### Replay is the verifier's responsibility
A signature proves key control over that exact text. It carries **no freshness and no audience**: it
is valid forever and to everyone. A login flow must put a single-use, server-issued nonce in
`message` and retire it after one use; nothing at the protocol level can enforce that.
Use `createLoginChallenge()` / `verifyLoginChallenge()` from `@wizardconnect/core` rather than
composing the text yourself — `verifyLoginChallenge` cannot be called without a nonce-consuming
callback and an expected domain, which is the enforcement this layer cannot provide. See
[dapp.md § Replay](dapp.md#replay--use-the-login-challenge-helpers).
### Re-delivery on reconnect
Handled exactly like `sign_transaction_request`: the dapp re-sends pending requests on
`wallet_ready`, and the wallet's shared dedup guard means a replay does not re-prompt the user.
---
## disconnect
Either side may send a `disconnect` message before tearing down the relay connection. This is a

View file

@ -34,6 +34,13 @@ interface WalletAdapter {
/** Optional: extension data for the session handshake. */
getExtensions?(): Record<string, unknown>;
/** Optional: sign a plain message to prove key control. Implementing this is what
* advertises the `sign_message` extension to dapps. See § signMessage below. */
signMessage?(request: SignMessageRequest): Promise<SignMessageResult>;
/** Optional: key-selection modes signMessage supports. Defaults to [MODE_DAPP_PATH]. */
signMessageModes?(): MessageSigningMode[];
}
```
@ -58,6 +65,22 @@ interface SignTransactionResult {
}
```
### SignMessageResult
```typescript
interface SignMessageResult {
signature: string; // base64 — build it with signBitcoinMessage()
addressPrefix?: CashAddrPrefix; // default "bitcoincash"; set on testnet
path?: PathName; // which key was used, when the wallet can say
addressIndex?: number;
}
```
Deliberately just the signature. The public key and address are **not** asked for, because both are
recoverable from the signature and `WalletConnectionManager` derives them that way — so the three
values in the response can never disagree, and an adapter cannot accidentally claim a proof about an
address it did not prove.
## WalletConnectionManager
```typescript
@ -83,9 +106,17 @@ class WalletConnectionManager extends EventEmitter {
// Send an error back to the dapp (user rejected, signing failed, etc.)
sendSignError(connectionId: string, sequence: number, errorMessage: string): Promise<void>
// Send a message signature back to the dapp. Derives the public key and address
// from the signature; throws if it does not match the requested message and key.
sendSignMessageResponse(connectionId: string, sequence: number, result: SignMessageResult): Promise<void>
// Refuse a message-signing request (user rejected, unsupported, etc.)
sendSignMessageError(connectionId: string, sequence: number, errorMessage: string): Promise<void>
// Events
on("connectionStatusChanged", (id: string, status: RelayStatus) => void)
on("pendingSignRequest", (req: PendingSignRequest) => void)
on("pendingSignMessageRequest", (req: PendingSignMessageRequest) => void)
on("connectionsChanged", () => void)
on("remoteDisconnect", (connectionId: string, reason: DisconnectReason, message: string | undefined) => void)
on("message", (connectionId: string, message: ProtocolMessage) => void) // extension messages
@ -185,6 +216,80 @@ docs for the security rationale. Because the dapp specifies `inputPaths`, the wa
dapp's key selection — `SIGHASH_ALL` is what makes this safe (a wrong-key signature is simply
invalid and cannot be repurposed).
## signMessage
Optional. Implementing it advertises the `sign_message` extension; omitting it leaves the wallet
working exactly as before, and a dapp that asks anyway gets an explicit error rather than silence.
```typescript
import { signBitcoinMessage, MODE_DAPP_PATH, MODE_WALLET_CHOICE } from "@wizardconnect/core";
signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
async signMessage(request) {
const index = request.addressIndex ?? 0;
const privateKey = derivePrivateKey(request.path ?? "receive", index);
return {
signature: signBitcoinMessage(request.message, privateKey),
path: request.path ?? "receive",
addressIndex: index,
};
},
```
Then approve it like a transaction:
```typescript
manager.on("pendingSignMessageRequest", async ({ connectionId, request }) => {
const approved = await showMessageSigningDialog(request); // your UI
if (!approved) {
await manager.sendSignMessageError(connectionId, request.sequence, "User rejected");
return;
}
const result = await adapter.signMessage!(request);
await manager.sendSignMessageResponse(connectionId, request.sequence, result);
});
```
### Requirements
**Sign the message verbatim.** UTF-8, no trimming, no Unicode normalisation. The signature must
verify against exactly the text the dapp displayed; re-encoding it produces a signature that
verifies against nothing.
**Use `signBitcoinMessage()`.** The magic string and both compactSize length prefixes are what
third-party verifiers check. This repository tests them against a real Electron Cash install
(`npm run test:compat --workspace @wizardconnect/core`) and against committed vectors Electron Cash
generated; a hand-rolled reimplementation in a wallet gets neither.
**Choose deterministically under `wallet_choice`.** A dapp treats the returned address as a durable
identity. If the wallet picks a different key per connection, a returning user is unrecognisable and
login breaks. Only advertise `MODE_WALLET_CHOICE` if the choice is stable across restarts. Consider a
dedicated identity key rather than `receive/0`, so proving identity does not link it to the user's
main address history.
**Echo the path used.** It is how a dapp learns which key answered under `wallet_choice`, and it lets
`sendSignMessageResponse` check the signature against the key that should have produced it.
### Display requirements
`sendSignMessageResponse` verifies the cryptography. It cannot verify that the user understood what
they signed, which is the wallet's job:
- **Show `request.message` in full, verbatim.** Do not truncate it. The user is signing every byte,
including trailing whitespace and newlines.
- **`request.userPrompt` is dapp-supplied and unsigned.** Render it as clearly subordinate to the
message and attribute it to the dapp. Presented as equal, it lets a dapp caption hostile text
reassuringly.
- **Flag adversarial text.** Bidirectional overrides, zero-width characters, control characters and
very long messages can all make signed text display as something other than what it is.
- Message signing is safer to approve than a dummy transaction — the magic prefix guarantees the
digest can never coincide with a transaction sighash, so no message signature can ever authorise a
spend. That is a reason to prefer it over "sign this unspendable transaction" patterns, not a
reason to show the user less.
---
## Minimal example
```typescript

View file

@ -26,6 +26,7 @@
"scripts": {
"build": "tsc",
"test": "vitest --config vitest.config.ts --run --passWithNoTests",
"test:compat": "vitest --config vitest.compat.config.ts --run",
"lint:prettier": "prettier --ignore-path ../../.gitignore . --list-different",
"lint:eslint": "eslint .",
"lint": "npm run lint:eslint && npm run lint:prettier",

View file

@ -30,6 +30,8 @@ export type {
KeyExchangeURIResult,
} from "./key-exchange.js";
export * from "./protocols/hdwalletv1.js";
export * from "./protocols/message-signing.js";
export * from "./protocols/login-challenge.js";
export * from "./protocols/base.js";
export {
CHUNK_EXTENSION_NAME,

View file

@ -21,8 +21,15 @@ export enum RelayMsgAction {
/// Request: The dapp wants wallet to sign a transaction.
SignTransactionRequest = "sign_transaction_request",
SignTransactionResponse = "sign_transaction_response",
/// Dapp-only: cancels an in-flight sign_transaction_request.
/// Dapp-only: cancels an in-flight sign_transaction_request or
/// sign_message_request. Sequences are unique across both, so one action
/// cancels either.
SignCancel = "sign_cancel",
/// Request: The dapp wants the wallet to sign a plain message, proving control
/// of a key without a transaction. Gated on the `sign_message` extension —
/// see message-signing.ts and docs/extensions.md.
SignMessageRequest = "sign_message_request",
SignMessageResponse = "sign_message_response",
/// Courtesy notification: one side is closing the connection.
Disconnect = "disconnect",
/// Transport-level: carries one slice of a message that exceeds NIP-44's

View file

@ -0,0 +1,453 @@
// 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
/**
* Login challenge tests.
*
* These exist because the failure mode is silent: a login built without single-use
* nonce enforcement, domain binding or expiry works perfectly in every manual test
* and is permanently replayable in production. So the tests here are mostly about
* what must be REFUSED the replay, the wrong site, the stale challenge, the
* injected field rather than the happy path.
*/
import { describe, expect, it } from "vitest";
import {
encodeBase58Address,
encodeCashAddress,
hash160,
hexToBin,
secp256k1,
} from "@bitauth/libauth";
import {
createInMemoryNonceStore,
createLoginChallenge,
createLoginNonce,
DEFAULT_MAX_AGE_SECONDS,
MIN_NONCE_LENGTH,
parseLoginChallenge,
verifyLoginChallenge,
} from "./login-challenge.js";
import { signBitcoinMessage } from "./message-signing.js";
const PRIVATE_KEY = hexToBin("00".repeat(31) + "01");
const OTHER_KEY = hexToBin("00".repeat(31) + "02");
function addressOf(privateKey: Uint8Array): string {
const publicKey = secp256k1.derivePublicKeyCompressed(
privateKey,
) as Uint8Array;
return encodeCashAddress({
payload: hash160(publicKey),
prefix: "bitcoincash",
type: "p2pkh",
}).address;
}
const ADDRESS = addressOf(PRIVATE_KEY);
const OTHER_ADDRESS = addressOf(OTHER_KEY);
const DOMAIN = "app.example.com";
const NONCE = "8f3a21c0d4b57e69";
const NOW = Date.parse("2026-08-06T12:00:00Z");
const ISSUED_AT = "2026-08-06T12:00:00Z";
/** A challenge plus its signature, and a store that has issued the nonce. */
function scenario(
overrides: Partial<Parameters<typeof createLoginChallenge>[0]> = {},
privateKey = PRIVATE_KEY,
) {
const message = createLoginChallenge({
domain: DOMAIN,
nonce: NONCE,
address: ADDRESS,
issuedAt: ISSUED_AT,
...overrides,
});
const store = createInMemoryNonceStore();
store.issue(overrides.nonce ?? NONCE);
return {
message,
signature: signBitcoinMessage(message, privateKey),
store,
verify: (options: Record<string, unknown> = {}) =>
verifyLoginChallenge(message, signBitcoinMessage(message, privateKey), {
domain: DOMAIN,
consumeNonce: store.consume,
now: NOW,
...options,
}),
};
}
describe("createLoginNonce", () => {
it("produces distinct, long-enough nonces", () => {
const a = createLoginNonce();
const b = createLoginNonce();
expect(a).not.toBe(b);
expect(a.length).toBeGreaterThanOrEqual(MIN_NONCE_LENGTH);
expect(a).toMatch(/^[0-9a-f]+$/);
});
it("refuses a size that would be guessable", () => {
expect(() => createLoginNonce(4)).toThrow();
});
});
describe("createLoginChallenge", () => {
it("produces the documented layout", () => {
expect(
createLoginChallenge({
domain: DOMAIN,
nonce: NONCE,
address: ADDRESS,
issuedAt: ISSUED_AT,
}),
).toBe(
`${DOMAIN} wants you to sign in.\n` +
`\n` +
`Address: ${ADDRESS}\n` +
`Nonce: ${NONCE}\n` +
`Issued At: ${ISSUED_AT}`,
);
});
it("omits the address when the dapp does not know it yet", () => {
// MODE_WALLET_CHOICE: the wallet has not told us which key it will use.
const message = createLoginChallenge({
domain: DOMAIN,
nonce: NONCE,
issuedAt: ISSUED_AT,
});
expect(message).not.toContain("Address:");
expect(parseLoginChallenge(message)?.address).toBeUndefined();
});
it("includes an expiry and a statement when asked", () => {
const message = createLoginChallenge({
domain: DOMAIN,
nonce: NONCE,
issuedAt: ISSUED_AT,
expiresInSeconds: 300,
statement: "Sign in to view your positions.",
});
const parsed = parseLoginChallenge(message)!;
expect(parsed.expiresAt).toBe("2026-08-06T12:05:00Z");
expect(parsed.statement).toBe("Sign in to view your positions.");
});
it("refuses a nonce short enough to guess", () => {
// The difference between a real nonce and a counter is invisible at the call
// site, so it is checked here rather than trusted.
expect(() =>
createLoginChallenge({
domain: DOMAIN,
nonce: "123",
issuedAt: ISSUED_AT,
}),
).toThrow(/at least 16 characters/);
});
it("refuses a line break in any field, so no field can inject another", () => {
for (const bad of [
{ domain: `evil\nNonce: ${NONCE}` },
{ nonce: `${NONCE}\nExpires At: 2099-01-01T00:00:00Z` },
{ address: `${ADDRESS}\nAddress: ${OTHER_ADDRESS}` },
{ statement: "hi\nNonce: attacker" },
]) {
expect(() =>
createLoginChallenge({
domain: DOMAIN,
nonce: NONCE,
issuedAt: ISSUED_AT,
...bad,
}),
).toThrow(/line break/);
}
});
it("refuses an empty domain and a bad date", () => {
expect(() => createLoginChallenge({ domain: "", nonce: NONCE })).toThrow(
/domain/,
);
expect(() =>
createLoginChallenge({
domain: DOMAIN,
nonce: NONCE,
issuedAt: "not a date",
}),
).toThrow(/valid date/);
});
});
describe("parseLoginChallenge", () => {
it("round-trips what createLoginChallenge produced", () => {
const message = createLoginChallenge({
domain: DOMAIN,
nonce: NONCE,
address: ADDRESS,
issuedAt: ISSUED_AT,
expiresInSeconds: 60,
statement: "hello",
});
expect(parseLoginChallenge(message)).toEqual({
domain: DOMAIN,
nonce: NONCE,
address: ADDRESS,
issuedAt: ISSUED_AT,
expiresAt: "2026-08-06T12:01:00Z",
statement: "hello",
});
});
it("rejects anything that is not this exact shape", () => {
const valid = createLoginChallenge({
domain: DOMAIN,
nonce: NONCE,
issuedAt: ISSUED_AT,
});
const cases: [string, string][] = [
["arbitrary text", "hello world"],
["missing blank line", valid.replace("\n\n", "\n")],
[
"missing nonce",
`${DOMAIN} wants you to sign in.\n\nIssued At: ${ISSUED_AT}`,
],
[
"missing issued at",
`${DOMAIN} wants you to sign in.\n\nNonce: ${NONCE}`,
],
["unknown field", `${valid}\nAdmin: true`],
["duplicate nonce", `${valid}\nNonce: other-nonce-1234567`],
[
"fields out of order",
`${DOMAIN} wants you to sign in.\n\nIssued At: ${ISSUED_AT}\nNonce: ${NONCE}`,
],
[
"empty value",
`${DOMAIN} wants you to sign in.\n\nNonce: \nIssued At: x`,
],
["bad date", valid.replace(ISSUED_AT, "yesterday")],
[
"no domain",
` wants you to sign in.\n\nNonce: ${NONCE}\nIssued At: ${ISSUED_AT}`,
],
[
"wrong first line",
valid.replace("wants you to sign in.", "says hello"),
],
];
for (const [name, message] of cases) {
expect(parseLoginChallenge(message), name).toBeUndefined();
}
});
});
describe("verifyLoginChallenge", () => {
it("accepts a fresh, correctly signed challenge and returns the address", async () => {
const result = await scenario().verify();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.address).toBe(ADDRESS);
expect(result.challenge.nonce).toBe(NONCE);
}
});
it("retires the nonce, so the same signature cannot be used twice", async () => {
// The whole reason this module exists.
const s = scenario();
const first = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: s.store.consume,
now: NOW,
});
expect(first.ok).toBe(true);
const replay = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: s.store.consume,
now: NOW,
});
expect(replay.ok).toBe(false);
if (!replay.ok) expect(replay.reason).toMatch(/nonce was not outstanding/);
});
it("rejects a nonce the server never issued", async () => {
const s = scenario();
const empty = createInMemoryNonceStore();
const result = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: empty.consume,
now: NOW,
});
expect(result.ok).toBe(false);
});
it("does not spend the nonce when the signature is bad", async () => {
// Otherwise anyone who sniffs a nonce can burn it before the real user
// finishes signing.
const s = scenario();
const tampered = Buffer.from(s.signature, "base64");
tampered[10] ^= 0x01;
const result = await verifyLoginChallenge(
s.message,
tampered.toString("base64"),
{ domain: DOMAIN, consumeNonce: s.store.consume, now: NOW },
);
expect(result.ok).toBe(false);
expect(s.store.outstanding()).toBe(1);
// ...and the real user can still complete the login afterwards.
const genuine = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: s.store.consume,
now: NOW,
});
expect(genuine.ok).toBe(true);
});
it("rejects a challenge addressed to another site", async () => {
// A signature the user made for evil.example is valid; it is just not for us.
const s = scenario({ domain: "evil.example" });
const result = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: s.store.consume,
now: NOW,
});
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.reason).toMatch(/addressed to "evil.example"/);
expect(s.store.outstanding()).toBe(1);
});
it("rejects a challenge older than the max age", async () => {
const result = await scenario().verify({
now: NOW + (DEFAULT_MAX_AGE_SECONDS + 120) * 1000,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/older than/);
});
it("honours an explicit expiry before the max age", async () => {
const s = scenario({ expiresInSeconds: 60 });
const stillValid = await s.verify({ now: NOW + 30_000 });
expect(stillValid.ok).toBe(true);
const expired = await scenario({ expiresInSeconds: 60 }).verify({
now: NOW + 300_000,
});
expect(expired.ok).toBe(false);
if (!expired.ok) expect(expired.reason).toMatch(/expired/);
});
it("rejects a challenge issued in the future beyond clock tolerance", async () => {
const result = await scenario().verify({ now: NOW - 3600_000 });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/issued in the future/);
});
it("tolerates small clock skew", async () => {
const result = await scenario().verify({ now: NOW - 30_000 });
expect(result.ok).toBe(true);
});
it("rejects a signature from a key other than the one the message names", async () => {
const s = scenario({}, OTHER_KEY);
const result = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: s.store.consume,
now: NOW,
});
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/not valid for/);
expect(s.store.outstanding()).toBe(1);
});
it("rejects when the caller expected a different address than the message names", async () => {
const result = await scenario().verify({ address: OTHER_ADDRESS });
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/but .* was expected/);
});
it("accepts the expected address in a different encoding", async () => {
// Legacy base58 for the same key must not read as a mismatch.
const publicKey = secp256k1.derivePublicKeyCompressed(
PRIVATE_KEY,
) as Uint8Array;
const legacy = encodeBase58Address("p2pkh", hash160(publicKey));
const result = await scenario().verify({ address: legacy });
expect(result.ok).toBe(true);
});
it("verifies against the caller's address when the message names none", async () => {
// MODE_WALLET_CHOICE: the dapp learned the address from the response and can
// pass it here.
const s = scenario({ address: undefined });
const ok = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: s.store.consume,
now: NOW,
address: ADDRESS,
});
expect(ok.ok).toBe(true);
const s2 = scenario({ address: undefined });
const wrong = await verifyLoginChallenge(s2.message, s2.signature, {
domain: DOMAIN,
consumeNonce: s2.store.consume,
now: NOW,
address: OTHER_ADDRESS,
});
expect(wrong.ok).toBe(false);
});
it("still recovers an address when neither side named one", async () => {
const s = scenario({ address: undefined });
const result = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: s.store.consume,
now: NOW,
});
expect(result.ok).toBe(true);
if (result.ok) expect(result.address).toBe(ADDRESS);
});
it("rejects a message that is not a login challenge at all", async () => {
const message = "just sign this please";
const store = createInMemoryNonceStore();
const result = await verifyLoginChallenge(
message,
signBitcoinMessage(message, PRIVATE_KEY),
{ domain: DOMAIN, consumeNonce: store.consume, now: NOW },
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toMatch(/well-formed/);
});
it("awaits an async nonce store", async () => {
const s = scenario();
const result = await verifyLoginChallenge(s.message, s.signature, {
domain: DOMAIN,
consumeNonce: async (nonce) => {
await new Promise((r) => setTimeout(r, 1));
return s.store.consume(nonce);
},
now: NOW,
});
expect(result.ok).toBe(true);
});
});
describe("createInMemoryNonceStore", () => {
it("consumes each nonce exactly once", () => {
const store = createInMemoryNonceStore();
store.issue("a");
expect(store.consume("a")).toBe(true);
expect(store.consume("a")).toBe(false);
expect(store.consume("never-issued")).toBe(false);
expect(store.outstanding()).toBe(0);
});
});

View file

@ -0,0 +1,408 @@
// 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
/**
* Login challenges a safe default message format for `sign_message` logins.
*
* WHY THIS EXISTS
*
* A message signature proves key control over an exact string. It carries no
* freshness and no audience, so on its own it is a password that never expires
* and works everywhere. Telling integrators "use a single-use nonce" in the docs
* does not stop anyone building a permanently replayable login; it just means the
* broken version is their fault instead of ours.
*
* So the two failures that matter are structural here rather than advisory:
*
* - `verifyLoginChallenge` cannot be called without a `consumeNonce` callback
* and an expected `domain`. There is no overload that skips them. Verifying a
* login without single-use enforcement is not something this API can express.
*
* - `createLoginChallenge` refuses a nonce shorter than
* MIN_NONCE_LENGTH and refuses any field containing a line break, so a
* guessable nonce or an injected `Nonce:` line cannot get into a message.
*
* This is a message FORMAT, not a wire protocol message: the result is the
* `message` field of a normal sign_message_request, and any wallet that can sign
* text can sign it.
*
* NOT SIWE. The layout is deliberately similar to Sign-In With Ethereum
* (EIP-4361) so it reads familiarly, but it is not that format and does not claim
* compatibility with it or with CAIP-122 there is no agreed SIWX profile for
* Bitcoin Cash to conform to. If one lands, it belongs beside this as a second
* format, not as a silent change to this one.
*/
import { binToHex, generateRandomBytes } from "@bitauth/libauth";
import {
addressesEqual,
messageSignatureAddress,
recoverMessageSigner,
verifyMessageSignatureForAddress,
type CashAddrPrefix,
} from "./message-signing.js";
/**
* Shortest nonce `createLoginChallenge` will accept, in characters.
*
* 16 hex characters is 64 bits, which is far past guessable for a value that
* lives for minutes. The check exists because the difference between a real nonce
* and `Date.now()` is invisible in a code review of the calling site.
*/
export const MIN_NONCE_LENGTH = 16;
/** Default window between `Issued At` and verification. */
export const DEFAULT_MAX_AGE_SECONDS = 600;
/** Allowance for clock skew between the signer and the verifier. */
export const DEFAULT_CLOCK_TOLERANCE_SECONDS = 60;
const FIRST_LINE_SUFFIX = " wants you to sign in.";
/** Fields of a login challenge, in the order they appear in the message. */
const FIELD_ORDER = [
"Address",
"Nonce",
"Issued At",
"Expires At",
"Statement",
] as const;
type FieldName = (typeof FIELD_ORDER)[number];
export interface LoginChallenge {
/** The site the user is signing in to, e.g. "app.example.com". */
domain: string;
/** Single-use, server-issued value. */
nonce: string;
/** ISO 8601, seconds precision, UTC. */
issuedAt: string;
/** The address being claimed, when the dapp knew it in advance. */
address?: string;
/** ISO 8601 hard expiry, independent of the verifier's max age. */
expiresAt?: string;
/** Human-readable purpose, shown to the user as part of the signed text. */
statement?: string;
}
/**
* Generate a nonce.
*
* MUST be called by the server that will later verify the signature, and stored
* before it is handed out. A nonce the client invents is not a nonce the client
* is the party a replay attack impersonates, so it cannot also be the party that
* decides what counts as fresh.
*/
export function createLoginNonce(bytes = 16): string {
if (bytes < 8) {
throw new Error(`Login nonce must be at least 8 bytes, got ${bytes}`);
}
return binToHex(generateRandomBytes(bytes));
}
function assertSingleLine(field: string, value: string): void {
if (/[\r\n]/.test(value)) {
throw new Error(
`Login challenge ${field} must not contain a line break — a value that ` +
`spans lines could inject its own fields into the signed message`,
);
}
}
/**
* Build the message text for a login challenge.
*
* ```
* app.example.com wants you to sign in.
*
* Address: bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h
* Nonce: 8f3a21c0d4b57e69
* Issued At: 2026-08-06T12:00:00Z
* ```
*
* `address` is optional because under MODE_WALLET_CHOICE the dapp does not know
* which key will answer. Including it when it IS known makes the proof
* self-describing a third party reading the message alone can see which
* address was claimed and `verifyLoginChallenge` then requires the recovered
* address to match it.
*/
export function createLoginChallenge(challenge: {
domain: string;
nonce: string;
address?: string;
issuedAt?: Date | string;
expiresInSeconds?: number;
statement?: string;
}): string {
const { domain, nonce, address, statement } = challenge;
if (!domain) throw new Error("Login challenge needs a domain");
assertSingleLine("domain", domain);
if (nonce.length < MIN_NONCE_LENGTH) {
throw new Error(
`Login challenge nonce must be at least ${MIN_NONCE_LENGTH} characters, ` +
`got ${nonce.length} — use createLoginNonce() on the server`,
);
}
assertSingleLine("nonce", nonce);
if (address !== undefined) assertSingleLine("address", address);
if (statement !== undefined) assertSingleLine("statement", statement);
const issued =
challenge.issuedAt instanceof Date
? challenge.issuedAt
: challenge.issuedAt !== undefined
? new Date(challenge.issuedAt)
: new Date();
if (Number.isNaN(issued.getTime())) {
throw new Error(`Login challenge issuedAt is not a valid date`);
}
const lines = [`${domain}${FIRST_LINE_SUFFIX}`, ""];
if (address !== undefined) lines.push(`Address: ${address}`);
lines.push(`Nonce: ${nonce}`);
lines.push(`Issued At: ${toIsoSeconds(issued)}`);
if (challenge.expiresInSeconds !== undefined) {
if (challenge.expiresInSeconds <= 0) {
throw new Error("Login challenge expiresInSeconds must be positive");
}
lines.push(
`Expires At: ${toIsoSeconds(
new Date(issued.getTime() + challenge.expiresInSeconds * 1000),
)}`,
);
}
if (statement !== undefined) lines.push(`Statement: ${statement}`);
return lines.join("\n");
}
function toIsoSeconds(date: Date): string {
return `${date.toISOString().slice(0, 19)}Z`;
}
/**
* Parse a login challenge, strictly.
*
* Strict on purpose: a lenient parser is where field-injection lives. Unknown
* field names, duplicate fields, fields out of order and stray lines are all
* rejected rather than skipped, so there is exactly one byte sequence that parses
* to a given challenge.
*/
export function parseLoginChallenge(
message: string,
): LoginChallenge | undefined {
const lines = message.split("\n");
if (lines.length < 4) return undefined;
if (!lines[0].endsWith(FIRST_LINE_SUFFIX)) return undefined;
const domain = lines[0].slice(0, -FIRST_LINE_SUFFIX.length);
if (!domain) return undefined;
if (lines[1] !== "") return undefined;
const fields = new Map<FieldName, string>();
let lastFieldIndex = -1;
for (const line of lines.slice(2)) {
const separator = line.indexOf(": ");
if (separator <= 0) return undefined;
const name = line.slice(0, separator);
const value = line.slice(separator + 2);
const fieldIndex = (FIELD_ORDER as readonly string[]).indexOf(name);
// Unknown, repeated or out-of-order fields all mean this is not a
// well-formed challenge; a parser that shrugged here would let an attacker
// append their own Nonce line to a legitimate statement.
if (fieldIndex === -1 || fieldIndex <= lastFieldIndex) return undefined;
if (!value) return undefined;
lastFieldIndex = fieldIndex;
fields.set(name as FieldName, value);
}
const nonce = fields.get("Nonce");
const issuedAt = fields.get("Issued At");
if (!nonce || !issuedAt) return undefined;
if (Number.isNaN(Date.parse(issuedAt))) return undefined;
const expiresAt = fields.get("Expires At");
if (expiresAt !== undefined && Number.isNaN(Date.parse(expiresAt))) {
return undefined;
}
const address = fields.get("Address");
const statement = fields.get("Statement");
return {
domain,
nonce,
issuedAt,
...(address !== undefined ? { address } : {}),
...(expiresAt !== undefined ? { expiresAt } : {}),
...(statement !== undefined ? { statement } : {}),
};
}
export interface VerifyLoginChallengeOptions {
/**
* The domain this verifier serves. Required, and compared exactly.
*
* Without it a signature a user produced for evil.example can be presented to
* this site and accepted the signature is perfectly valid, it just was not
* meant for you.
*/
domain: string;
/**
* Retire the nonce, returning true if it was outstanding and is now spent.
*
* Required. This is the only thing that makes a login single-use, and it is a
* property of your storage rather than of this function, so it has to be
* supplied. It MUST be atomic a compare-and-delete, `DELETE ... RETURNING`,
* or equivalent because two replays arriving together will both reach this
* point, and only one may be told true.
*
* Called last, after every other check has passed, so an attacker cannot burn a
* legitimate user's nonce by submitting a bad signature with it.
*/
consumeNonce: (nonce: string) => boolean | Promise<boolean>;
/** Require this exact address, in addition to whatever the message states. */
address?: string;
/** Seconds after `Issued At` that the challenge stops being acceptable. */
maxAgeSeconds?: number;
/** Allowance for the signer's clock running ahead of ours. */
clockToleranceSeconds?: number;
/** Override the current time — for tests, and for replaying an audit log. */
now?: Date | number;
/** Network prefix used when deriving the signer's address. */
addressPrefix?: CashAddrPrefix;
}
export type LoginChallengeVerification =
| {
ok: true;
/** The proven address. Reachable only after `ok` is checked. */
address: string;
challenge: LoginChallenge;
}
| { ok: false; reason: string };
/**
* Verify a signed login challenge and retire its nonce.
*
* Checks, in order: the message parses; the domain is ours; the challenge has not
* expired; the signature is valid for the address the message claims (or the one
* the caller expects); and finally that the nonce was outstanding.
*
* Returns a result rather than throwing, and the proven address is only reachable
* through the `ok: true` branch, so a caller cannot read an identity out of a
* failed verification.
*/
export async function verifyLoginChallenge(
message: string,
signature: string,
options: VerifyLoginChallengeOptions,
): Promise<LoginChallengeVerification> {
const {
domain,
consumeNonce,
address: expectedAddress,
maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS,
clockToleranceSeconds = DEFAULT_CLOCK_TOLERANCE_SECONDS,
addressPrefix = "bitcoincash",
} = options;
const challenge = parseLoginChallenge(message);
if (!challenge) {
return {
ok: false,
reason: "message is not a well-formed login challenge",
};
}
if (challenge.domain !== domain) {
return {
ok: false,
reason: `challenge is addressed to "${challenge.domain}", not "${domain}"`,
};
}
const now =
options.now instanceof Date
? options.now.getTime()
: (options.now ?? Date.now());
const issuedAt = Date.parse(challenge.issuedAt);
const tolerance = clockToleranceSeconds * 1000;
if (issuedAt > now + tolerance) {
return { ok: false, reason: "challenge was issued in the future" };
}
if (now - issuedAt > maxAgeSeconds * 1000 + tolerance) {
return {
ok: false,
reason: `challenge is older than ${maxAgeSeconds}s`,
};
}
if (challenge.expiresAt !== undefined) {
if (Date.parse(challenge.expiresAt) < now - tolerance) {
return { ok: false, reason: "challenge has expired" };
}
}
// Whose signature should this be? The message's own claim wins when it makes
// one, which is what makes an address-bearing challenge self-describing.
const claimed = challenge.address ?? expectedAddress;
if (
challenge.address !== undefined &&
expectedAddress !== undefined &&
!addressesEqual(challenge.address, expectedAddress)
) {
return {
ok: false,
reason: `challenge names ${challenge.address}, but ${expectedAddress} was expected`,
};
}
if (claimed !== undefined) {
if (!verifyMessageSignatureForAddress(message, signature, claimed)) {
return {
ok: false,
reason: `signature is not valid for ${claimed}`,
};
}
} else if (!recoverMessageSigner(message, signature)) {
return { ok: false, reason: "signature is malformed or does not recover" };
}
const address = messageSignatureAddress(message, signature, addressPrefix);
if (!address) {
return { ok: false, reason: "could not derive the signer's address" };
}
// Last, so a bad signature cannot spend a nonce the real user still needs.
if (!(await consumeNonce(challenge.nonce))) {
return {
ok: false,
reason:
"nonce was not outstanding — already used, expired or never issued",
};
}
return { ok: true, address, challenge };
}
/**
* A `consumeNonce` backed by an in-memory set, for development and tests.
*
* NOT for production with more than one process: two servers behind a load
* balancer each get their own set, so a signature can be spent once per process.
* Use your database.
*/
export function createInMemoryNonceStore(): {
issue: (nonce: string) => void;
consume: (nonce: string) => boolean;
outstanding: () => number;
} {
const outstanding = new Set<string>();
return {
issue: (nonce) => {
outstanding.add(nonce);
},
consume: (nonce) => outstanding.delete(nonce),
outstanding: () => outstanding.size,
};
}

View file

@ -0,0 +1,178 @@
// 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
/**
* The live half of the compatibility claim: signatures made by THIS code, handed
* to Electron Cash's own verifier.
*
* message-signing.vectors.test.ts proves we agree with Electron Cash's hashes and
* accept its signatures. That is most of the story but not all of it it cannot
* catch a fault that makes our *output* unacceptable while our input handling
* stays correct. This file closes the loop by shelling out to
* `electron-cash verifymessage`, which is exactly the "paste it into Verify
* Message" check a user would do.
*
* Not part of `npm test`: each invocation starts a full Electron Cash process, so
* this runs on demand.
*
* npm run test:compat --workspace @wizardconnect/core
*
* Skips itself when Electron Cash is not on PATH, so it is safe in CI.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { afterAll, describe, expect, it } from "vitest";
import {
encodeCashAddress,
hash160,
hexToBin,
secp256k1,
} from "@bitauth/libauth";
import {
messageSignatureAddress,
signBitcoinMessage,
} from "./message-signing.js";
const EC = "electron-cash";
function electronCashAvailable(): boolean {
const probe = spawnSync(EC, ["version"], {
encoding: "utf8",
timeout: 60000,
});
return probe.status === 0;
}
const available = electronCashAvailable();
if (!available) {
console.warn(
`[message-signing.compat] '${EC}' not on PATH — skipping live compatibility checks.`,
);
}
// Its own data directory: verifymessage needs no wallet, but Electron Cash will
// happily create config in the user's real directory if we let it.
const dataDir = available
? mkdtempSync(join(tmpdir(), "wizardconnect-ec-compat-"))
: "";
afterAll(() => {
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
});
/** Ask Electron Cash whether it accepts this signature. */
function ecVerify(
address: string,
signature: string,
message: string,
): boolean {
const result = spawnSync(
EC,
["-D", dataDir, "verifymessage", address, signature, message],
{ encoding: "utf8", timeout: 120000 },
);
if (result.error) {
throw new Error(`Could not run ${EC}: ${result.error.message}`);
}
const stdout = result.stdout?.trim();
if (stdout === "true") return true;
if (stdout === "false") return false;
// Electron Cash rejects some malformed input by erroring rather than printing
// false. For our purposes that is still "did not verify".
return false;
}
const PRIVATE_KEY = hexToBin("00".repeat(31) + "01");
function addressFor(compressed: boolean): string {
const publicKey = (
compressed
? secp256k1.derivePublicKeyCompressed(PRIVATE_KEY)
: secp256k1.derivePublicKeyUncompressed(PRIVATE_KEY)
) as Uint8Array;
return encodeCashAddress({
payload: hash160(publicKey),
prefix: "bitcoincash",
type: "p2pkh",
}).address;
}
const COMPRESSED_ADDRESS = addressFor(true);
const UNCOMPRESSED_ADDRESS = addressFor(false);
const MESSAGES: [name: string, message: string][] = [
["simple", "hello"],
["login", "wizardconnect login nonce=8f3a21c0d4"],
["empty", ""],
["multibyte", "Straße 日本語 🍅"],
["multiline", "line one\nline two\n"],
// Crosses the compactSize length-prefix boundary.
["long", "a".repeat(253)],
];
describe.skipIf(!available)("Electron Cash accepts our signatures", () => {
it.each(MESSAGES)(
"verifymessage accepts a compressed signature over %s",
(_name, message) => {
const signature = signBitcoinMessage(message, PRIVATE_KEY);
// Sanity: we and Electron Cash must agree on the address before the
// verdict below means anything.
expect(messageSignatureAddress(message, signature)).toBe(
COMPRESSED_ADDRESS,
);
expect(ecVerify(COMPRESSED_ADDRESS, signature, message)).toBe(true);
},
);
it("verifymessage accepts an uncompressed signature for its own address", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(ecVerify(UNCOMPRESSED_ADDRESS, signature, "hello")).toBe(true);
});
it("verifymessage refuses an uncompressed signature for the compressed address", () => {
// The external confirmation that these two addresses are not
// interchangeable — the assumption behind honouring the header's compression
// bit in recoverMessageSigner.
const signature = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(ecVerify(COMPRESSED_ADDRESS, signature, "hello")).toBe(false);
});
});
describe.skipIf(!available)("Electron Cash rejects what it should", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
it("rejects a flipped signature byte", () => {
const raw = Buffer.from(signature, "base64");
raw[10] ^= 0x01;
expect(ecVerify(COMPRESSED_ADDRESS, raw.toString("base64"), "hello")).toBe(
false,
);
});
it("rejects an altered message", () => {
expect(ecVerify(COMPRESSED_ADDRESS, signature, "hello!")).toBe(false);
expect(ecVerify(COMPRESSED_ADDRESS, signature, "hell")).toBe(false);
});
it("rejects a different address", () => {
const other = encodeCashAddress({
payload: hash160(
secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array,
),
prefix: "bitcoincash",
type: "p2pkh",
}).address;
expect(ecVerify(other, signature, "hello")).toBe(false);
});
});

View file

@ -0,0 +1,660 @@
// 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
/**
* Tests for the `sign_message` extension primitives.
*
* The value of this feature is that a signature made here verifies in OTHER
* software. That property lives entirely in the byte layout of the hash preimage
* and the signature encoding, so those are pinned against fixed vectors rather
* than round-tripped against ourselves: a round-trip would still pass if we got
* the magic string or the length prefixes wrong, and the feature would be
* silently useless.
*
* The claim that this actually matches Electron Cash is not asserted here it
* is checked against a real Electron Cash install in
* message-signing.compat.test.ts.
*/
import { describe, expect, it } from "vitest";
import {
bigIntToCompactUint,
binToHex,
encodeBase58Address,
encodeCashAddress,
hash160,
hash256,
hexToBin,
secp256k1,
utf8ToBin,
} from "@bitauth/libauth";
import {
bitcoinSignedMessageHash,
checkSignMessageResponse,
decodeMessageSignature,
encodeMessageSignature,
isSignMessageFailure,
isSignMessageRequest,
isSignMessageResponse,
messageSignatureAddress,
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
peerSignMessageInfo,
peerSupportsSignMessage,
recoverMessageSigner,
SCHEME_BITCOIN_SIGNED_MESSAGE,
SIGN_MESSAGE_EXTENSION,
signBitcoinMessage,
signMessageExtensionAdvertisement,
signMessageRequestMode,
verifyMessageSignature,
verifyMessageSignatureForAddress,
type SignMessageSuccess,
} from "./message-signing.js";
import { RelayMsgAction } from "./hdwalletv1.js";
/** Deterministic key so vectors below are stable. */
const PRIVATE_KEY = hexToBin(
"0000000000000000000000000000000000000000000000000000000000000001",
);
const PUBLIC_KEY = secp256k1.derivePublicKeyCompressed(
PRIVATE_KEY,
) as Uint8Array;
const PUBLIC_KEY_UNCOMPRESSED = secp256k1.derivePublicKeyUncompressed(
PRIVATE_KEY,
) as Uint8Array;
function cashaddr(publicKey: Uint8Array): string {
const encoded = encodeCashAddress({
payload: hash160(publicKey),
prefix: "bitcoincash",
type: "p2pkh",
});
return encoded.address;
}
const COMPRESSED_ADDRESS = cashaddr(PUBLIC_KEY);
const UNCOMPRESSED_ADDRESS = cashaddr(PUBLIC_KEY_UNCOMPRESSED);
describe("bitcoinSignedMessageHash", () => {
it("builds the preimage as compactSize(magic) || compactSize(message), double-SHA256", () => {
// Independent reconstruction. If the implementation ever stops matching
// this, signatures stop verifying in other wallets.
const magic = utf8ToBin("Bitcoin Signed Message:\n");
const body = utf8ToBin("hello");
const expected = hash256(
new Uint8Array([
...bigIntToCompactUint(BigInt(magic.length)),
...magic,
...bigIntToCompactUint(BigInt(body.length)),
...body,
]),
);
expect(binToHex(bitcoinSignedMessageHash("hello"))).toBe(
binToHex(expected),
);
});
it("uses the 0x18-prefixed Bitcoin magic, not a BCH-specific one", () => {
// BCH wallets kept Bitcoin's string verbatim; a "Bitcoin Cash Signed
// Message" variant would be incompatible with every existing verifier.
const magic = utf8ToBin("Bitcoin Signed Message:\n");
expect(magic.length).toBe(0x18);
expect(binToHex(bigIntToCompactUint(BigInt(magic.length)))).toBe("18");
});
it("length-prefixes the message so concatenations cannot collide", () => {
// Without prefixes, "ab"+"c" and "a"+"bc" would hash the same and a
// signature over one message could be presented as another.
expect(binToHex(bitcoinSignedMessageHash("abc"))).not.toBe(
binToHex(bitcoinSignedMessageHash("ab")),
);
expect(binToHex(bitcoinSignedMessageHash("a"))).not.toBe(
binToHex(bitcoinSignedMessageHash("ab")),
);
});
it("is 32 bytes and stable", () => {
const a = bitcoinSignedMessageHash("same input");
const b = bitcoinSignedMessageHash("same input");
expect(a.length).toBe(32);
expect(binToHex(a)).toBe(binToHex(b));
});
it("signs the message bytes verbatim — no trimming or normalising", () => {
// The wallet must sign exactly what the dapp displayed.
expect(binToHex(bitcoinSignedMessageHash(" x "))).not.toBe(
binToHex(bitcoinSignedMessageHash("x")),
);
// NFC vs NFD of the same grapheme must not collide either.
expect(binToHex(bitcoinSignedMessageHash("Straße"))).not.toBe(
binToHex(bitcoinSignedMessageHash("Strasse")),
);
expect(binToHex(bitcoinSignedMessageHash("é"))).not.toBe(
binToHex(bitcoinSignedMessageHash("é")),
);
});
it("handles an empty message and multi-byte UTF-8", () => {
expect(bitcoinSignedMessageHash("").length).toBe(32);
expect(bitcoinSignedMessageHash("日本語 🍅").length).toBe(32);
});
it("counts UTF-8 bytes, not characters, in the length prefix", () => {
// "🍅" is one character but four bytes. Prefixing the character count would
// produce a preimage no other implementation agrees with.
const message = "🍅";
const body = utf8ToBin(message);
expect(body.length).toBe(4);
const expected = hash256(
new Uint8Array([
0x18,
...utf8ToBin("Bitcoin Signed Message:\n"),
0x04,
...body,
]),
);
expect(binToHex(bitcoinSignedMessageHash(message))).toBe(
binToHex(expected),
);
});
it("switches to a 3-byte compactSize past 252 bytes", () => {
// Boundary in the length prefix itself — get this wrong and long messages
// silently produce signatures no other wallet accepts.
expect(bigIntToCompactUint(BigInt(252)).length).toBe(1);
expect(bigIntToCompactUint(BigInt(253)).length).toBe(3);
for (const length of [252, 253, 254]) {
const message = "a".repeat(length);
const expected = hash256(
new Uint8Array([
0x18,
...utf8ToBin("Bitcoin Signed Message:\n"),
...bigIntToCompactUint(BigInt(length)),
...utf8ToBin(message),
]),
);
expect(binToHex(bitcoinSignedMessageHash(message))).toBe(
binToHex(expected),
);
}
});
});
describe("encodeMessageSignature / decodeMessageSignature", () => {
const compact = new Uint8Array(64).fill(7);
it("produces header 27+recid+4 when compressed", () => {
for (const recoveryId of [0, 1, 2, 3]) {
const encoded = encodeMessageSignature(recoveryId, compact, true);
const decoded = decodeMessageSignature(encoded);
expect(decoded).toBeDefined();
expect(decoded!.recoveryId).toBe(recoveryId);
expect(decoded!.compressed).toBe(true);
expect(binToHex(decoded!.compactSignature)).toBe(binToHex(compact));
}
});
it("produces header 27+recid when uncompressed", () => {
for (const recoveryId of [0, 1, 2, 3]) {
const encoded = encodeMessageSignature(recoveryId, compact, false);
const decoded = decodeMessageSignature(encoded);
expect(decoded!.recoveryId).toBe(recoveryId);
expect(decoded!.compressed).toBe(false);
}
});
it("rejects a compact signature that is not 64 bytes", () => {
expect(() => encodeMessageSignature(0, new Uint8Array(63))).toThrow();
expect(() => encodeMessageSignature(0, new Uint8Array(65))).toThrow();
});
it("rejects a recovery id outside 0..3", () => {
expect(() => encodeMessageSignature(-1, compact)).toThrow();
expect(() => encodeMessageSignature(4, compact)).toThrow();
expect(() => encodeMessageSignature(1.5, compact)).toThrow();
});
it("rejects malformed signatures rather than guessing", () => {
expect(decodeMessageSignature("")).toBeUndefined();
expect(decodeMessageSignature("not base64 at all !!!")).toBeUndefined();
// 64 bytes — one short of a header plus signature.
expect(
decodeMessageSignature(encodeMessageSignature(0, compact).slice(0, 20)),
).toBeUndefined();
});
it("rejects header bytes outside the 27..34 range", () => {
for (const header of [0, 26, 35, 255]) {
const bytes = new Uint8Array(65);
bytes[0] = header;
const base64 = Buffer.from(bytes).toString("base64");
expect(decodeMessageSignature(base64)).toBeUndefined();
}
});
});
describe("signBitcoinMessage", () => {
it("round-trips through verification", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
expect(verifyMessageSignature("hello", signature, PUBLIC_KEY)).toBe(true);
expect(
verifyMessageSignatureForAddress("hello", signature, COMPRESSED_ADDRESS),
).toBe(true);
});
it("is deterministic (RFC 6979), so the same input gives the same bytes", () => {
expect(signBitcoinMessage("hello", PRIVATE_KEY)).toBe(
signBitcoinMessage("hello", PRIVATE_KEY),
);
});
it("declares compression per the option, selecting which address it proves", () => {
const compressed = signBitcoinMessage("hello", PRIVATE_KEY);
const uncompressed = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(decodeMessageSignature(compressed)!.compressed).toBe(true);
expect(decodeMessageSignature(uncompressed)!.compressed).toBe(false);
expect(messageSignatureAddress("hello", compressed)).toBe(
COMPRESSED_ADDRESS,
);
expect(messageSignatureAddress("hello", uncompressed)).toBe(
UNCOMPRESSED_ADDRESS,
);
expect(COMPRESSED_ADDRESS).not.toBe(UNCOMPRESSED_ADDRESS);
});
it("signs an empty message", () => {
const signature = signBitcoinMessage("", PRIVATE_KEY);
expect(
verifyMessageSignatureForAddress("", signature, COMPRESSED_ADDRESS),
).toBe(true);
});
it("fails loudly on an invalid private key", () => {
expect(() => signBitcoinMessage("hello", new Uint8Array(32))).toThrow();
});
});
describe("recoverMessageSigner", () => {
it("returns the key in the serialisation the header declares", () => {
const compressed = recoverMessageSigner(
"hello",
signBitcoinMessage("hello", PRIVATE_KEY),
);
expect(compressed!.compressed).toBe(true);
expect(compressed!.publicKey.length).toBe(33);
expect(binToHex(compressed!.publicKey)).toBe(binToHex(PUBLIC_KEY));
const uncompressed = recoverMessageSigner(
"hello",
signBitcoinMessage("hello", PRIVATE_KEY, { compressed: false }),
);
expect(uncompressed!.compressed).toBe(false);
expect(uncompressed!.publicKey.length).toBe(65);
expect(binToHex(uncompressed!.publicKey)).toBe(
binToHex(PUBLIC_KEY_UNCOMPRESSED),
);
});
it("returns undefined for a malformed signature", () => {
expect(recoverMessageSigner("hello", "garbage")).toBeUndefined();
});
it("returns undefined rather than a wrong key when recovery fails", () => {
// All-zero r/s cannot be recovered to a point.
const impossible = encodeMessageSignature(0, new Uint8Array(64), true);
expect(recoverMessageSigner("hello", impossible)).toBeUndefined();
});
});
describe("verifyMessageSignatureForAddress", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
it("accepts the address the signature actually proves", () => {
expect(
verifyMessageSignatureForAddress("hello", signature, COMPRESSED_ADDRESS),
).toBe(true);
});
it("rejects a signature presented for the wrong address", () => {
const other = cashaddr(
secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array,
);
expect(verifyMessageSignatureForAddress("hello", signature, other)).toBe(
false,
);
});
it("rejects a tampered message or signature", () => {
expect(
verifyMessageSignatureForAddress("hello!", signature, COMPRESSED_ADDRESS),
).toBe(false);
const raw = Buffer.from(signature, "base64");
raw[10] ^= 0x01;
expect(
verifyMessageSignatureForAddress(
"hello",
raw.toString("base64"),
COMPRESSED_ADDRESS,
),
).toBe(false);
});
it("accepts CashAddr without a prefix", () => {
const bare = COMPRESSED_ADDRESS.split(":")[1];
expect(bare).not.toContain(":");
expect(verifyMessageSignatureForAddress("hello", signature, bare)).toBe(
true,
);
});
it("accepts a legacy base58 address", () => {
const legacy = encodeBase58Address("p2pkh", hash160(PUBLIC_KEY));
expect(verifyMessageSignatureForAddress("hello", signature, legacy)).toBe(
true,
);
});
it("accepts a token-aware P2PKH address — same public key hash", () => {
const tokenAware = encodeCashAddress({
payload: hash160(PUBLIC_KEY),
prefix: "bitcoincash",
type: "p2pkhWithTokens",
}).address;
expect(
verifyMessageSignatureForAddress("hello", signature, tokenAware),
).toBe(true);
});
it("rejects a P2SH address — a script hash is not a public key hash", () => {
const p2sh = encodeCashAddress({
payload: hash160(PUBLIC_KEY),
prefix: "bitcoincash",
type: "p2sh",
}).address;
expect(verifyMessageSignatureForAddress("hello", signature, p2sh)).toBe(
false,
);
});
it("rejects nonsense addresses", () => {
for (const address of ["", "bitcoincash:qqqq", "not-an-address", "1234"]) {
expect(
verifyMessageSignatureForAddress("hello", signature, address),
).toBe(false);
}
});
// The regression that matters most. A signature made with an uncompressed
// header is a statement about a DIFFERENT address than the compressed one, so
// accepting it for the compressed address would let a proof of control over
// one address stand in for the other.
it("does not accept an uncompressed-header signature for the compressed address", () => {
const uncompressed = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(
verifyMessageSignatureForAddress(
"hello",
uncompressed,
UNCOMPRESSED_ADDRESS,
),
).toBe(true);
expect(
verifyMessageSignatureForAddress(
"hello",
uncompressed,
COMPRESSED_ADDRESS,
),
).toBe(false);
// ...and symmetrically.
expect(
verifyMessageSignatureForAddress(
"hello",
signature,
UNCOMPRESSED_ADDRESS,
),
).toBe(false);
});
});
describe("verifyMessageSignature (public key form)", () => {
it("answers 'did this key sign', so it accepts either serialisation", () => {
const compressed = signBitcoinMessage("hello", PRIVATE_KEY);
const uncompressed = signBitcoinMessage("hello", PRIVATE_KEY, {
compressed: false,
});
expect(verifyMessageSignature("hello", compressed, PUBLIC_KEY)).toBe(true);
expect(
verifyMessageSignature("hello", compressed, PUBLIC_KEY_UNCOMPRESSED),
).toBe(true);
expect(verifyMessageSignature("hello", uncompressed, PUBLIC_KEY)).toBe(
true,
);
expect(
verifyMessageSignature("hello", uncompressed, PUBLIC_KEY_UNCOMPRESSED),
).toBe(true);
});
it("rejects a different key, a tampered message, and garbage", () => {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
const otherKey = secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array;
expect(verifyMessageSignature("hello", signature, otherKey)).toBe(false);
expect(verifyMessageSignature("hello!", signature, PUBLIC_KEY)).toBe(false);
expect(verifyMessageSignature("hello", "garbage", PUBLIC_KEY)).toBe(false);
expect(verifyMessageSignature("hello", signature, new Uint8Array(33))).toBe(
false,
);
});
});
describe("checkSignMessageResponse", () => {
function success(overrides: Partial<SignMessageSuccess> = {}) {
const signature = signBitcoinMessage("hello", PRIVATE_KEY);
const response: SignMessageSuccess = {
action: RelayMsgAction.SignMessageResponse,
sequence: 1,
time: 0,
signature,
publicKey: binToHex(PUBLIC_KEY),
address: COMPRESSED_ADDRESS,
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
...overrides,
};
return response;
}
it("accepts a self-consistent response and returns the signer", () => {
const result = checkSignMessageResponse("hello", success());
expect(result.ok).toBe(true);
if (result.ok) {
expect(binToHex(result.signer.publicKey)).toBe(binToHex(PUBLIC_KEY));
}
});
it("accepts an upper-case hex public key", () => {
const result = checkSignMessageResponse(
"hello",
success({ publicKey: binToHex(PUBLIC_KEY).toUpperCase() }),
);
expect(result.ok).toBe(true);
});
it("rejects a response whose public key did not sign", () => {
const otherKey = secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array;
const result = checkSignMessageResponse(
"hello",
success({ publicKey: binToHex(otherKey) }),
);
expect(result.ok).toBe(false);
});
it("rejects a response whose address is not the signing key's", () => {
const otherAddress = cashaddr(
secp256k1.derivePublicKeyCompressed(
hexToBin("00".repeat(31) + "02"),
) as Uint8Array,
);
const result = checkSignMessageResponse(
"hello",
success({ address: otherAddress }),
);
expect(result.ok).toBe(false);
if (!result.ok) expect(result.reason).toContain("address");
});
it("rejects a malformed signature", () => {
const result = checkSignMessageResponse(
"hello",
success({ signature: "garbage" }),
);
expect(result.ok).toBe(false);
});
it("rejects a response for a different message", () => {
expect(checkSignMessageResponse("goodbye", success()).ok).toBe(false);
});
});
describe("request and response guards", () => {
const base = {
action: RelayMsgAction.SignMessageRequest,
sequence: 1,
time: 0,
message: "hello",
};
it("accepts a wallet-choice request with no key selection", () => {
expect(isSignMessageRequest(base)).toBe(true);
expect(signMessageRequestMode(base as never)).toBe(MODE_WALLET_CHOICE);
});
it("accepts a dapp-path request with both halves", () => {
const request = { ...base, path: "receive", addressIndex: 0 };
expect(isSignMessageRequest(request)).toBe(true);
expect(signMessageRequestMode(request)).toBe(MODE_DAPP_PATH);
});
it("rejects a request with only half the key selection", () => {
// Ambiguous between the two modes — guessing would sign with the wrong key.
expect(isSignMessageRequest({ ...base, path: "receive" })).toBe(false);
expect(isSignMessageRequest({ ...base, addressIndex: 0 })).toBe(false);
});
it("rejects wrong shapes", () => {
expect(isSignMessageRequest(undefined)).toBe(false);
expect(isSignMessageRequest({})).toBe(false);
expect(isSignMessageRequest({ ...base, message: 42 })).toBe(false);
expect(isSignMessageRequest({ ...base, sequence: "1" })).toBe(false);
expect(isSignMessageRequest({ ...base, action: RelayMsgAction.Ping })).toBe(
false,
);
expect(isSignMessageRequest({ ...base, path: 7, addressIndex: 0 })).toBe(
false,
);
});
it("distinguishes success from failure responses", () => {
const failure = {
action: RelayMsgAction.SignMessageResponse,
sequence: 1,
time: 0,
error: "User rejected",
};
expect(isSignMessageResponse(failure)).toBe(true);
expect(isSignMessageFailure(failure as never)).toBe(true);
const success = {
action: RelayMsgAction.SignMessageResponse,
sequence: 1,
time: 0,
signature: "sig",
publicKey: "02aa",
address: "bitcoincash:q...",
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
};
expect(isSignMessageResponse(success)).toBe(true);
expect(isSignMessageFailure(success as never)).toBe(false);
});
it("rejects a success response missing the key or address", () => {
// Both are required precisely so a dapp can always verify; a response
// without them cannot be checked and must not pass as one that can.
expect(
isSignMessageResponse({
action: RelayMsgAction.SignMessageResponse,
sequence: 1,
time: 0,
signature: "sig",
}),
).toBe(false);
});
});
describe("extension advertisement", () => {
it("advertises schemes and modes under the extension key", () => {
const advert = signMessageExtensionAdvertisement([
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
]);
expect(advert[SIGN_MESSAGE_EXTENSION]).toEqual({
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
});
});
it("gates on the mode, not just the extension's presence", () => {
const dappPathOnly = signMessageExtensionAdvertisement([MODE_DAPP_PATH]);
expect(peerSupportsSignMessage(dappPathOnly, MODE_DAPP_PATH)).toBe(true);
// The point of advertising modes: a dapp discovers this now rather than
// after showing the user a login button.
expect(peerSupportsSignMessage(dappPathOnly, MODE_WALLET_CHOICE)).toBe(
false,
);
});
it("gates on the scheme", () => {
const advert = signMessageExtensionAdvertisement([MODE_DAPP_PATH]);
expect(
peerSupportsSignMessage(advert, MODE_DAPP_PATH, "bip322" as never),
).toBe(false);
});
it("reports no support when the extension is absent", () => {
expect(peerSupportsSignMessage(undefined)).toBe(false);
expect(peerSupportsSignMessage({})).toBe(false);
expect(peerSignMessageInfo({})).toBeUndefined();
});
it("treats a detail-free advertisement as the original behaviour", () => {
// A wallet that advertised `sign_message: {}` before modes existed still
// supports a dapp-named path signed as BSM.
const info = peerSignMessageInfo({ [SIGN_MESSAGE_EXTENSION]: {} });
expect(info).toEqual({
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: [MODE_DAPP_PATH],
});
expect(
peerSupportsSignMessage(
{ [SIGN_MESSAGE_EXTENSION]: {} },
MODE_WALLET_CHOICE,
),
).toBe(false);
});
});

View file

@ -0,0 +1,701 @@
// 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
/**
* Message signing the `sign_message` hdwalletv1 extension.
*
* WHY THIS EXISTS
*
* Dapps need a portable proof that someone controls a key: identity
* verification, SIWX-style login, and publishing a signed statement on chain.
*
* The known workaround is a dummy transaction one input with a null outpoint,
* its prevout script set to the P2PKH of the key being proven, one OP_RETURN
* output carrying a server nonce, signed but never broadcast. That does work for
* *login*, because the dapp reconstructs the sighash and checks the signature in
* the input's unlocking script. Two things it does not do:
*
* 1. It is not portable. The signature is bound to a transaction, so verifying
* it means rebuilding that exact dummy transaction and knowing how it was
* serialised. You cannot publish it in an OP_RETURN and have a third party
* check it later holding only the message, the signature and an address.
*
* 2. It asks a wallet to sign a real transaction preimage. A wallet that does
* not verify for itself that the outpoint is null is one bug away from
* signing a genuine spend. The construction below cannot be abused that way:
* the magic prefix guarantees the digest can never coincide with a
* transaction sighash, which is the property that makes blind-signing a
* message categorically safer than blind-signing a dummy transaction.
*
* So this extension produces the standard "Bitcoin Signed Message" signature.
* BCH wallets kept Bitcoin's magic string verbatim, so a signature made here
* verifies in Electron Cash, Electrum and `bitcoin-cli verifymessage`. That
* compatibility IS the feature: the magic string and both compactSize length
* prefixes are load-bearing, and message-signing.compat.test.ts checks them
* against a real Electron Cash install rather than against our own beliefs.
*
* SCOPE
*
* This module owns the construction and the verification. Verification lives
* here because both sides and third parties must agree on it byte for byte.
*
* WHAT A SIGNATURE DOES NOT PROVE
*
* It proves the holder of a key signed that exact text. Nothing about freshness,
* and nothing about audience. A captured signature is replayable forever unless
* the verifier makes the text single-use, so a login flow MUST put a server
* nonce in the message and retire it after one use. No amount of care in this
* module can supply that; see docs/dapp.md.
*/
import type { RecoveryId } from "@bitauth/libauth";
import type { WcSignMessageRequest } from "@bch-wc2/interfaces";
import {
base64ToBin,
bigIntToCompactUint,
binToBase64,
binToHex,
decodeBase58Address,
decodeCashAddress,
encodeCashAddress,
hash160,
hash256,
secp256k1,
utf8ToBin,
} from "@bitauth/libauth";
import { PathName, ProtocolMessage, RelayMsgAction } from "./hdwalletv1.js";
/// Capability advertisement
/**
* Extension name advertised in `wallet_ready` under
* `session["hdwalletv1"].extensions`.
*
* A wallet that implements `WalletAdapter.signMessage` advertises this key, so a
* dapp can hide a login button instead of offering one that fails. Wallets that
* do not are unaffected.
*
* Note this extension is unlike the ones in docs/extensions.md § 3: its actions
* live in `RelayMsgAction` and are handled by the connection managers, rather
* than riding the generic `message` events. That is deliberate for a capability
* shipped by this library see docs/extensions.md § First-party extensions.
*/
export const SIGN_MESSAGE_EXTENSION = "sign_message" as const;
/**
* The only scheme defined today: the "Bitcoin Signed Message" construction, as
* produced by Electron Cash's Sign/Verify Message and Bitcoin Core's
* `signmessage`. Recoverable compact signature, base64 encoded.
*
* The field exists so a future scheme (BIP-322, say) can be negotiated rather
* than requiring a breaking change.
*/
export const SCHEME_BITCOIN_SIGNED_MESSAGE = "bitcoin_signed_message" as const;
export type MessageSignatureScheme = typeof SCHEME_BITCOIN_SIGNED_MESSAGE;
/**
* The dapp names the key: it sends `path` and `addressIndex`, which it can only
* do if it already holds the xpub for that path. Use when the dapp needs a
* signature from one specific address it already knows about.
*/
export const MODE_DAPP_PATH = "dapp_path" as const;
/**
* The wallet chooses the key and returns it. The dapp needs no xpub at all,
* which makes this the privacy-preserving option for pure identity checks, and
* the only one that works before any xpub has been shared.
*
* A wallet supporting this mode MUST choose deterministically, returning the
* same key for the same wallet every time. A dapp uses the returned address as a
* stable identity; a wallet that picks a fresh key per connection makes a
* returning user unrecognisable and breaks login.
*/
export const MODE_WALLET_CHOICE = "wallet_choice" as const;
export type MessageSigningMode =
| typeof MODE_DAPP_PATH
| typeof MODE_WALLET_CHOICE;
/** Handshake payload advertised under the `sign_message` extension key. */
export interface SignMessageExtensionInfo {
/** Signature schemes the wallet can produce. */
schemes: MessageSignatureScheme[];
/**
* Key-selection modes the wallet supports. Advertised separately from
* `schemes` because they are independent capabilities: a wallet may be able to
* sign with a dapp-named path but have no notion of a stable identity key. A
* dapp that checked only for the extension would discover that at request
* time, after showing the user a login button.
*/
modes: MessageSigningMode[];
}
/** The advertisement a wallet with these capabilities publishes. */
export function signMessageExtensionAdvertisement(
modes: MessageSigningMode[],
schemes: MessageSignatureScheme[] = [SCHEME_BITCOIN_SIGNED_MESSAGE],
): Record<string, SignMessageExtensionInfo> {
return { [SIGN_MESSAGE_EXTENSION]: { schemes, modes } };
}
/** Read a peer's `sign_message` advertisement, if any. */
export function peerSignMessageInfo(
extensions: Record<string, unknown> | undefined,
): SignMessageExtensionInfo | undefined {
const info = extensions?.[SIGN_MESSAGE_EXTENSION];
if (!info || typeof info !== "object") return undefined;
const { schemes, modes } = info as Partial<SignMessageExtensionInfo>;
return {
// Tolerate a peer that omits either list rather than throwing: an older
// wallet that advertised the extension with no detail still supports the
// original behaviour, which is a dapp-named path signed as BSM.
schemes: Array.isArray(schemes) ? schemes : [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: Array.isArray(modes) ? modes : [MODE_DAPP_PATH],
};
}
/**
* Whether a peer can serve this request shape.
*
* @param mode - Which key-selection mode the dapp intends to use.
*/
export function peerSupportsSignMessage(
extensions: Record<string, unknown> | undefined,
mode: MessageSigningMode = MODE_DAPP_PATH,
scheme: MessageSignatureScheme = SCHEME_BITCOIN_SIGNED_MESSAGE,
): boolean {
const info = peerSignMessageInfo(extensions);
if (!info) return false;
return info.schemes.includes(scheme) && info.modes.includes(mode);
}
/// Dapp -> Wallet
/**
* Ask the wallet to sign a plain message.
*
* Extends `WcSignMessageRequest` from `@bch-wc2/interfaces` the same interface
* wallets already implement for WalletConnect so this object can be handed
* straight to an existing WC2 `signMessage` handler. hdwalletv1 adds only the
* optional key selection, mirroring how `SignTransactionRequest` wraps
* `WcSignTransactionRequest` and adds `inputPaths`.
*/
export interface SignMessageRequest
extends ProtocolMessage, WcSignMessageRequest {
action: RelayMsgAction.SignMessageRequest;
sequence: number;
/**
* The exact message to sign, as the user should see it. Signed as UTF-8; the
* wallet must not trim, normalise or re-encode it, or the signature will not
* verify against what the dapp displayed.
*
* Inherited from WcSignMessageRequest; restated because it is load-bearing.
*/
message: string;
/**
* Optional dapp-supplied context for the wallet's prompt ("Sign in to
* example.com"). Inherited from WcSignMessageRequest.
*
* This is NOT signed and it is NOT trustworthy. A wallet must render it as
* clearly subordinate to `message`, or a dapp can caption a hostile message
* with a reassuring prompt. See docs/wallet.md.
*/
userPrompt?: string;
/**
* Which HD path signs. Same vocabulary as SignTransactionRequest.inputPaths.
* Omit together with `addressIndex` to let the wallet choose the key
* (MODE_WALLET_CHOICE).
*/
path?: PathName;
/** Address index within `path`. Omit together with `path`. */
addressIndex?: number;
/** Defaults to bitcoin_signed_message when absent. */
scheme?: MessageSignatureScheme;
}
/** Which key-selection mode a request is using. */
export function signMessageRequestMode(
request: Pick<SignMessageRequest, "path" | "addressIndex">,
): MessageSigningMode {
return request.path === undefined && request.addressIndex === undefined
? MODE_WALLET_CHOICE
: MODE_DAPP_PATH;
}
/// Wallet -> Dapp
interface SignMessageResponseBase extends ProtocolMessage {
action: RelayMsgAction.SignMessageResponse;
sequence: number;
}
export interface SignMessageSuccess extends SignMessageResponseBase {
/**
* Base64 recoverable compact signature (65 bytes decoded). This is exactly a
* `WcSignMessageResponse`, so a wallet's existing WalletConnect handler's
* return value drops in here unchanged.
*/
signature: string;
/**
* Public key that produced the signature, hex.
*
* Serialised the way the signature's header byte declares 33 bytes for a
* compressed header, 65 for an uncompressed one. Those two forms hash to
* DIFFERENT addresses, so a wallet that reports the wrong one is claiming a
* proof about an address it did not prove.
*/
publicKey: string;
/**
* CashAddr of `publicKey`, for display and for explorer-side verification.
*
* Required rather than optional: under MODE_WALLET_CHOICE this is the dapp's
* only way to learn which key answered, and it is the identity a dapp stores.
*/
address: string;
scheme: MessageSignatureScheme;
/**
* The path and index the wallet actually used, when it can express them.
* Echoed back so MODE_WALLET_CHOICE is transparent rather than opaque; omitted
* when the wallet signed with a key outside any advertised path.
*/
path?: PathName;
addressIndex?: number;
error?: undefined;
}
export interface SignMessageFailure extends SignMessageResponseBase {
/** Why the message was not signed. Non-empty. */
error: string;
}
/**
* A discriminated union rather than one shape with optional fields: narrowing on
* `error` is what stops a caller reading `.address` off a rejection and treating
* an empty string as an identity.
*/
export type SignMessageResponse = SignMessageSuccess | SignMessageFailure;
export function isSignMessageRequest(msg: unknown): msg is SignMessageRequest {
const m = msg as SignMessageRequest;
if (
!m ||
typeof m !== "object" ||
m.action !== RelayMsgAction.SignMessageRequest ||
typeof m.sequence !== "number" ||
typeof m.message !== "string"
) {
return false;
}
// Key selection is all-or-nothing. A request with only one half is ambiguous
// between the two modes, so reject it rather than guess.
const hasPath = m.path !== undefined;
const hasIndex = m.addressIndex !== undefined;
if (hasPath !== hasIndex) return false;
if (
hasPath &&
(typeof m.path !== "string" || typeof m.addressIndex !== "number")
) {
return false;
}
return true;
}
export function isSignMessageResponse(
msg: unknown,
): msg is SignMessageResponse {
const m = msg as SignMessageResponse;
if (
!m ||
typeof m !== "object" ||
m.action !== RelayMsgAction.SignMessageResponse ||
typeof m.sequence !== "number"
) {
return false;
}
if (isSignMessageFailure(m)) return true;
const s = m as SignMessageSuccess;
return (
typeof s.signature === "string" &&
typeof s.publicKey === "string" &&
typeof s.address === "string"
);
}
export function isSignMessageFailure(
msg: SignMessageResponse,
): msg is SignMessageFailure {
return typeof (msg as SignMessageFailure).error === "string";
}
/// Canonical hashing
/**
* The magic prefix. Bitcoin Cash wallets kept Bitcoin's string verbatim, so a
* signature made here verifies in Electron Cash, Electrum, and anything else
* implementing `verifymessage`. Changing this byte string breaks that
* compatibility, which is the entire point of the feature.
*
* Electron Cash's own definition, for reference (electroncash/bitcoin.py):
* b"\x18Bitcoin Signed Message:\n" + var_int(len(message)) + message
* where 0x18 is the compactSize length of the magic itself.
*/
const MESSAGE_MAGIC = "Bitcoin Signed Message:\n";
/** Length-prefix a byte string the way Bitcoin's message serialisation does. */
function compactSizePrefixed(bytes: Uint8Array): Uint8Array {
const prefix = bigIntToCompactUint(BigInt(bytes.length));
const out = new Uint8Array(prefix.length + bytes.length);
out.set(prefix, 0);
out.set(bytes, prefix.length);
return out;
}
/**
* Double-SHA256 of the magic-prefixed message the digest that actually gets
* signed.
*
* Both the magic and the message are compact-size length-prefixed. Without the
* prefixes, `"ab" + "c"` and `"a" + "bc"` would hash identically, so a
* signature over one message could be presented as a signature over another.
*/
export function bitcoinSignedMessageHash(message: string): Uint8Array {
const magic = compactSizePrefixed(utf8ToBin(MESSAGE_MAGIC));
const body = compactSizePrefixed(utf8ToBin(message));
const preimage = new Uint8Array(magic.length + body.length);
preimage.set(magic, 0);
preimage.set(body, magic.length);
return hash256(preimage);
}
/// Signature encoding
/** Encode a recoverable signature the way `signmessage` does. */
export function encodeMessageSignature(
recoveryId: number,
compactSignature: Uint8Array,
compressed = true,
): string {
if (compactSignature.length !== 64) {
throw new Error(
`Compact signature must be 64 bytes, got ${compactSignature.length}`,
);
}
if (!Number.isInteger(recoveryId) || recoveryId < 0 || recoveryId > 3) {
throw new Error(`Recovery id must be 0..3, got ${recoveryId}`);
}
// 27 = uncompressed base, +4 when the pubkey is compressed. Matches Bitcoin
// Core and Electron Cash so third-party verifiers accept it.
const header = 27 + recoveryId + (compressed ? 4 : 0);
const out = new Uint8Array(65);
out[0] = header;
out.set(compactSignature, 1);
return binToBase64(out);
}
export interface DecodedMessageSignature {
recoveryId: RecoveryId;
compactSignature: Uint8Array;
/**
* Whether the header declares a compressed public key. This is not cosmetic:
* the compressed and uncompressed serialisations of one key hash to two
* different addresses, so this bit decides which address the signature is a
* proof about.
*/
compressed: boolean;
}
/** Inverse of encodeMessageSignature. Returns undefined if malformed. */
export function decodeMessageSignature(
signature: string,
): DecodedMessageSignature | undefined {
let bytes: Uint8Array;
try {
const decoded = base64ToBin(signature);
if (typeof decoded === "string") return undefined;
bytes = decoded;
} catch {
return undefined;
}
if (bytes.length !== 65) return undefined;
const header = bytes[0];
if (header < 27 || header > 34) return undefined;
const compressed = header >= 31;
const recoveryId = header - 27 - (compressed ? 4 : 0);
// Narrow to libauth's RecoveryId union by checking rather than casting: the
// header range above already implies 0..3, but an explicit check means a
// future header change cannot silently hand secp256k1 an out-of-range id.
if (
recoveryId !== 0 &&
recoveryId !== 1 &&
recoveryId !== 2 &&
recoveryId !== 3
) {
return undefined;
}
return { recoveryId, compactSignature: bytes.slice(1), compressed };
}
/// Signing
export interface SignBitcoinMessageOptions {
/**
* Whether to declare a compressed public key, which selects which of the
* key's two addresses the signature proves control of. Defaults to true;
* essentially every modern wallet uses compressed keys.
*/
compressed?: boolean;
}
/**
* Produce a "Bitcoin Signed Message" signature over `message`.
*
* Takes the private key as an argument and never retains it this module holds
* no key material and has no state. It exists so that a wallet implementing
* `WalletAdapter.signMessage` calls one function instead of reassembling the
* magic string, both length prefixes and the header byte itself. Those bytes are
* what third-party verifiers check, and they are covered by this package's
* conformance test; a wallet that reimplements them is not.
*/
export function signBitcoinMessage(
message: string,
privateKey: Uint8Array,
options: SignBitcoinMessageOptions = {},
): string {
const { compressed = true } = options;
const hash = bitcoinSignedMessageHash(message);
const recoverable = secp256k1.signMessageHashRecoverableCompact(
privateKey,
hash,
);
if (typeof recoverable === "string") {
throw new Error(`Could not sign message: ${recoverable}`);
}
return encodeMessageSignature(
recoverable.recoveryId,
recoverable.signature,
compressed,
);
}
/// Recovery and verification
export interface RecoveredSigner {
/**
* The recovered key, serialised as the signature's header declares: 33 bytes
* when `compressed`, 65 when not.
*/
publicKey: Uint8Array;
compressed: boolean;
}
/**
* Recover the public key that signed `message`.
*
* Returns the key together with the serialisation its header declared, because
* the two are inseparable: the same key yields two different addresses in its
* compressed and uncompressed forms, and dropping the flag is how a signature
* proving control of one address gets accepted as proof of the other.
*
* Returns undefined when the signature is malformed or recovery fails, so a
* caller cannot mistake a broken signature for a valid one from an unexpected
* key.
*/
export function recoverMessageSigner(
message: string,
signature: string,
): RecoveredSigner | undefined {
const decoded = decodeMessageSignature(signature);
if (!decoded) return undefined;
const hash = bitcoinSignedMessageHash(message);
const recovered = decoded.compressed
? secp256k1.recoverPublicKeyCompressed(
decoded.compactSignature,
decoded.recoveryId,
hash,
)
: secp256k1.recoverPublicKeyUncompressed(
decoded.compactSignature,
decoded.recoveryId,
hash,
);
if (typeof recovered === "string") return undefined;
return { publicKey: recovered, compressed: decoded.compressed };
}
/** Fixed-length compare. Both values are public; this just avoids an early-exit
* habit leaking somewhere it would matter. */
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
/**
* The P2PKH CashAddr the signature is a proof about.
*
* This is the primary verification entry point for third parties, and the shape
* Ron's use case needs: pull a message and signature off an explorer, derive the
* address, compare it to the identity being claimed. Electron Cash exposes only
* an address-based `verify_message` for the same reason.
*/
export function messageSignatureAddress(
message: string,
signature: string,
prefix: CashAddrPrefix = "bitcoincash",
): string | undefined {
const recovered = recoverMessageSigner(message, signature);
if (!recovered) return undefined;
const encoded = encodeCashAddress({
payload: hash160(recovered.publicKey),
prefix,
throwErrors: false,
type: "p2pkh",
});
return typeof encoded === "string" ? undefined : encoded.address;
}
/** Networks whose prefix we will try when an address arrives without one. */
const CASHADDR_PREFIXES = ["bitcoincash", "bchtest", "bchreg"] as const;
/** A CashAddr network prefix, as libauth's encoder accepts it. */
export type CashAddrPrefix = (typeof CASHADDR_PREFIXES)[number];
/** Public-key-hash of a P2PKH address, in any encoding a user might paste. */
function addressPublicKeyHash(address: string): Uint8Array | undefined {
// Re-attach a prefix rather than decoding the bare form, so the `type` field
// does the p2pkh check for us instead of version-byte arithmetic here.
const candidates = address.includes(":")
? [address]
: CASHADDR_PREFIXES.map((prefix) => `${prefix}:${address}`);
for (const candidate of candidates) {
const decoded = decodeCashAddress(candidate);
if (typeof decoded === "string") continue;
// p2pkhWithTokens carries the same pubkey hash, so a token-aware address is
// still a valid statement about this key. p2sh is not: a script hash is not
// a public key hash, and no message signature can prove control of it.
return decoded.type === "p2pkh" || decoded.type === "p2pkhWithTokens"
? decoded.payload
: undefined;
}
// Legacy base58 — still what some explorers and older tools display.
const legacy = decodeBase58Address(address);
if (typeof legacy !== "string") {
// 0 = mainnet P2PKH, 111 = testnet P2PKH. Anything else (P2SH, WIF) cannot
// be the subject of a message signature.
return legacy.version === 0 || legacy.version === 111
? legacy.payload
: undefined;
}
return undefined;
}
/**
* Whether two P2PKH addresses denote the same key, across encodings.
*
* Compares decoded public key hashes, so prefixed CashAddr, bare CashAddr, the
* token-aware form and legacy base58 all compare equal when they describe the
* same key. Returns false if either side is not a P2PKH address.
*/
export function addressesEqual(a: string, b: string): boolean {
const hashA = addressPublicKeyHash(a);
const hashB = addressPublicKeyHash(b);
return hashA !== undefined && hashB !== undefined && bytesEqual(hashA, hashB);
}
/**
* Verify a signature against an address the Electron Cash-equivalent check,
* and the one to use for identity.
*
* Accepts CashAddr with or without a prefix, and legacy base58. Rejects P2SH:
* no message signature can prove control of a script hash.
*
* Proves only that the holder of the key behind `address` signed this exact
* message. Nothing about freshness see the note at the top of this file.
*/
export function verifyMessageSignatureForAddress(
message: string,
signature: string,
address: string,
): boolean {
const expected = addressPublicKeyHash(address);
if (!expected) return false;
const recovered = recoverMessageSigner(message, signature);
if (!recovered) return false;
return bytesEqual(hash160(recovered.publicKey), expected);
}
/**
* Verify a signature against an expected public key.
*
* Answers "did the holder of this key sign this message", and so accepts either
* serialisation a key is a key. That is NOT the same question as "does this
* prove control of address A", because one key has two addresses. For identity,
* use verifyMessageSignatureForAddress.
*/
export function verifyMessageSignature(
message: string,
signature: string,
expectedPublicKey: Uint8Array,
): boolean {
const recovered = recoverMessageSigner(message, signature);
if (!recovered) return false;
// Normalise both sides to the expected serialisation before comparing, so the
// answer does not depend on which form the signature's header happened to
// declare.
if (recovered.publicKey.length === expectedPublicKey.length) {
return bytesEqual(recovered.publicKey, expectedPublicKey);
}
const normalised =
expectedPublicKey.length === 33
? secp256k1.compressPublicKey(recovered.publicKey)
: secp256k1.uncompressPublicKey(recovered.publicKey);
if (typeof normalised === "string") return false;
return bytesEqual(normalised, expectedPublicKey);
}
/**
* Check a wallet's response is internally consistent before trusting any of it.
*
* A response asserts three things a signature, the key that made it, and that
* key's address and a dapp that reads `address` without checking it against
* the signature is trusting the wallet's arithmetic. Returns the recovered
* signer so a caller can go on to compare it with a key it derived itself.
*/
export function checkSignMessageResponse(
message: string,
response: SignMessageSuccess,
): { ok: true; signer: RecoveredSigner } | { ok: false; reason: string } {
const signer = recoverMessageSigner(message, response.signature);
if (!signer) {
return { ok: false, reason: "signature is malformed or does not recover" };
}
if (binToHex(signer.publicKey) !== response.publicKey.toLowerCase()) {
return {
ok: false,
reason: `response public key ${response.publicKey} is not the key that signed (${binToHex(signer.publicKey)})`,
};
}
if (
!verifyMessageSignatureForAddress(
message,
response.signature,
response.address,
)
) {
return {
ok: false,
reason: `response address ${response.address} is not the address of the signing key`,
};
}
return { ok: true, signer };
}

View file

@ -0,0 +1,394 @@
{
"_provenance": {
"generatedBy": "Electron Cash",
"electronCashVersion": "4.4.5",
"regenerate": "contrib/generate-message-signing-vectors.py",
"note": "Signatures, addresses and preimage hashes here are produced by Electron Cash, not by this repository. They are the external reference the sign_message extension must match. Signature bytes are NOT portable across implementations (Electron Cash and libauth derive the ECDSA nonce differently), so a conforming implementation must reproduce preimageHash exactly and must VERIFY these signatures — it will not reproduce them byte for byte."
},
"vectors": [
{
"name": "one/simple/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "hello",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "H/mzy5+5Fm+EooCaer1daNEoxZIySWMG5z0nltbDsTQkXLtxnEbYsx50nq6Jn82QZkuEPAeOX8AiOmb2sIdSs7M=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "cf0447ec85f0ce7150a257db32ebfcb7523dae17c36dbd1be598779fec0484f4",
"header": 31
},
{
"name": "one/login/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IEdtg5x1UJpFg3d80PClk5yRcvyIqkPn5EASet/kIy79TKXqKJhzCEbneDWwXznsoEiXNpVFkPzlbMZBNjY0Zic=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "c8deff901075b37d065a96600a39e780cd5b911e6b09bef3a44df1913ac00977",
"header": 32
},
{
"name": "one/empty/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IMxPYD+3770VTikCiJlecbBQlSQJ4tDQ+NoN+H8y98VkcUvzCYA55DiRM2dFtuy4gVmdI3g8vYIU9X+nejVIbBk=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "80e795d4a4caadd7047af389d9f7f220562feb6196032e2131e10563352c4bcc",
"header": 32
},
{
"name": "one/multibyte/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "Straße 日本語 🍅",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IH8QSqilrlNBn/xipxitaMZVeCdfVOIvgao+vMws/gGREFeq/R5h+d4gTZDnHFmnaMVWryCGPVmYwV6Q4HA8C1E=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "565da30e2c171eb852acd72f5db4dfe0e9ed19b1b93604434a86fcd02c1bdfe2",
"header": 32
},
{
"name": "one/multiline/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "line one\nline two\n",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IHT5+p+qLR6YjjCpzy9JasLO1y2+eoVcaTrB41wgNLAZbaPNgF/1DpKUnZ+ai3ewnKq/gNGSKiHoP1qL+rTXQhk=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "64bcb7ef4eac20670b4391d659189bb5db1e801dcc0c2fbf177a8abaef27d541",
"header": 32
},
{
"name": "one/whitespace/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": " padded ",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "HzcLD0+z3ALNsSkU3UH722aYTygCMi6Fz7wugvpBUfNOCB1oXxMAWYKcszFZYwHNSMRmAYa9Xw5N2DnbZrIOCB8=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "dd1eccdff8f4766ac4ca2bc5166ea4a93129fb43e8e628f33224b63d3ae17b6d",
"header": 31
},
{
"name": "one/compactsize_252/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "IC+K/+WtHc+Sbevjiwz/wE+sV+RDQDcnDTJdUuv3zSBgaGlO9C2kPEmmnOwUBNoiUitWvzAcdl5/esVgk7snC/A=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "b7b164ef991d52735c6bb888642ad7eb6b6939dc984a7fceff4376be041d142f",
"header": 32
},
{
"name": "one/compactsize_253/compressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": true,
"publicKey": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
"signature": "H+UwklDorwkmZoSR/lrnY2NHcBwR19Wp2ZRDEa/EWn+qbNy93haG6MtokLXI3kSxbNg+XE7F6NcE0gjGFKnVsaU=",
"cashaddr": "bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h",
"legacy": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH",
"preimageHash": "df167ad249ff5837e6acada677118b2ecc6757ab4cdade39caead99ef0220230",
"header": 31
},
{
"name": "one/simple/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "hello",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "G/mzy5+5Fm+EooCaer1daNEoxZIySWMG5z0nltbDsTQkXLtxnEbYsx50nq6Jn82QZkuEPAeOX8AiOmb2sIdSs7M=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "cf0447ec85f0ce7150a257db32ebfcb7523dae17c36dbd1be598779fec0484f4",
"header": 27
},
{
"name": "one/login/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HEdtg5x1UJpFg3d80PClk5yRcvyIqkPn5EASet/kIy79TKXqKJhzCEbneDWwXznsoEiXNpVFkPzlbMZBNjY0Zic=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "c8deff901075b37d065a96600a39e780cd5b911e6b09bef3a44df1913ac00977",
"header": 28
},
{
"name": "one/empty/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HMxPYD+3770VTikCiJlecbBQlSQJ4tDQ+NoN+H8y98VkcUvzCYA55DiRM2dFtuy4gVmdI3g8vYIU9X+nejVIbBk=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "80e795d4a4caadd7047af389d9f7f220562feb6196032e2131e10563352c4bcc",
"header": 28
},
{
"name": "one/multibyte/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "Straße 日本語 🍅",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HH8QSqilrlNBn/xipxitaMZVeCdfVOIvgao+vMws/gGREFeq/R5h+d4gTZDnHFmnaMVWryCGPVmYwV6Q4HA8C1E=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "565da30e2c171eb852acd72f5db4dfe0e9ed19b1b93604434a86fcd02c1bdfe2",
"header": 28
},
{
"name": "one/multiline/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "line one\nline two\n",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HHT5+p+qLR6YjjCpzy9JasLO1y2+eoVcaTrB41wgNLAZbaPNgF/1DpKUnZ+ai3ewnKq/gNGSKiHoP1qL+rTXQhk=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "64bcb7ef4eac20670b4391d659189bb5db1e801dcc0c2fbf177a8abaef27d541",
"header": 28
},
{
"name": "one/whitespace/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": " padded ",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "GzcLD0+z3ALNsSkU3UH722aYTygCMi6Fz7wugvpBUfNOCB1oXxMAWYKcszFZYwHNSMRmAYa9Xw5N2DnbZrIOCB8=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "dd1eccdff8f4766ac4ca2bc5166ea4a93129fb43e8e628f33224b63d3ae17b6d",
"header": 27
},
{
"name": "one/compactsize_252/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "HC+K/+WtHc+Sbevjiwz/wE+sV+RDQDcnDTJdUuv3zSBgaGlO9C2kPEmmnOwUBNoiUitWvzAcdl5/esVgk7snC/A=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "b7b164ef991d52735c6bb888642ad7eb6b6939dc984a7fceff4376be041d142f",
"header": 28
},
{
"name": "one/compactsize_253/uncompressed",
"privateKey": "0000000000000000000000000000000000000000000000000000000000000001",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": false,
"publicKey": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8",
"signature": "G+UwklDorwkmZoSR/lrnY2NHcBwR19Wp2ZRDEa/EWn+qbNy93haG6MtokLXI3kSxbNg+XE7F6NcE0gjGFKnVsaU=",
"cashaddr": "bitcoincash:qzgmyjle755g2v5kptrg02asx5f8k8fg55zdx7hd4l",
"legacy": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm",
"preimageHash": "df167ad249ff5837e6acada677118b2ecc6757ab4cdade39caead99ef0220230",
"header": 27
},
{
"name": "arbitrary/simple/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "hello",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "IJRZMoi3AsjiH0CPOhj/zRWKP08pEf2S6az/WON3MCPjA/OElOO7WkevxPEOiRJIEXlOmaxfcwJABcrN30ZDxis=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "cf0447ec85f0ce7150a257db32ebfcb7523dae17c36dbd1be598779fec0484f4",
"header": 32
},
{
"name": "arbitrary/login/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "H50xakGxLaL7N5O2tmYci2QrAMRD+f5qQagc+gCc9fLNEIVDPwvA/gqVa87EMaas1OXxauEuHStD5wUEKV4JkdM=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "c8deff901075b37d065a96600a39e780cd5b911e6b09bef3a44df1913ac00977",
"header": 31
},
{
"name": "arbitrary/empty/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "IChpxZELMRKQbs07z68fd/rh2uczOArbCf+TvdldC0T8LYzqzadA7ch5FEaBNMuuuslJ9sfOdqn7kpHykiZ9bWA=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "80e795d4a4caadd7047af389d9f7f220562feb6196032e2131e10563352c4bcc",
"header": 32
},
{
"name": "arbitrary/multibyte/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "Straße 日本語 🍅",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "H0pkdN6MFy5qujPr5YKG85/X+D8JnVHctpuuci7XvyyVQwU4RveZ8K1wJzVr12L5mQRIKZxG3miTkDCYyPCaF50=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "565da30e2c171eb852acd72f5db4dfe0e9ed19b1b93604434a86fcd02c1bdfe2",
"header": 31
},
{
"name": "arbitrary/multiline/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "line one\nline two\n",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "IPCkRcPcJ5BdBseCSKTElT8VEcFZLwPPqtEP9b79sphsMWSsHw6j9QrA58+jwTUijIycjlTPgifI3jdSJN6Y7gA=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "64bcb7ef4eac20670b4391d659189bb5db1e801dcc0c2fbf177a8abaef27d541",
"header": 32
},
{
"name": "arbitrary/whitespace/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": " padded ",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "H0lOT0KH3MtY5dTXY7HiBVj3o3bMB2tzV05SjUs9shMnUWAtyFyk/w9uTy4OJNUetwStY4hgZsZhwo8KiZgOt74=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "dd1eccdff8f4766ac4ca2bc5166ea4a93129fb43e8e628f33224b63d3ae17b6d",
"header": 31
},
{
"name": "arbitrary/compactsize_252/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "IBZqSx7Kshur6V+YED2A4twtzjHCrVOSln4WZQmZ60oLKfeiwGD4HnEqX/R8AZSXDsZZ6UUnZC6uvSSceTAMuts=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "b7b164ef991d52735c6bb888642ad7eb6b6939dc984a7fceff4376be041d142f",
"header": 32
},
{
"name": "arbitrary/compactsize_253/compressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": true,
"publicKey": "03d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235",
"signature": "H9scO+W8u2f0Kd/6LEgh4KnXlsFBBdPoX6US1ro25/pXeT07O2QpWobzJa//ELH7Ij0IRvcbzKCGtss21s9gM34=",
"cashaddr": "bitcoincash:qqdpc89rky8kw4cldfpc2s9r73ymdd7duupq8cys00",
"legacy": "13P4FEchog2d959bzvfUGSyQQwQM1ruFxL",
"preimageHash": "df167ad249ff5837e6acada677118b2ecc6757ab4cdade39caead99ef0220230",
"header": 31
},
{
"name": "arbitrary/simple/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "hello",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "HJRZMoi3AsjiH0CPOhj/zRWKP08pEf2S6az/WON3MCPjA/OElOO7WkevxPEOiRJIEXlOmaxfcwJABcrN30ZDxis=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "cf0447ec85f0ce7150a257db32ebfcb7523dae17c36dbd1be598779fec0484f4",
"header": 28
},
{
"name": "arbitrary/login/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "wizardconnect login nonce=8f3a21c0d4 issued=2026-08-06T10:00:00Z",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "G50xakGxLaL7N5O2tmYci2QrAMRD+f5qQagc+gCc9fLNEIVDPwvA/gqVa87EMaas1OXxauEuHStD5wUEKV4JkdM=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "c8deff901075b37d065a96600a39e780cd5b911e6b09bef3a44df1913ac00977",
"header": 27
},
{
"name": "arbitrary/empty/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "HChpxZELMRKQbs07z68fd/rh2uczOArbCf+TvdldC0T8LYzqzadA7ch5FEaBNMuuuslJ9sfOdqn7kpHykiZ9bWA=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "80e795d4a4caadd7047af389d9f7f220562feb6196032e2131e10563352c4bcc",
"header": 28
},
{
"name": "arbitrary/multibyte/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "Straße 日本語 🍅",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "G0pkdN6MFy5qujPr5YKG85/X+D8JnVHctpuuci7XvyyVQwU4RveZ8K1wJzVr12L5mQRIKZxG3miTkDCYyPCaF50=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "565da30e2c171eb852acd72f5db4dfe0e9ed19b1b93604434a86fcd02c1bdfe2",
"header": 27
},
{
"name": "arbitrary/multiline/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "line one\nline two\n",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "HPCkRcPcJ5BdBseCSKTElT8VEcFZLwPPqtEP9b79sphsMWSsHw6j9QrA58+jwTUijIycjlTPgifI3jdSJN6Y7gA=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "64bcb7ef4eac20670b4391d659189bb5db1e801dcc0c2fbf177a8abaef27d541",
"header": 28
},
{
"name": "arbitrary/whitespace/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": " padded ",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "G0lOT0KH3MtY5dTXY7HiBVj3o3bMB2tzV05SjUs9shMnUWAtyFyk/w9uTy4OJNUetwStY4hgZsZhwo8KiZgOt74=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "dd1eccdff8f4766ac4ca2bc5166ea4a93129fb43e8e628f33224b63d3ae17b6d",
"header": 27
},
{
"name": "arbitrary/compactsize_252/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "HBZqSx7Kshur6V+YED2A4twtzjHCrVOSln4WZQmZ60oLKfeiwGD4HnEqX/R8AZSXDsZZ6UUnZC6uvSSceTAMuts=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "b7b164ef991d52735c6bb888642ad7eb6b6939dc984a7fceff4376be041d142f",
"header": 28
},
{
"name": "arbitrary/compactsize_253/uncompressed",
"privateKey": "c96d9a1f7f1a3e5b2d84f0a7c31e6b90d5427ac8fe13b6420d9e8a7c5b3f1d2e",
"message": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"compressed": false,
"publicKey": "04d1fc29278766711e70ba41686072cde6474a09665766af7e052a75a0e9543235347b7e6d7bb45cc68d5a425c299dab1bec6fb2922253b601d1fb61b48e8ddbcd",
"signature": "G9scO+W8u2f0Kd/6LEgh4KnXlsFBBdPoX6US1ro25/pXeT07O2QpWobzJa//ELH7Ij0IRvcbzKCGtss21s9gM34=",
"cashaddr": "bitcoincash:qpk7g48f238ds9ytw7ctmk4d6ka0wfclnvvpnw7y38",
"legacy": "1B24Bg3Sgo86ssBAiEUvJVMVY7SmetkwyD",
"preimageHash": "df167ad249ff5837e6acada677118b2ecc6757ab4cdade39caead99ef0220230",
"header": 27
}
]
}

View file

@ -0,0 +1,219 @@
// 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
/**
* Conformance against Electron Cash, using vectors Electron Cash produced.
*
* message-signing.test.ts checks the construction against an independent
* reconstruction of the spec which catches a coding mistake but not a
* misreading of the spec. These vectors close that gap: every hash, signature
* and address here came out of a real Electron Cash install (see
* contrib/generate-message-signing-vectors.py), so agreeing with them means
* agreeing with the software users will actually verify in.
*
* Note we can only VERIFY Electron Cash's signatures, not reproduce them: it and
* libauth derive the ECDSA nonce differently, so the signature bytes differ even
* though both are valid. The portable, reproducible quantity is the preimage
* hash, and that is asserted byte for byte.
*
* For the live round trip our signatures fed to Electron Cash's own verifier
* see message-signing.compat.test.ts.
*/
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { binToHex, hexToBin } from "@bitauth/libauth";
import {
bitcoinSignedMessageHash,
decodeMessageSignature,
messageSignatureAddress,
recoverMessageSigner,
signBitcoinMessage,
verifyMessageSignature,
verifyMessageSignatureForAddress,
} from "./message-signing.js";
interface Vector {
name: string;
privateKey: string;
message: string;
compressed: boolean;
publicKey: string;
signature: string;
cashaddr: string;
legacy: string;
preimageHash: string;
header: number;
}
const fixture = JSON.parse(
readFileSync(
new URL("./message-signing.vectors.json", import.meta.url),
"utf8",
),
) as {
_provenance: { electronCashVersion: string };
vectors: Vector[];
};
const VECTORS = fixture.vectors;
describe("Electron Cash vector fixture", () => {
it("is present and records its provenance", () => {
// Guards against a truncated or half-written regeneration silently reducing
// this file to a no-op.
expect(fixture._provenance.electronCashVersion).toMatch(/^\d+\.\d+/);
expect(VECTORS.length).toBeGreaterThanOrEqual(16);
});
it("covers both compression forms and the compactSize boundary", () => {
expect(VECTORS.some((v) => v.compressed)).toBe(true);
expect(VECTORS.some((v) => !v.compressed)).toBe(true);
expect(VECTORS.some((v) => v.message.length === 252)).toBe(true);
expect(VECTORS.some((v) => v.message.length === 253)).toBe(true);
expect(VECTORS.some((v) => v.message === "")).toBe(true);
});
});
describe.each(VECTORS)("Electron Cash vector: $name", (vector) => {
it("our preimage hash matches Electron Cash's byte for byte", () => {
// The single most load-bearing assertion in this package: it pins the magic
// string and both compactSize prefixes against the reference implementation.
expect(binToHex(bitcoinSignedMessageHash(vector.message))).toBe(
vector.preimageHash,
);
});
it("recovers the public key Electron Cash signed with, in the declared form", () => {
const recovered = recoverMessageSigner(vector.message, vector.signature);
expect(recovered).toBeDefined();
expect(recovered!.compressed).toBe(vector.compressed);
expect(binToHex(recovered!.publicKey)).toBe(vector.publicKey);
expect(recovered!.publicKey.length).toBe(vector.compressed ? 33 : 65);
});
it("decodes the header Electron Cash wrote", () => {
const decoded = decodeMessageSignature(vector.signature);
expect(decoded).toBeDefined();
expect(decoded!.compressed).toBe(vector.compressed);
expect(27 + decoded!.recoveryId + (decoded!.compressed ? 4 : 0)).toBe(
vector.header,
);
});
it("verifies against the CashAddr and the legacy address", () => {
expect(
verifyMessageSignatureForAddress(
vector.message,
vector.signature,
vector.cashaddr,
),
).toBe(true);
expect(
verifyMessageSignatureForAddress(
vector.message,
vector.signature,
vector.legacy,
),
).toBe(true);
// ...and without the CashAddr prefix, the form users paste most often.
expect(
verifyMessageSignatureForAddress(
vector.message,
vector.signature,
vector.cashaddr.split(":")[1],
),
).toBe(true);
});
it("derives the same address Electron Cash derived", () => {
expect(messageSignatureAddress(vector.message, vector.signature)).toBe(
vector.cashaddr,
);
});
it("verifies against the expected public key", () => {
expect(
verifyMessageSignature(
vector.message,
vector.signature,
hexToBin(vector.publicKey),
),
).toBe(true);
});
it("our own signature proves the same address as Electron Cash's", () => {
// Both implementations produce different bytes; what must agree is which
// address the resulting proof is about.
const ours = signBitcoinMessage(
vector.message,
hexToBin(vector.privateKey),
{
compressed: vector.compressed,
},
);
expect(messageSignatureAddress(vector.message, ours)).toBe(vector.cashaddr);
expect(
verifyMessageSignatureForAddress(vector.message, ours, vector.cashaddr),
).toBe(true);
});
it("rejects a flipped signature byte and an altered message", () => {
const raw = Buffer.from(vector.signature, "base64");
raw[10] ^= 0x01;
expect(
verifyMessageSignatureForAddress(
vector.message,
raw.toString("base64"),
vector.cashaddr,
),
).toBe(false);
expect(
verifyMessageSignatureForAddress(
`${vector.message}x`,
vector.signature,
vector.cashaddr,
),
).toBe(false);
});
});
describe("compression is not interchangeable", () => {
it("a signature never verifies for the other form's address", () => {
// Electron Cash signed the same message with the same key in both forms, so
// these pair up: two valid signatures, two different addresses, and neither
// is a proof about the other's.
const byKeyAndMessage = new Map<string, Vector[]>();
for (const vector of VECTORS) {
const key = `${vector.privateKey}|${vector.message}`;
byKeyAndMessage.set(key, [...(byKeyAndMessage.get(key) ?? []), vector]);
}
let pairs = 0;
for (const group of byKeyAndMessage.values()) {
const compressed = group.find((v) => v.compressed);
const uncompressed = group.find((v) => !v.compressed);
if (!compressed || !uncompressed) continue;
pairs++;
expect(compressed.cashaddr).not.toBe(uncompressed.cashaddr);
expect(
verifyMessageSignatureForAddress(
compressed.message,
compressed.signature,
uncompressed.cashaddr,
),
).toBe(false);
expect(
verifyMessageSignatureForAddress(
uncompressed.message,
uncompressed.signature,
compressed.cashaddr,
),
).toBe(false);
}
expect(pairs).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,14 @@
// 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 { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.compat.test.ts"],
// Every assertion spawns a full Electron Cash process.
testTimeout: 120000,
hookTimeout: 120000,
},
});

View file

@ -6,6 +6,11 @@ import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
exclude: ["**/*.integration.test.ts", "**/node_modules/**"],
// *.compat.test.ts shells out to Electron Cash — see vitest.compat.config.ts.
exclude: [
"**/*.integration.test.ts",
"**/*.compat.test.ts",
"**/node_modules/**",
],
},
});

View file

@ -20,12 +20,58 @@ import {
ProtocolMessage,
PROTOCOL_NAME,
childIndexOfPathName,
checkSignMessageResponse,
isHdwalletv1Session,
isSignMessageFailure,
peerSupportsSignMessage,
MODE_DAPP_PATH,
SCHEME_BITCOIN_SIGNED_MESSAGE,
binToHex,
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "@wizardconnect/core";
import type { PathXpub, DappRelayResult } from "@wizardconnect/core";
import type {
PathXpub,
DappRelayResult,
MessageSignatureScheme,
MessageSigningMode,
PathName,
SignMessageRequest,
SignMessageResponse,
SignMessageSuccess,
} from "@wizardconnect/core";
/**
* Whether the dapp itself confirmed the signer is the key it asked for.
*
* `checked: false` does not mean the signature is unverified it always is. It
* means there was no dapp-chosen key to compare against, so the proven address is
* the wallet's choice rather than the dapp's selection. A dapp storing an identity
* should care about the difference.
*/
export type MessageKeyBinding =
| { checked: true; path: PathName; addressIndex: number }
| {
checked: false;
reason: "wallet_chose_key" | "path_not_derivable";
};
/** A sign_message result this library has already verified. */
export interface VerifiedMessageSignature {
/** The exact message that was signed — store this, not a reconstruction. */
message: string;
/** Base64 signature. Portable: any third party can verify it. */
signature: string;
/** Hex public key that signed, in the serialisation its header declares. */
publicKey: string;
/** CashAddr of the signing key — the identity this proves control of. */
address: string;
scheme: MessageSignatureScheme;
/** Path and index the wallet reported using, when it said. */
path?: PathName;
addressIndex?: number;
keyBinding: MessageKeyBinding;
}
import { DappPubkeyStateManager } from "./pubkey-state-manager.js";
import {
type SessionStorage,
@ -87,6 +133,26 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
/** Timestamp (ms) of the last pong or wallet_ready received. Used for liveness detection. */
private lastPongTime: number = 0;
private sessionPaths: PathXpub[] = [];
/**
* hdwalletv1 extensions the wallet advertised in wallet_ready. Retained so a
* dapp can ask what the wallet supports before offering a feature. Replaced on
* every wallet_ready, so reconnecting to a wallet whose capabilities changed
* reflects the new set rather than a stale one.
*/
private sessionExtensions: Record<string, unknown> = {};
/**
* In-flight sign_message requests, keyed by sequence. Separate from
* pendingSignatureRequests because the two settle with different types; one
* shared map would force a union and lose that.
*/
private pendingSignMessageRequests = new Map<
number,
{
request: SignMessageRequest;
resolve: (r: SignMessageSuccess) => void;
reject: (e: Error) => void;
}
>();
private pendingSignatureRequests = new Map<
number,
{
@ -270,6 +336,15 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.pendingSignatureRequests.delete(sequence);
handlers.reject(new Error(reason ?? "Sign request cancelled"));
}
// Sequences come from one counter, so a cancel names exactly one request and
// it may be either kind.
const messageHandlers = this.pendingSignMessageRequests.get(sequence);
if (messageHandlers) {
this.pendingSignMessageRequests.delete(sequence);
messageHandlers.reject(
new Error(reason ?? "Sign message request cancelled"),
);
}
if (!this.conn) return;
const msg: SignCancelMessage = {
action: RelayMsgAction.SignCancel,
@ -281,6 +356,142 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.emit("messagesent", msg);
}
/**
* Whether the connected wallet advertised the `sign_message` extension, for the
* mode and scheme this dapp intends to use. Lets a dapp hide a login button
* rather than offer one that fails.
*
* Defaults to asking about MODE_DAPP_PATH. Pass MODE_WALLET_CHOICE if the dapp
* wants the wallet to pick the key those are separate capabilities and a
* wallet may implement only the first.
*/
walletSupportsSignMessage(
mode: MessageSigningMode = MODE_DAPP_PATH,
scheme: MessageSignatureScheme = SCHEME_BITCOIN_SIGNED_MESSAGE,
): boolean {
return peerSupportsSignMessage(this.sessionExtensions, mode, scheme);
}
/**
* Ask the wallet to sign a plain message, proving control of a key.
*
* Resolves only after this library has verified the result, so a caller never
* has to trust the wallet's arithmetic:
*
* - the signature recovers over the message that was actually sent;
* - the returned public key is the key that signed it;
* - the returned address is that key's address;
* - and, when the dapp named a path it can derive, the signer is exactly the
* key the dapp asked for.
*
* Anything inconsistent rejects. `keyBinding` on the result reports whether the
* last check was performed, because "the wallet chose a key" and "this is the
* key I asked for" are different claims and only one of them is an identity the
* dapp selected.
*
* Omit `path` and `addressIndex` to let the wallet choose the key no xpub
* required, which is the privacy-preserving option for a pure identity check.
* Check walletSupportsSignMessage(MODE_WALLET_CHOICE) first.
*
* REPLAY: the signature proves control of a key over that exact text, with no
* freshness and no audience. For login, put a single-use server nonce in
* `message` and retire it after one use, or a captured signature logs someone in
* forever. This library cannot enforce that see docs/dapp.md.
*
* Cancellation works like signTransaction: pass an AbortSignal.
*/
async signMessage(
request: Pick<SignMessageRequest, "message"> &
Partial<
Pick<
SignMessageRequest,
"path" | "addressIndex" | "scheme" | "userPrompt"
>
>,
options?: { signal?: AbortSignal },
): Promise<VerifiedMessageSignature> {
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
if ((request.path === undefined) !== (request.addressIndex === undefined)) {
throw new Error(
"[wizardconnect/dapp] signMessage needs both path and addressIndex, or neither",
);
}
const sequence = this.nextSequence();
const fullRequest: SignMessageRequest = {
action: RelayMsgAction.SignMessageRequest,
time: Math.floor(Date.now() / 1000),
sequence,
...request,
};
const responsePromise = this.sendSignMessageRequest(fullRequest);
if (!options?.signal) {
return this.verifySignMessageResponse(fullRequest, await responsePromise);
}
// Suppress unhandled rejection — the abort path rejects separately.
responsePromise.catch(() => {});
const response = await new Promise<SignMessageSuccess>(
(resolve, reject) => {
const onAbort = () => {
const reason =
options.signal!.reason instanceof Error
? options.signal!.reason.message
: typeof options.signal!.reason === "string"
? options.signal!.reason
: "Sign message request cancelled";
this.sendSignCancel(sequence, reason).catch(() => {});
reject(new DOMException(reason, "AbortError"));
};
if (options.signal!.aborted) {
onAbort();
return;
}
options.signal!.addEventListener("abort", onAbort, { once: true });
responsePromise
.then(resolve)
.catch(reject)
.finally(() => {
options.signal!.removeEventListener("abort", onAbort);
});
},
);
return this.verifySignMessageResponse(fullRequest, response);
}
/**
* Send a sign_message request and wait for the wallet's raw response, without
* verifying it. Prefer signMessage(), which verifies.
*/
async sendSignMessageRequest(
request: SignMessageRequest,
): Promise<SignMessageSuccess> {
if (!this.conn) throw new Error("[wizardconnect/dapp] Not connected");
return new Promise<SignMessageSuccess>((resolve, reject) => {
this.pendingSignMessageRequests.set(request.sequence, {
request,
resolve,
reject,
});
this.conn!.relay(request)
.then(() => {
this.emit("messagesent", request);
})
.catch((err) => {
this.pendingSignMessageRequests.delete(request.sequence);
reject(err instanceof Error ? err : new Error(String(err)));
});
});
}
/**
* Send a disconnect message to the wallet (courtesy notification).
* The caller is responsible for calling dappRelay.cleanup() afterwards.
@ -489,6 +700,9 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
case RelayMsgAction.SignTransactionResponse:
this.handleSignTransactionResponse(msg as SignTransactionResponse);
break;
case RelayMsgAction.SignMessageResponse:
this.handleSignMessageResponse(msg as SignMessageResponse);
break;
case RelayMsgAction.Disconnect:
this.handleRemoteDisconnect(msg as DisconnectMessage);
break;
@ -572,6 +786,7 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
// Store raw paths for getSessionPaths() and xpub nodes for derivation
this.sessionPaths = [...sessionData.paths];
this.sessionExtensions = { ...(sessionData.extensions ?? {}) };
for (const pathInfo of sessionData.paths) {
const decoded = decodeHdPublicKey(pathInfo.xpub);
if (typeof decoded === "string") {
@ -614,6 +829,21 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
}
}
// Same for pending sign_message requests. The wallet dedups on sequence, so a
// resend of one the user is still looking at will not prompt them twice.
if (this.pendingSignMessageRequests.size > 0) {
const now = Math.floor(Date.now() / 1000);
for (const [, entry] of this.pendingSignMessageRequests) {
const refreshed = { ...entry.request, time: now };
this.conn!.relay(refreshed)
.then(() => this.emit("messagesent", refreshed))
.catch((err) => {
this.pendingSignMessageRequests.delete(entry.request.sequence);
entry.reject(err instanceof Error ? err : new Error(String(err)));
});
}
}
// Auto-persist wallet identity and xpub paths to session storage
if (this.sessionOptions) {
const sessionUpdate: Partial<StoredSession> = {
@ -632,6 +862,99 @@ export class DappConnectionManager extends EventEmitter<DappConnectionManagerEve
this.startPingInterval();
}
/**
* Check a wallet's sign_message response before handing it to the caller.
*
* Throws rather than returning a flag for anything that indicates the response
* is not what it claims: a caller that wanted a proof and got an inconsistent
* one has no use for it, and returning it with `verified: false` invites being
* ignored.
*/
private verifySignMessageResponse(
request: SignMessageRequest,
response: SignMessageSuccess,
): VerifiedMessageSignature {
const consistency = checkSignMessageResponse(request.message, response);
if (!consistency.ok) {
throw new Error(
`[wizardconnect/dapp] Wallet's sign_message response is inconsistent: ${consistency.reason}`,
);
}
const signer = consistency.signer;
let keyBinding: MessageKeyBinding;
if (request.path === undefined || request.addressIndex === undefined) {
// Nothing was requested, so there is nothing to bind to. The address in the
// result is the identity, and it is proven — it was simply the wallet's
// choice, not the dapp's.
keyBinding = { checked: false, reason: "wallet_chose_key" };
} else {
const childIndex = childIndexOfPathName(request.path);
if (childIndex === undefined) {
// An extension path (e.g. stealth_scan): the dapp has no derivation rule
// for it, so it cannot confirm the key. Say so instead of implying it did.
keyBinding = { checked: false, reason: "path_not_derivable" };
} else {
const expected = this.getPubkey(
childIndex,
BigInt(request.addressIndex),
);
if (!expected) {
throw new Error(
`[wizardconnect/dapp] No xpub for path "${request.path}" yet, so the ` +
`signer cannot be checked — wait for wallet_ready before calling signMessage`,
);
}
// Exact bytes, not a point comparison: the wallet must answer with the
// compressed key this dapp derived, because the uncompressed form of the
// same key is a different address and would be a proof about something
// the dapp did not ask about.
if (binToHex(signer.publicKey) !== binToHex(expected)) {
throw new Error(
`[wizardconnect/dapp] Wallet signed with ${binToHex(signer.publicKey)}, ` +
`but path "${request.path}"/${request.addressIndex} is ${binToHex(expected)}`,
);
}
keyBinding = {
checked: true,
path: request.path,
addressIndex: request.addressIndex,
};
}
}
return {
message: request.message,
signature: response.signature,
publicKey: response.publicKey,
address: response.address,
scheme: response.scheme,
...(response.path !== undefined ? { path: response.path } : {}),
...(response.addressIndex !== undefined
? { addressIndex: response.addressIndex }
: {}),
keyBinding,
};
}
private handleSignMessageResponse(response: SignMessageResponse): void {
const handlers = this.pendingSignMessageRequests.get(response.sequence);
if (!handlers) {
console.warn(
"[wizardconnect/dapp] No pending sign_message request for sequence:",
response.sequence,
);
return;
}
this.pendingSignMessageRequests.delete(response.sequence);
if (isSignMessageFailure(response)) {
handlers.reject(new Error(response.error));
return;
}
handlers.resolve(response);
}
private handleSignTransactionResponse(
response: SignTransactionResponse,
): void {

View file

@ -6,6 +6,8 @@ export { DappConnectionManager } from "./dapp-connection-manager.js";
export type {
DappConnectionManagerEvents,
DappSessionOptions,
MessageKeyBinding,
VerifiedMessageSignature,
} from "./dapp-connection-manager.js";
export { DappPubkeyStateManager } from "./pubkey-state-manager.js";
export {

View file

@ -0,0 +1,543 @@
// 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
/**
* Dapp-side behaviour of the `sign_message` extension.
*
* The point of these tests is that signMessage() does not hand back whatever the
* wallet said. A wallet on the other end of a relay can return a signature from a
* different key, an address that is not that key's, or a signature over other
* text; an integrator who trusts the response has an identity check that does not
* check anything. Every one of those cases must reject here.
*/
import { describe, it, expect, vi } from "vitest";
import {
binToHex,
deriveHdPath,
deriveHdPrivateNodeChild,
deriveHdPrivateNodeFromSeed,
deriveHdPublicNode,
encodeHdPublicKey,
hexToBin,
secp256k1,
} from "@bitauth/libauth";
import {
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
PROTOCOL_NAME,
RelayMsgAction,
SCHEME_BITCOIN_SIGNED_MESSAGE,
SIGN_MESSAGE_EXTENSION,
messageSignatureAddress,
signBitcoinMessage,
} from "@wizardconnect/core";
import type {
ProtocolMessage,
SignMessageRequest,
SignMessageSuccess,
WalletReadyMessage,
} from "@wizardconnect/core";
import { DappConnectionManager } from "./dapp-connection-manager.js";
// A wallet whose keys the dapp can actually derive: the xpub below is the public
// node for m/0, so getPubkey(0, i) matches the private child the "wallet" signs
// with. Without that the binding check would be untestable.
const SEED = new Uint8Array(32).fill(0x2a);
const MASTER = deriveHdPrivateNodeFromSeed(SEED);
const RECEIVE_NODE = deriveHdPath(MASTER, "m/0") as never;
function receiveXpub(): string {
const result = encodeHdPublicKey({
node: deriveHdPublicNode(RECEIVE_NODE),
network: "mainnet",
});
if (typeof result === "string") throw new Error(result);
return result.hdPublicKey;
}
/** Private key of receive/index, as the wallet would use. */
function receivePrivateKey(index: number): Uint8Array {
return deriveHdPrivateNodeChild(RECEIVE_NODE, index).privateKey;
}
function receivePublicKey(index: number): Uint8Array {
return secp256k1.derivePublicKeyCompressed(
receivePrivateKey(index),
) as Uint8Array;
}
function makeMockClient() {
const listeners = new Map<string, ((...args: never[]) => void)[]>();
const relayed: ProtocolMessage[] = [];
return {
relayed,
on(event: string, fn: (...args: never[]) => void) {
if (!listeners.has(event)) listeners.set(event, []);
listeners.get(event)!.push(fn);
},
emit(event: string, ...args: never[]) {
for (const fn of listeners.get(event) ?? []) fn(...args);
},
relay: vi.fn(async (msg: ProtocolMessage) => {
relayed.push(msg);
}),
setPeerCapabilities: vi.fn(),
isKeyExchangeComplete: () => true,
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
type MockClient = ReturnType<typeof makeMockClient>;
function makeWalletReady(
extensions?: Record<string, unknown>,
): WalletReadyMessage {
return {
action: RelayMsgAction.WalletReady,
wallet_name: "Test Wallet",
wallet_icon: "",
public_key: "aa".repeat(32),
secret: "bb".repeat(16),
supported_protocols: [PROTOCOL_NAME],
dapp_discovered: true,
session: {
[PROTOCOL_NAME]: {
paths: [{ name: "receive", xpub: receiveXpub() }],
...(extensions ? { extensions } : {}),
},
},
time: Math.floor(Date.now() / 1000),
};
}
const SIGN_MESSAGE_ADVERT = {
[SIGN_MESSAGE_EXTENSION]: {
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
},
};
/** Connect a manager to a mock client and complete the handshake. */
async function connected(extensions = SIGN_MESSAGE_ADVERT) {
const mgr = new DappConnectionManager();
const client = makeMockClient();
mgr.updateConnection(client as never, { status: "connected" });
client.emit("message", makeWalletReady(extensions) as never);
await new Promise((r) => setTimeout(r, 10));
return { mgr, client };
}
/** The sign_message_request the dapp put on the wire. */
function sentRequest(client: MockClient): SignMessageRequest {
const request = client.relayed.find(
(msg) => msg.action === RelayMsgAction.SignMessageRequest,
);
if (!request) throw new Error("no sign_message_request was relayed");
return request as SignMessageRequest;
}
/** Build the response an honest wallet would send for a relayed request. */
function honestResponse(
request: SignMessageRequest,
options: { index?: number; privateKey?: Uint8Array } = {},
): SignMessageSuccess {
const index = options.index ?? request.addressIndex ?? 0;
const privateKey = options.privateKey ?? receivePrivateKey(index);
const signature = signBitcoinMessage(request.message, privateKey);
const publicKey = secp256k1.derivePublicKeyCompressed(
privateKey,
) as Uint8Array;
return {
action: RelayMsgAction.SignMessageResponse,
sequence: request.sequence,
time: Math.floor(Date.now() / 1000),
signature,
publicKey: binToHex(publicKey),
address: messageSignatureAddress(request.message, signature)!,
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
};
}
describe("walletSupportsSignMessage", () => {
it("is false before wallet_ready", () => {
const mgr = new DappConnectionManager();
expect(mgr.walletSupportsSignMessage()).toBe(false);
});
it("is false when the wallet does not advertise the extension", async () => {
const { mgr } = await connected({});
expect(mgr.walletSupportsSignMessage()).toBe(false);
});
it("is true for an advertised mode and false for an unadvertised one", async () => {
const { mgr } = await connected({
[SIGN_MESSAGE_EXTENSION]: {
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: [MODE_DAPP_PATH],
},
});
expect(mgr.walletSupportsSignMessage(MODE_DAPP_PATH)).toBe(true);
// The whole reason modes are advertised: the dapp finds out now, not after
// the user has clicked a login button.
expect(mgr.walletSupportsSignMessage(MODE_WALLET_CHOICE)).toBe(false);
});
it("is false for a scheme the wallet cannot produce", async () => {
const { mgr } = await connected();
expect(
mgr.walletSupportsSignMessage(MODE_DAPP_PATH, "bip322" as never),
).toBe(false);
});
it("reflects a reconnected wallet's capabilities rather than the old ones", async () => {
const { mgr, client } = await connected();
expect(mgr.walletSupportsSignMessage()).toBe(true);
client.emit("message", makeWalletReady({}) as never);
await new Promise((r) => setTimeout(r, 10));
expect(mgr.walletSupportsSignMessage()).toBe(false);
});
});
describe("signMessage — request shape", () => {
it("rejects half a key selection rather than guessing the mode", async () => {
const { mgr } = await connected();
await expect(
mgr.signMessage({ message: "hello", path: "receive" }),
).rejects.toThrow(/both path and addressIndex, or neither/i);
});
it("sends the message, path and userPrompt as given", async () => {
const { mgr, client } = await connected();
void mgr.signMessage({
message: "hello",
path: "receive",
addressIndex: 0,
userPrompt: "Sign in to example.com",
});
await new Promise((r) => setTimeout(r, 10));
const request = sentRequest(client);
expect(request.message).toBe("hello");
expect(request.path).toBe("receive");
expect(request.addressIndex).toBe(0);
expect(request.userPrompt).toBe("Sign in to example.com");
});
it("throws when not connected", async () => {
const mgr = new DappConnectionManager();
await expect(mgr.signMessage({ message: "hello" })).rejects.toThrow(
/not connected/i,
);
});
});
describe("signMessage — verification", () => {
async function roundTrip(
mutate: (response: SignMessageSuccess, request: SignMessageRequest) => void,
request: Parameters<DappConnectionManager["signMessage"]>[0] = {
message: "hello",
path: "receive",
addressIndex: 0,
},
) {
const { mgr, client } = await connected();
const promise = mgr.signMessage(request);
await new Promise((r) => setTimeout(r, 10));
const sent = sentRequest(client);
const response = honestResponse(sent);
mutate(response, sent);
client.emit("message", response as never);
return promise;
}
it("resolves with a verified result for an honest wallet", async () => {
const result = await roundTrip(() => {});
expect(result.message).toBe("hello");
expect(result.publicKey).toBe(binToHex(receivePublicKey(0)));
expect(result.scheme).toBe(SCHEME_BITCOIN_SIGNED_MESSAGE);
expect(result.keyBinding).toEqual({
checked: true,
path: "receive",
addressIndex: 0,
});
});
it("rejects a signature from a key other than the requested one", async () => {
// The case Ron described: the dapp must enforce the expected public key. It
// is the library's job, not the integrator's.
await expect(
roundTrip((response, request) => {
Object.assign(response, honestResponse(request, { index: 5 }));
}),
).rejects.toThrow(/but path "receive"\/0 is/);
});
it("rejects a response whose public key did not sign", async () => {
await expect(
roundTrip((response) => {
response.publicKey = binToHex(receivePublicKey(9));
}),
).rejects.toThrow(/inconsistent/i);
});
it("rejects a response whose address is not the signing key's", async () => {
await expect(
roundTrip((response) => {
response.address = messageSignatureAddress(
"hello",
signBitcoinMessage("hello", receivePrivateKey(7)),
)!;
}),
).rejects.toThrow(/inconsistent/i);
});
it("rejects a tampered signature", async () => {
await expect(
roundTrip((response) => {
const raw = Buffer.from(response.signature, "base64");
raw[10] ^= 0x01;
response.signature = raw.toString("base64");
}),
).rejects.toThrow(/inconsistent/i);
});
it("rejects a signature over different text", async () => {
await expect(
roundTrip((response) => {
response.signature = signBitcoinMessage(
"hello, but not what was asked",
receivePrivateKey(0),
);
}),
).rejects.toThrow(/inconsistent|but path/i);
});
it("rejects an error response with the wallet's reason", async () => {
const { mgr, client } = await connected();
const promise = mgr.signMessage({
message: "hello",
path: "receive",
addressIndex: 0,
});
await new Promise((r) => setTimeout(r, 10));
client.emit("message", {
action: RelayMsgAction.SignMessageResponse,
sequence: sentRequest(client).sequence,
time: Math.floor(Date.now() / 1000),
error: "User rejected",
} as never);
await expect(promise).rejects.toThrow("User rejected");
});
it("reports wallet_chose_key when the dapp named no path", async () => {
// Still fully verified — there was simply no dapp-chosen key to bind to.
const result = await roundTrip(() => {}, { message: "hello" });
expect(result.keyBinding).toEqual({
checked: false,
reason: "wallet_chose_key",
});
expect(result.address).toBeTruthy();
});
it("reports path_not_derivable for an extension path", async () => {
// The dapp has no derivation rule for "stealth_scan", so it says so rather
// than implying it checked.
const result = await roundTrip(() => {}, {
message: "hello",
path: "stealth_scan",
addressIndex: 0,
});
expect(result.keyBinding).toEqual({
checked: false,
reason: "path_not_derivable",
});
});
it("refuses to pretend when the xpub for a named path is missing", async () => {
// A derivable path with no xpub yet cannot be checked, and quietly returning
// an unchecked result is exactly the footgun to avoid.
const mgr = new DappConnectionManager();
const client = makeMockClient();
mgr.updateConnection(client as never, { status: "connected" });
client.emit("message", {
...makeWalletReady(SIGN_MESSAGE_ADVERT),
session: {
[PROTOCOL_NAME]: { paths: [], extensions: SIGN_MESSAGE_ADVERT },
},
} as never);
await new Promise((r) => setTimeout(r, 10));
const promise = mgr.signMessage({
message: "hello",
path: "receive",
addressIndex: 0,
});
await new Promise((r) => setTimeout(r, 10));
const sent = sentRequest(client);
client.emit("message", honestResponse(sent) as never);
await expect(promise).rejects.toThrow(/no xpub for path/i);
});
it("passes the wallet's echoed path through to the caller", async () => {
const result = await roundTrip((response) => {
response.path = "receive";
response.addressIndex = 0;
});
expect(result.path).toBe("receive");
expect(result.addressIndex).toBe(0);
});
it("produces a signature a third party can verify from the address alone", async () => {
// The property the whole feature exists for.
const result = await roundTrip(() => {});
const { verifyMessageSignatureForAddress } =
await import("@wizardconnect/core");
expect(
verifyMessageSignatureForAddress(
result.message,
result.signature,
result.address,
),
).toBe(true);
});
});
describe("signMessage — cancellation and reconnect", () => {
it("cancels via AbortSignal and tells the wallet", async () => {
const { mgr, client } = await connected();
const controller = new AbortController();
const promise = mgr.signMessage(
{ message: "hello", path: "receive", addressIndex: 0 },
{ signal: controller.signal },
);
await new Promise((r) => setTimeout(r, 10));
controller.abort("User closed the dialog");
await expect(promise).rejects.toThrow(/User closed the dialog/);
await new Promise((r) => setTimeout(r, 10));
expect(
client.relayed.some((msg) => msg.action === RelayMsgAction.SignCancel),
).toBe(true);
});
it("rejects a pending request when sendSignCancel names its sequence", async () => {
const { mgr, client } = await connected();
const promise = mgr.signMessage({
message: "hello",
path: "receive",
addressIndex: 0,
});
await new Promise((r) => setTimeout(r, 10));
await mgr.sendSignCancel(sentRequest(client).sequence, "changed my mind");
await expect(promise).rejects.toThrow(/changed my mind/);
});
it("re-sends a pending request when wallet_ready arrives again", async () => {
const { mgr, client } = await connected();
void mgr.signMessage({
message: "hello",
path: "receive",
addressIndex: 0,
});
await new Promise((r) => setTimeout(r, 10));
const before = client.relayed.filter(
(msg) => msg.action === RelayMsgAction.SignMessageRequest,
).length;
client.emit("message", makeWalletReady() as never);
await new Promise((r) => setTimeout(r, 10));
const after = client.relayed.filter(
(msg) => msg.action === RelayMsgAction.SignMessageRequest,
).length;
expect(after).toBe(before + 1);
});
it("ignores a response for an unknown sequence", async () => {
const { mgr, client } = await connected();
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
client.emit("message", {
action: RelayMsgAction.SignMessageResponse,
sequence: 9999,
time: 0,
error: "stray",
} as never);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
expect(mgr).toBeDefined();
});
it("does not let a sign_message response settle a transaction request", async () => {
// Both kinds share one sequence counter; routing is by action, so a response
// of the wrong kind must not resolve the other map's promise.
const { mgr, client } = await connected();
const signPromise = mgr.sendSignRequest({
action: RelayMsgAction.SignTransactionRequest,
sequence: 4,
time: 0,
inputPaths: [],
transaction: "deadbeef",
});
let settled = false;
void signPromise.then(
() => (settled = true),
() => (settled = true),
);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
client.emit("message", {
action: RelayMsgAction.SignMessageResponse,
sequence: 4,
time: 0,
signature: signBitcoinMessage("hello", receivePrivateKey(0)),
publicKey: binToHex(receivePublicKey(0)),
address: messageSignatureAddress(
"hello",
signBitcoinMessage("hello", receivePrivateKey(0)),
)!,
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
} as never);
await new Promise((r) => setTimeout(r, 10));
warn.mockRestore();
expect(settled).toBe(false);
});
});
describe("hex handling", () => {
it("accepts an upper-case public key from the wallet", async () => {
const { mgr, client } = await connected();
const promise = mgr.signMessage({
message: "hello",
path: "receive",
addressIndex: 0,
});
await new Promise((r) => setTimeout(r, 10));
const sent = sentRequest(client);
const response = honestResponse(sent);
response.publicKey = response.publicKey.toUpperCase();
client.emit("message", response as never);
const result = await promise;
expect(hexToBin(result.publicKey).length).toBe(33);
});
});

View file

@ -3,7 +3,7 @@
// 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 { Command } from "commander";
import { Command, Option } from "commander";
import { runDappMode } from "./dapp.js";
import { runWalletMode } from "./wallet.js";
@ -37,6 +37,15 @@ program
"--sign",
"Send a dummy sign request after wallet is ready (tests approval flow)",
)
.addOption(
new Option(
"--sign-message [mode]",
"Send a sign_message request after wallet_ready and verify the result",
)
.choices(["dapp_path", "wallet_choice"])
.preset("dapp_path")
.default("off"),
)
.action(async (options) => {
await runDappMode(options);
});
@ -54,6 +63,10 @@ program
"-k, --private-key <hex>",
"Wallet private key (64 hex chars) — random if omitted",
)
.option(
"--reject-messages",
"Refuse every sign_message request (tests the dapp's rejection path)",
)
.action(async (options) => {
await runWalletMode(options);
});

View file

@ -15,6 +15,19 @@ import {
type SignTransactionResponse,
type ProtocolMessage,
type RelayClient,
type SignMessageRequest,
type SignMessageResponse,
checkSignMessageResponse,
createInMemoryNonceStore,
createLoginChallenge,
createLoginNonce,
isSignMessageFailure,
peerSignMessageInfo,
peerSupportsSignMessage,
verifyLoginChallenge,
verifyMessageSignatureForAddress,
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
} from "@wizardconnect/core";
// ---- State ----
@ -26,6 +39,13 @@ interface DappState {
// Whether we've received at least one wallet_ready
walletReady: boolean;
sequence: number;
/** Messages sent for signing, by sequence, so responses can be verified. */
pendingMessages: Map<number, string>;
/** Only one sign_message request per run, however many wallet_readys arrive. */
signMessageSent: boolean;
messageNonce: string;
/** Stands in for the server-side nonce store a real dapp would use. */
nonceStore: ReturnType<typeof createInMemoryNonceStore>;
}
function makeState(): DappState {
@ -35,6 +55,10 @@ function makeState(): DappState {
walletIcon: null,
walletReady: false,
sequence: 1,
pendingMessages: new Map(),
signMessageSent: false,
messageNonce: createLoginNonce(),
nonceStore: createInMemoryNonceStore(),
};
}
@ -101,6 +125,57 @@ async function sendSignRequest(
}
}
// ---- Send a sign_message request ----
/**
* Ask the wallet to prove key control over a nonce, the way a login flow would.
*
* `walletChoice` omits the path so the wallet picks the key the mode that needs
* no xpub. Otherwise the request names receive/0, which the dapp could verify
* against its own derived key.
*/
async function sendSignMessageRequest(
client: RelayClient,
state: DappState,
walletChoice: boolean,
): Promise<void> {
const sequence = state.sequence++;
// Built with the real login-challenge helper rather than hand-rolled text, since
// this is what integrators copy. The nonce store here is per-process; a real dapp
// issues and retires nonces server-side.
const message = createLoginChallenge({
domain: "wiz-test.localhost",
nonce: state.messageNonce,
expiresInSeconds: 300,
statement: "Sign in to the WizardConnect test CLI",
});
state.nonceStore.issue(state.messageNonce);
const request: SignMessageRequest = {
action: RelayMsgAction.SignMessageRequest,
sequence,
time: Math.floor(Date.now() / 1000),
message,
userPrompt: "Sign in to the WizardConnect test CLI",
...(walletChoice ? {} : { path: "receive", addressIndex: 0 }),
};
state.pendingMessages.set(sequence, message);
console.log(
chalk.blue("→ sign_message_request") +
chalk.dim(
` seq=${sequence} mode=${walletChoice ? "wallet_choice" : "dapp_path"}`,
),
);
console.log(chalk.dim(` message: ${JSON.stringify(message)}`));
try {
await client.relay(request);
} catch (err: any) {
console.error(chalk.red(" send error:"), err.message);
}
}
// ---- Handle incoming messages ----
function handleMessage(
@ -108,6 +183,7 @@ function handleMessage(
client: RelayClient,
state: DappState,
sendSign: boolean,
signMessageMode: "off" | "dapp_path" | "wallet_choice",
): void {
const now = chalk.dim(new Date().toISOString().slice(11, 23));
@ -138,6 +214,20 @@ function handleMessage(
sendDappReady(client, state).catch(() => {});
}
// Report whether this wallet can sign messages, and in which modes — the
// check a real dapp makes before showing a login button.
const signMessageInfo = peerSignMessageInfo(hdwv1?.extensions);
if (signMessageInfo) {
console.log(
chalk.dim(
` sign_message: schemes=[${signMessageInfo.schemes.join(",")}] ` +
`modes=[${signMessageInfo.modes.join(",")}]`,
),
);
} else {
console.log(chalk.dim(" sign_message: not supported by this wallet"));
}
// If --sign flag set, schedule a sign request after wallet_ready
if (sendSign && state.walletReady) {
console.log(
@ -147,6 +237,97 @@ function handleMessage(
sendSignRequest(client, state).catch(() => {});
}, 500);
}
if (signMessageMode !== "off" && !state.signMessageSent) {
state.signMessageSent = true;
const walletChoice = signMessageMode === "wallet_choice";
const mode = walletChoice ? MODE_WALLET_CHOICE : MODE_DAPP_PATH;
if (!peerSupportsSignMessage(hdwv1?.extensions, mode)) {
console.log(
chalk.yellow(
` (--sign-message: wallet does not advertise ${mode}; sending anyway ` +
`to show what a stale dapp would get)`,
),
);
}
setTimeout(() => {
sendSignMessageRequest(client, state, walletChoice).catch(() => {});
}, 500);
}
break;
}
case RelayMsgAction.SignMessageResponse: {
const msg = message as SignMessageResponse;
const requested = state.pendingMessages.get(msg.sequence);
state.pendingMessages.delete(msg.sequence);
if (isSignMessageFailure(msg)) {
console.log(
chalk.red("← sign_message_response") +
` seq=${msg.sequence} error="${msg.error}"`,
);
break;
}
console.log(
chalk.green("← sign_message_response") +
` seq=${msg.sequence} address=${msg.address}` +
(msg.path !== undefined
? chalk.dim(` used=${msg.path}/${msg.addressIndex}`)
: ""),
);
console.log(chalk.dim(` signature: ${msg.signature}`));
if (requested === undefined) {
console.log(
chalk.yellow(" cannot verify: no pending message for that sequence"),
);
break;
}
// Verify exactly as a third party would — from the message, the signature
// and the address, with no knowledge of this protocol.
const consistent = checkSignMessageResponse(requested, msg);
const verified = verifyMessageSignatureForAddress(
requested,
msg.signature,
msg.address,
);
console.log(
(consistent.ok && verified ? chalk.green(" ✓") : chalk.red(" ✗")) +
` verified against address: ${consistent.ok && verified}` +
(consistent.ok ? "" : chalk.red(` (${consistent.reason})`)),
);
// ...and then as the server would: domain, expiry, and retiring the nonce.
void (async () => {
const login = await verifyLoginChallenge(requested, msg.signature, {
domain: "wiz-test.localhost",
consumeNonce: state.nonceStore.consume,
});
console.log(
(login.ok ? chalk.green(" ✓") : chalk.red(" ✗")) +
` login challenge: ${login.ok ? `signed in as ${login.address}` : login.reason}`,
);
// Prove the nonce is spent: the identical signature must not log in twice.
const replay = await verifyLoginChallenge(requested, msg.signature, {
domain: "wiz-test.localhost",
consumeNonce: state.nonceStore.consume,
});
console.log(
(!replay.ok ? chalk.green(" ✓") : chalk.red(" ✗")) +
` replay of the same signature rejected: ${!replay.ok}` +
(replay.ok ? "" : chalk.dim(` (${replay.reason})`)),
);
})();
console.log(
chalk.dim(
` paste into Electron Cash → Tools → Verify Message to confirm ` +
`independently`,
),
);
break;
}
@ -183,6 +364,7 @@ export async function runDappMode(options: {
secret?: string;
walletPublicKey?: string;
sign?: boolean;
signMessage?: "off" | "dapp_path" | "wallet_choice";
}): Promise<void> {
const state = makeState();
@ -273,7 +455,13 @@ export async function runDappMode(options: {
// Register message handler
client.on("message", (message: ProtocolMessage) => {
handleMessage(message, client, state, options.sign ?? false);
handleMessage(
message,
client,
state,
options.sign ?? false,
options.signMessage ?? "off",
);
});
// Send initial dapp_ready

View file

@ -9,7 +9,13 @@ import {
type WalletAdapter,
DerivationPath,
} from "@wizardconnect/wallet";
import { generateKeyExchangeCredentials, hexToBin } from "@wizardconnect/core";
import {
generateKeyExchangeCredentials,
hexToBin,
signBitcoinMessage,
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
} from "@wizardconnect/core";
import {
deriveHdPrivateNodeFromSeed,
deriveHdPrivateNodeChild,
@ -73,6 +79,27 @@ async function buildAdapter(
async signTransaction(_request): Promise<any> {
throw new Error("signTransaction not implemented in test wallet");
},
// Message signing IS implemented here, unlike signTransaction: the keys are
// already derived above, and this is the reference for what a wallet has to do
// — one call, no reassembling the construction.
signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
async signMessage(request) {
const index = request.addressIndex ?? 0;
const hdChain =
request.path === "change"
? hdChange
: request.path === "defi"
? hdDefi
: hdMain;
const child = deriveHdPrivateNodeChild(hdChain, index);
return {
signature: signBitcoinMessage(request.message, child.privateKey),
path: request.path ?? "receive",
addressIndex: index,
};
},
};
}
@ -82,6 +109,7 @@ export async function runWalletMode(options: {
relay: string;
uri: string;
privateKey?: string;
rejectMessages?: boolean;
}): Promise<void> {
console.log(chalk.bold("\nwiz-test wallet mode"));
console.log(chalk.dim(`relay: ${options.relay}`));
@ -106,6 +134,7 @@ export async function runWalletMode(options: {
const adapter = await buildAdapter(relayPrivKey);
buildSpinner.succeed("Wallet adapter ready");
const rejectMessages = options.rejectMessages ?? false;
const manager = new WalletConnectionManager(adapter);
manager.on("connectionStatusChanged", (connectionId, status) => {
@ -136,6 +165,56 @@ export async function runWalletMode(options: {
.catch(() => {});
});
manager.on("pendingSignMessageRequest", ({ connectionId, request }) => {
console.log(
chalk.yellow("← sign_message_request") +
chalk.dim(
` conn=${connectionId} seq=${request.sequence}` +
` key=${request.path !== undefined ? `${request.path}/${request.addressIndex}` : "wallet's choice"}`,
),
);
// What a wallet UI must show the user: the message verbatim, and the dapp's
// prompt marked as coming from the dapp rather than presented as fact.
console.log(chalk.bold(" message to sign:"));
for (const line of request.message.split("\n")) {
console.log(` ${chalk.cyan(JSON.stringify(line))}`);
}
if (request.userPrompt !== undefined) {
console.log(
chalk.dim(` dapp says (unsigned, untrusted): `) +
JSON.stringify(request.userPrompt),
);
}
if (rejectMessages) {
console.log(chalk.dim(" (--reject-messages: refusing)"));
manager
.sendSignMessageError(connectionId, request.sequence, "User rejected")
.catch(() => {});
return;
}
console.log(chalk.dim(" (auto-approving — this is a test wallet)"));
void (async () => {
try {
const result = await adapter.signMessage!(request);
await manager.sendSignMessageResponse(
connectionId,
request.sequence,
result,
);
console.log(
chalk.green("→ sign_message_response") + chalk.dim(" sent"),
);
} catch (err: any) {
console.error(chalk.red(" signing failed:"), err.message);
await manager
.sendSignMessageError(connectionId, request.sequence, err.message)
.catch(() => {});
}
})();
});
console.log(chalk.dim("\nConnecting to dapp..."));
const connectionId = manager.connect(options.uri);
console.log(chalk.dim(`connection id: ${connectionId}`));

View file

@ -7,10 +7,15 @@ export {
childIndexOfPath,
pathOfChildIndex,
} from "./derivation-path.js";
export type { WalletAdapter, SignTransactionResult } from "./wallet-adapter.js";
export type {
WalletAdapter,
SignTransactionResult,
SignMessageResult,
} from "./wallet-adapter.js";
export { WalletConnectionManager } from "./wallet-connection-manager.js";
export type {
RelayConnectionState,
PendingSignRequest,
PendingSignMessageRequest,
WalletConnectionManagerEvents,
} from "./wallet-connection-manager.js";

View file

@ -24,6 +24,9 @@ import {
PROTOCOL_NAME,
chunkExtensionAdvertisement,
peerSupportsChunk,
signBitcoinMessage,
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
type RelayClient,
type RelayUpdatePayload,
type DappReadyMessage,
@ -102,6 +105,29 @@ export function makeTestAdapter(seed?: Uint8Array): WalletAdapter {
async signTransaction() {
throw new Error("signTransaction not implemented in test adapter");
},
// Message signing IS implemented, because the point of the integration test
// is a real signature crossing a real relay. The keys are right here, so
// there is nothing to fake.
signMessageModes: () => [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
async signMessage(request) {
const index = request.addressIndex ?? 0;
const hdChain =
request.path === "change"
? hdChange
: request.path === "defi"
? hdDefi
: hdMain;
const child = deriveHdPrivateNodeChild(hdChain, index);
return {
signature: signBitcoinMessage(request.message, child.privateKey),
// Echo what was used, including under wallet_choice where the dapp asked
// for nothing and this is its only way to learn the path.
path: request.path ?? "receive",
addressIndex: index,
};
},
};
}

View file

@ -0,0 +1,300 @@
// 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
/**
* `sign_message` over a live relay.
*
* The unit tests drive the managers with mock clients, which proves the logic but
* not that a signature survives the trip: NIP-17 gift wrapping, JSON encoding,
* relay storage and replay all sit between the two sides. This exercises the
* whole path dapp relay wallet user approval relay dapp and then
* verifies the signature the way a third party would, from the address alone.
*/
import { describe, it, expect, afterEach } from "vitest";
import { binToHex } from "@bitauth/libauth";
import {
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
RelayMsgAction,
SCHEME_BITCOIN_SIGNED_MESSAGE,
SIGN_MESSAGE_EXTENSION,
checkSignMessageResponse,
messageSignatureAddress,
peerSupportsSignMessage,
verifyMessageSignatureForAddress,
} from "@wizardconnect/core";
import type {
Hdwalletv1Session,
ProtocolMessage,
SignMessageRequest,
SignMessageResponse,
SignMessageSuccess,
} from "@wizardconnect/core";
import { DerivationPath } from "@wizardconnect/wallet";
import { setupConnection, waitFor, type ConnectionHandles } from "./helpers.js";
let handles: ConnectionHandles | null = null;
afterEach(() => {
handles?.cleanup();
handles = null;
});
/** Wire a wallet-side auto-approver, as a host app's UI would on user approval. */
function autoApprove(h: ConnectionHandles): {
prompted: SignMessageRequest[];
failures: Error[];
} {
const prompted: SignMessageRequest[] = [];
const failures: Error[] = [];
h.wallet.manager.on(
"pendingSignMessageRequest",
({ connectionId, request }) => {
prompted.push(request);
void (async () => {
try {
const result = await h.wallet.adapter.signMessage!(request);
await h.wallet.manager.sendSignMessageResponse(
connectionId,
request.sequence,
result,
);
} catch (err) {
failures.push(err as Error);
await h.wallet.manager
.sendSignMessageError(connectionId, request.sequence, String(err))
.catch(() => {});
}
})();
},
);
return { prompted, failures };
}
function responses(h: ConnectionHandles): SignMessageResponse[] {
return h.dapp.messages.filter(
(msg: ProtocolMessage) => msg.action === RelayMsgAction.SignMessageResponse,
) as SignMessageResponse[];
}
async function requestSignature(
h: ConnectionHandles,
overrides: Partial<SignMessageRequest> = {},
): Promise<SignMessageRequest> {
const client = h.dapp.client()!;
const request: SignMessageRequest = {
action: RelayMsgAction.SignMessageRequest,
sequence: client.nextSequence(),
time: Math.floor(Date.now() / 1000),
message: "hello from an integration test",
path: "receive",
addressIndex: 0,
...overrides,
};
await client.relay(request);
return request;
}
describe("sign_message over a live relay", () => {
it("advertises the extension in wallet_ready", async () => {
handles = await setupConnection();
const session = handles.dapp.walletReadyMessages[0].session[
"hdwalletv1"
] as Hdwalletv1Session;
expect(session.extensions?.[SIGN_MESSAGE_EXTENSION]).toBeDefined();
expect(peerSupportsSignMessage(session.extensions, MODE_DAPP_PATH)).toBe(
true,
);
expect(
peerSupportsSignMessage(session.extensions, MODE_WALLET_CHOICE),
).toBe(true);
});
it("round-trips a signature the dapp can verify against its own derived key", async () => {
handles = await setupConnection();
const { prompted, failures } = autoApprove(handles);
const request = await requestSignature(handles);
await waitFor(() => responses(handles!).length > 0, {
timeoutMs: 30000,
what: "sign_message_response",
});
expect(failures).toEqual([]);
expect(prompted).toHaveLength(1);
// The message must arrive byte-identical, or the signature is over other text.
expect(prompted[0].message).toBe(request.message);
const response = responses(handles)[0] as SignMessageSuccess;
expect(response.error).toBeUndefined();
expect(response.scheme).toBe(SCHEME_BITCOIN_SIGNED_MESSAGE);
// Internally consistent: signature, key and address all agree.
const check = checkSignMessageResponse(request.message, response);
expect(check.ok).toBe(true);
// ...and it is the key the dapp asked for. The dapp holds the xpub, so this
// is the check a real dapp performs.
const expectedKey = handles.wallet.adapter.getPublicKey(
DerivationPath.Receive,
0n,
);
expect(response.publicKey).toBe(binToHex(expectedKey));
// Finally, the property the feature exists for: verifiable from the address
// alone, by anyone, with no knowledge of this protocol.
expect(
verifyMessageSignatureForAddress(
request.message,
response.signature,
response.address,
),
).toBe(true);
expect(messageSignatureAddress(request.message, response.signature)).toBe(
response.address,
);
});
it("signs multi-byte text without mangling it in transit", async () => {
// JSON encoding and NIP-44 both sit in the path; a re-encode would change the
// bytes and the signature would verify against nothing.
handles = await setupConnection();
autoApprove(handles);
const message = "Straße 日本語 🍅 — nonce=7f3a2c";
const request = await requestSignature(handles, { message });
await waitFor(() => responses(handles!).length > 0, {
timeoutMs: 30000,
what: "sign_message_response",
});
const response = responses(handles)[0] as SignMessageSuccess;
expect(
verifyMessageSignatureForAddress(
message,
response.signature,
response.address,
),
).toBe(true);
// The same text with a normalised or trimmed variation must NOT verify.
expect(
verifyMessageSignatureForAddress(
message.trim().replace("ß", "ss"),
response.signature,
response.address,
),
).toBe(false);
expect(request.message).toBe(message);
});
it("lets the wallet choose the key when the dapp names none", async () => {
handles = await setupConnection();
autoApprove(handles);
const request = await requestSignature(handles, {
path: undefined,
addressIndex: undefined,
});
await waitFor(() => responses(handles!).length > 0, {
timeoutMs: 30000,
what: "sign_message_response",
});
const response = responses(handles)[0] as SignMessageSuccess;
expect(response.error).toBeUndefined();
// The dapp learns the identity from the response, having sent no xpub-derived
// path at all — the no-xpub-required case.
expect(response.address).toBeTruthy();
expect(response.path).toBe("receive");
expect(
verifyMessageSignatureForAddress(
request.message,
response.signature,
response.address,
),
).toBe(true);
});
it("signs with a non-zero index and a non-default path", async () => {
handles = await setupConnection();
autoApprove(handles);
await requestSignature(handles, { path: "change", addressIndex: 4 });
await waitFor(() => responses(handles!).length > 0, {
timeoutMs: 30000,
what: "sign_message_response",
});
const response = responses(handles)[0] as SignMessageSuccess;
expect(response.publicKey).toBe(
binToHex(handles.wallet.adapter.getPublicKey(DerivationPath.Change, 4n)),
);
});
it("prompts the user once when the dapp re-sends the same request", async () => {
// Dapps re-send pending requests on wallet_ready and relays replay stored
// events; both land as a duplicate delivery.
handles = await setupConnection();
const { prompted } = autoApprove(handles);
const request = await requestSignature(handles);
await waitFor(() => prompted.length > 0, {
timeoutMs: 30000,
what: "first prompt",
});
await handles.dapp.client()!.relay({ ...request, time: request.time + 1 });
await new Promise((r) => setTimeout(r, 3000));
expect(prompted).toHaveLength(1);
});
it("delivers a rejection as an error response", async () => {
handles = await setupConnection();
handles.wallet.manager.on(
"pendingSignMessageRequest",
({ connectionId, request }) => {
void handles!.wallet.manager.sendSignMessageError(
connectionId,
request.sequence,
"User rejected the signature",
);
},
);
await requestSignature(handles);
await waitFor(() => responses(handles!).length > 0, {
timeoutMs: 30000,
what: "sign_message error response",
});
const response = responses(handles)[0];
expect(response.error).toBe("User rejected the signature");
});
it("answers a request for an unsupported scheme instead of going quiet", async () => {
handles = await setupConnection();
const { prompted } = autoApprove(handles);
await requestSignature(handles, { scheme: "bip322" as never });
await waitFor(() => responses(handles!).length > 0, {
timeoutMs: 30000,
what: "sign_message error response",
});
expect(prompted).toEqual([]);
expect(responses(handles)[0].error).toMatch(
/unsupported signature scheme/i,
);
});
});

View file

@ -0,0 +1,617 @@
// 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
/**
* Wallet-side behaviour of the `sign_message` extension.
*
* The construction itself is covered in core. What matters here is the wiring
* around it: that support is advertised from the adapter's real capability, that
* a request the wallet cannot serve gets an answer instead of silence, and that
* the manager derives the response's key and address from the signature rather
* than trusting an adapter to state them.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "eventemitter3";
import { binToHex, hexToBin, secp256k1 } from "@bitauth/libauth";
import {
MODE_DAPP_PATH,
MODE_WALLET_CHOICE,
RelayMsgAction,
RelayStatus,
SCHEME_BITCOIN_SIGNED_MESSAGE,
SIGN_MESSAGE_EXTENSION,
signBitcoinMessage,
verifyMessageSignatureForAddress,
type MessageSigningMode,
type RelayStatusCallback,
type RelayUpdatePayload,
type SignMessageRequest,
type SignMessageResponse,
type SignMessageSuccess,
type WalletReadyMessage,
} from "@wizardconnect/core";
import type { SignMessageResult, WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath } from "./derivation-path.js";
let capturedCallback: RelayStatusCallback | null = null;
function makeMockClient() {
const emitter = new EventEmitter();
return {
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
relay: vi.fn(async () => {}),
isKeyExchangeComplete: () => true,
setPairedPublicKey: vi.fn(),
setPeerCapabilities: vi.fn(),
nextSequence: (() => {
let seq = 0;
return () => (seq += 2);
})(),
};
}
type MockClient = ReturnType<typeof makeMockClient>;
let mockClient: MockClient;
vi.mock("@wizardconnect/core", async () => {
const actual = await vi.importActual<typeof import("@wizardconnect/core")>(
"@wizardconnect/core",
);
return {
...actual,
initiateWalletRelay: (cb: RelayStatusCallback) => {
capturedCallback = cb;
return {
client: mockClient,
dappPublicKey: new Uint8Array(32),
walletPublicKey: new Uint8Array(33).fill(0x02),
secret: "aa".repeat(16),
cleanup: vi.fn(),
};
},
};
});
const { WalletConnectionManager } =
await import("./wallet-connection-manager.js");
const PRIVATE_KEY = hexToBin("00".repeat(31) + "01");
const PUBLIC_KEY = secp256k1.derivePublicKeyCompressed(
PRIVATE_KEY,
) as Uint8Array;
const XPUB =
"xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8";
interface AdapterOptions {
signMessage?: WalletAdapter["signMessage"];
signMessageModes?: MessageSigningMode[];
getExtensions?: () => Record<string, unknown>;
}
function makeAdapter(options: AdapterOptions = {}): WalletAdapter {
const adapter: WalletAdapter = {
walletName: "Test Wallet",
walletIcon: "",
getRelayPrivateKey: () => new Uint8Array(32).fill(0x01),
getPublicKey: () => PUBLIC_KEY,
getXpub: (_path: DerivationPath) => XPUB,
signTransaction: vi.fn(),
};
if (options.signMessage) adapter.signMessage = options.signMessage;
if (options.signMessageModes) {
adapter.signMessageModes = () => options.signMessageModes!;
}
if (options.getExtensions) adapter.getExtensions = options.getExtensions;
return adapter;
}
/** An adapter that signs honestly with the fixed test key. */
function honestSignMessage(): WalletAdapter["signMessage"] {
return async (request: SignMessageRequest): Promise<SignMessageResult> => ({
signature: signBitcoinMessage(request.message, PRIVATE_KEY),
path: request.path ?? "receive",
addressIndex: request.addressIndex ?? 0,
});
}
function simulateConnection(): MockClient {
if (!capturedCallback) throw new Error("connect() not called yet");
const payload: RelayUpdatePayload = {
client: mockClient as never,
status: RelayStatus.connected(),
};
capturedCallback(payload);
return mockClient;
}
function makeRequest(
sequence: number,
overrides: Partial<SignMessageRequest> = {},
): SignMessageRequest {
return {
action: RelayMsgAction.SignMessageRequest,
sequence,
time: Math.floor(Date.now() / 1000),
message: "hello",
path: "receive",
addressIndex: 0,
...overrides,
};
}
/** All sign_message responses the wallet put on the wire. */
function responses(client: MockClient): SignMessageResponse[] {
return client.relay.mock.calls
.map((call) => call[0] as SignMessageResponse)
.filter((msg) => msg?.action === RelayMsgAction.SignMessageResponse);
}
async function flush(): Promise<void> {
await new Promise((r) => setTimeout(r, 10));
}
beforeEach(() => {
capturedCallback = null;
mockClient = makeMockClient();
(
WalletConnectionManager as never as {
uriSignSequences: Map<string, unknown>;
}
).uriSignSequences.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("extension advertisement", () => {
/** The hdwalletv1 session extensions from the wallet_ready that was queued. */
function advertisedExtensions(
client: MockClient,
): Record<string, unknown> | undefined {
const ready = client.relay.mock.calls
.map((call) => call[0] as WalletReadyMessage)
.find((msg) => msg?.action === RelayMsgAction.WalletReady);
const session = ready?.session?.["hdwalletv1"] as
| { extensions?: Record<string, unknown> }
| undefined;
return session?.extensions;
}
it("advertises nothing when the adapter cannot sign messages", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
await flush();
expect(
advertisedExtensions(client)?.[SIGN_MESSAGE_EXTENSION],
).toBeUndefined();
});
it("advertises from the adapter's capability, not a hand-written declaration", async () => {
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage() }),
);
mgr.connect("wiz://test");
const client = simulateConnection();
await flush();
expect(advertisedExtensions(client)?.[SIGN_MESSAGE_EXTENSION]).toEqual({
schemes: [SCHEME_BITCOIN_SIGNED_MESSAGE],
modes: [MODE_DAPP_PATH],
});
});
it("advertises wallet_choice when the adapter declares it", async () => {
const mgr = new WalletConnectionManager(
makeAdapter({
signMessage: honestSignMessage(),
signMessageModes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
}),
);
mgr.connect("wiz://test");
const client = simulateConnection();
await flush();
expect(
(
advertisedExtensions(client)?.[SIGN_MESSAGE_EXTENSION] as {
modes: string[];
}
).modes,
).toEqual([MODE_DAPP_PATH, MODE_WALLET_CHOICE]);
});
it("does not overwrite an explicit declaration from the adapter", async () => {
const explicit = {
schemes: ["bitcoin_signed_message", "future"],
modes: [],
};
const mgr = new WalletConnectionManager(
makeAdapter({
signMessage: honestSignMessage(),
getExtensions: () => ({ [SIGN_MESSAGE_EXTENSION]: explicit }),
}),
);
mgr.connect("wiz://test");
const client = simulateConnection();
await flush();
expect(advertisedExtensions(client)?.[SIGN_MESSAGE_EXTENSION]).toEqual(
explicit,
);
});
it("keeps other adapter extensions alongside sign_message", async () => {
const mgr = new WalletConnectionManager(
makeAdapter({
signMessage: honestSignMessage(),
getExtensions: () => ({ rpa_bip47: { scan_path: "m/47'" } }),
}),
);
mgr.connect("wiz://test");
const client = simulateConnection();
await flush();
const extensions = advertisedExtensions(client)!;
expect(extensions[SIGN_MESSAGE_EXTENSION]).toBeDefined();
expect(extensions["rpa_bip47"]).toEqual({ scan_path: "m/47'" });
});
});
describe("request handling", () => {
it("emits pendingSignMessageRequest for a well-formed request", async () => {
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage() }),
);
mgr.connect("wiz://test");
const client = simulateConnection();
const seen: SignMessageRequest[] = [];
mgr.on("pendingSignMessageRequest", (req) => seen.push(req.request));
client.emit("message", makeRequest(42));
await flush();
expect(seen).toHaveLength(1);
expect(seen[0].message).toBe("hello");
});
it("does not prompt twice for a replayed request", async () => {
// Relays replay stored events on reconnect and the dapp re-sends pending
// requests on wallet_ready.
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage() }),
);
mgr.connect("wiz://test");
const client = simulateConnection();
const seen: number[] = [];
mgr.on("pendingSignMessageRequest", (req) =>
seen.push(req.request.sequence),
);
const request = makeRequest(42);
client.emit("message", request);
client.emit("message", request);
await flush();
expect(seen).toEqual([42]);
});
it("still blocks a replay after the response was sent", async () => {
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage() }),
);
const connId = mgr.connect("wiz://test");
const client = simulateConnection();
const seen: number[] = [];
mgr.on("pendingSignMessageRequest", (req) =>
seen.push(req.request.sequence),
);
const request = makeRequest(42);
client.emit("message", request);
await flush();
await mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage("hello", PRIVATE_KEY),
});
client.emit("message", request);
await flush();
expect(seen).toEqual([42]);
});
it("answers rather than hangs when the wallet cannot sign messages", async () => {
const mgr = new WalletConnectionManager(makeAdapter());
mgr.connect("wiz://test");
const client = simulateConnection();
client.emit("message", makeRequest(42));
await flush();
const sent = responses(client);
expect(sent).toHaveLength(1);
expect(sent[0].error).toMatch(/does not support message signing/i);
});
it("rejects an unsupported scheme before bothering the user", async () => {
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage() }),
);
mgr.connect("wiz://test");
const client = simulateConnection();
const seen: number[] = [];
mgr.on("pendingSignMessageRequest", (req) =>
seen.push(req.request.sequence),
);
client.emit("message", makeRequest(42, { scheme: "bip322" as never }));
await flush();
expect(seen).toEqual([]);
expect(responses(client)[0].error).toMatch(/unsupported signature scheme/i);
});
it("rejects a mode the adapter does not support", async () => {
// Adapter defaults to dapp_path only; this request omits the key selection.
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage() }),
);
mgr.connect("wiz://test");
const client = simulateConnection();
const seen: number[] = [];
mgr.on("pendingSignMessageRequest", (req) =>
seen.push(req.request.sequence),
);
client.emit(
"message",
makeRequest(42, { path: undefined, addressIndex: undefined }),
);
await flush();
expect(seen).toEqual([]);
expect(responses(client)[0].error).toMatch(/wallet_choice/);
});
it("accepts a wallet_choice request when the adapter advertises it", async () => {
const mgr = new WalletConnectionManager(
makeAdapter({
signMessage: honestSignMessage(),
signMessageModes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
}),
);
mgr.connect("wiz://test");
const client = simulateConnection();
const seen: number[] = [];
mgr.on("pendingSignMessageRequest", (req) =>
seen.push(req.request.sequence),
);
client.emit(
"message",
makeRequest(42, { path: undefined, addressIndex: undefined }),
);
await flush();
expect(seen).toEqual([42]);
});
it("answers a malformed request instead of dropping it", async () => {
// Half a key selection is ambiguous between the two modes.
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage() }),
);
mgr.connect("wiz://test");
const client = simulateConnection();
const seen: number[] = [];
mgr.on("pendingSignMessageRequest", (req) =>
seen.push(req.request.sequence),
);
client.emit("message", makeRequest(42, { addressIndex: undefined }));
await flush();
expect(seen).toEqual([]);
expect(responses(client)[0].error).toMatch(/malformed/i);
});
});
describe("response construction", () => {
async function connected(adapterOptions: AdapterOptions = {}) {
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage(), ...adapterOptions }),
);
const connId = mgr.connect("wiz://test");
const client = simulateConnection();
client.emit("message", makeRequest(42));
await flush();
return { mgr, connId, client };
}
it("derives the public key and address from the signature", async () => {
const { mgr, connId, client } = await connected();
await mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage("hello", PRIVATE_KEY),
});
const response = responses(client)[0] as SignMessageSuccess;
expect(response.publicKey).toBe(binToHex(PUBLIC_KEY));
expect(response.scheme).toBe(SCHEME_BITCOIN_SIGNED_MESSAGE);
// The address must be the one the signature actually proves.
expect(
verifyMessageSignatureForAddress(
"hello",
response.signature,
response.address,
),
).toBe(true);
});
it("honours the address prefix so a testnet key is not shown as mainnet", async () => {
const { mgr, connId, client } = await connected();
await mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage("hello", PRIVATE_KEY),
addressPrefix: "bchtest",
});
expect((responses(client)[0] as SignMessageSuccess).address).toMatch(
/^bchtest:/,
);
});
it("echoes the path the wallet used", async () => {
const { mgr, connId, client } = await connected();
await mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage("hello", PRIVATE_KEY),
path: "receive",
addressIndex: 3,
});
const response = responses(client)[0] as SignMessageSuccess;
expect(response.path).toBe("receive");
expect(response.addressIndex).toBe(3);
});
it("throws when the adapter signed text other than the request", async () => {
// Recovery alone cannot catch this — it succeeds and yields some other key.
// Comparing that key against the adapter's own key for the path is what makes
// it detectable, so a wallet bug surfaces here rather than as an opaque
// rejection on the far side of a relay.
const { mgr, connId } = await connected();
await expect(
mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage("something else", PRIVATE_KEY),
}),
).rejects.toThrow(/wrong key, or over text other than/i);
});
it("throws when the adapter signed with a key that is not the requested one", async () => {
const { mgr, connId } = await connected();
const otherKey = hexToBin("00".repeat(31) + "02");
await expect(
mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage("hello", otherKey),
}),
).rejects.toThrow(/wrong key/i);
});
it("checks the echoed path when the dapp named none", async () => {
// Under wallet_choice the adapter is the only one who knows which key it used,
// so its echo is what gets checked.
const mgr = new WalletConnectionManager(
makeAdapter({
signMessage: honestSignMessage(),
signMessageModes: [MODE_DAPP_PATH, MODE_WALLET_CHOICE],
}),
);
const connId = mgr.connect("wiz://test");
const client = simulateConnection();
client.emit(
"message",
makeRequest(42, { path: undefined, addressIndex: undefined }),
);
await flush();
await expect(
mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage(
"hello",
hexToBin("00".repeat(31) + "02"),
),
path: "receive",
addressIndex: 0,
}),
).rejects.toThrow(/wrong key/i);
// ...and the honest signature for that echoed path goes through.
await mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage("hello", PRIVATE_KEY),
path: "receive",
addressIndex: 0,
});
expect(responses(client)).toHaveLength(1);
});
it("throws on a malformed signature", async () => {
const { mgr, connId } = await connected();
await expect(
mgr.sendSignMessageResponse(connId, 42, { signature: "not-a-signature" }),
).rejects.toThrow();
});
it("throws for a sequence that is not awaiting a response", async () => {
const { mgr, connId } = await connected();
await expect(
mgr.sendSignMessageResponse(connId, 999, {
signature: signBitcoinMessage("hello", PRIVATE_KEY),
}),
).rejects.toThrow(/no pending sign_message request/i);
});
it("sends a failure response when the user rejects", async () => {
const { mgr, connId, client } = await connected();
await mgr.sendSignMessageError(connId, 42, "User rejected");
const response = responses(client)[0];
expect(response.error).toBe("User rejected");
expect((response as SignMessageSuccess).signature).toBeUndefined();
});
it("will not answer a request twice", async () => {
const { mgr, connId } = await connected();
await mgr.sendSignMessageError(connId, 42, "User rejected");
await expect(
mgr.sendSignMessageResponse(connId, 42, {
signature: signBitcoinMessage("hello", PRIVATE_KEY),
}),
).rejects.toThrow(/no pending sign_message request/i);
});
});
describe("cancellation", () => {
it("reports a dapp cancel for a message request via signCancelled", async () => {
// One sequence counter means sign_cancel names exactly one request, whichever
// kind it was.
const mgr = new WalletConnectionManager(
makeAdapter({ signMessage: honestSignMessage() }),
);
mgr.connect("wiz://test");
const client = simulateConnection();
client.emit("message", makeRequest(42));
await flush();
const cancelled: number[] = [];
mgr.on("signCancelled", (_id, sequence) => cancelled.push(sequence));
client.emit("message", {
action: RelayMsgAction.SignCancel,
sequence: 42,
time: Math.floor(Date.now() / 1000),
});
await flush();
expect(cancelled).toEqual([42]);
});
});

View file

@ -3,12 +3,47 @@
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/lgpl-3.0.html
import { DerivationPath } from "./derivation-path.js";
import { SignTransactionRequest, PathXpub } from "@wizardconnect/core";
import {
CashAddrPrefix,
MessageSigningMode,
PathName,
PathXpub,
SignMessageRequest,
SignTransactionRequest,
} from "@wizardconnect/core";
export interface SignTransactionResult {
signedTransaction: string;
}
/**
* What a wallet returns after signing a message.
*
* Deliberately just the signature. The public key and address are not asked for
* because they are recoverable FROM the signature, and
* WalletConnectionManager derives them that way so the three can never
* disagree, and an adapter cannot accidentally claim a proof about an address it
* did not prove. Use `signBitcoinMessage()` from `@wizardconnect/core` to produce
* the signature rather than assembling the construction by hand.
*/
export interface SignMessageResult {
/** Base64 recoverable compact signature — see core's signBitcoinMessage(). */
signature: string;
/**
* Network prefix for the address derived from the signature. Defaults to
* `bitcoincash`; set it on testnet or the dapp is shown a mainnet address for a
* testnet key.
*/
addressPrefix?: CashAddrPrefix;
/**
* Which path and index the wallet actually used, when it can say. Echoed to the
* dapp so wallet-chosen signing is transparent; omit when the key sits outside
* any advertised path.
*/
path?: PathName;
addressIndex?: number;
}
/**
* Abstract interface that wallets implement to integrate with WizardConnect.
*/
@ -57,6 +92,50 @@ export interface WalletAdapter {
* Optional extension data to include in the hdwalletv1 session handshake.
* Each key is an extension name; its presence indicates wallet support.
* See docs/extensions.md for conventions.
*
* Note: WalletConnectionManager advertises the `sign_message` extension
* automatically when `signMessage` is implemented, so an adapter does not need
* to list it here. An adapter that does list it wins the automatic
* advertisement never overwrites an explicit one.
*/
getExtensions?(): Record<string, unknown>;
/**
* Sign a plain message, proving control of a key without a transaction.
*
* Optional implementing it is what advertises the `sign_message` extension to
* dapps. Wallets that omit it keep working unchanged; a dapp that asks anyway
* gets an explicit error rather than silence.
*
* Sign `request.message` byte for byte as UTF-8, with no trimming and no
* Unicode normalisation: the signature must verify against exactly the text the
* dapp displayed. Use `signBitcoinMessage()` from `@wizardconnect/core`, which
* owns the magic string, both length prefixes and the header byte the parts
* third-party verifiers check, and the parts this repo's conformance tests
* cover.
*
* Key selection depends on the request:
* - `path` and `addressIndex` set: sign with that key.
* - both absent: choose the key yourself and, ideally, echo it back in the
* result. The choice MUST be deterministic a dapp treats the resulting
* address as a durable identity, so a fresh key per connection makes a
* returning user unrecognisable. Only advertise MODE_WALLET_CHOICE via
* `signMessageModes()` if you can honour that.
*
* Throw to reject: the manager turns it into an error response for the dapp.
*
* SECURITY: `request.userPrompt` is dapp-supplied and unsigned. Show the
* message itself as the thing being signed, and treat the prompt as untrusted
* decoration otherwise a dapp can caption hostile text reassuringly. See
* docs/wallet.md.
*/
signMessage?(request: SignMessageRequest): Promise<SignMessageResult>;
/**
* Key-selection modes `signMessage` supports. Defaults to `[MODE_DAPP_PATH]`.
*
* Advertised to dapps so they can tell before offering the feature, rather than
* discovering it from a rejected request after the user clicked a button.
*/
signMessageModes?(): MessageSigningMode[];
}

View file

@ -10,6 +10,17 @@ import {
RelayUpdatePayload,
RelayStatusCallback,
initiateWalletRelay,
childIndexOfPathName,
isSignMessageRequest,
messageSignatureAddress,
recoverMessageSigner,
signMessageExtensionAdvertisement,
signMessageRequestMode,
MODE_DAPP_PATH,
SCHEME_BITCOIN_SIGNED_MESSAGE,
SignMessageRequest,
SignMessageFailure,
SignMessageSuccess,
SignTransactionRequest,
SignTransactionResponse,
SignCancelMessage,
@ -27,8 +38,8 @@ import {
chunkExtensionAdvertisement,
peerSupportsChunk,
} from "@wizardconnect/core";
import { WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath } from "./derivation-path.js";
import { SignMessageResult, WalletAdapter } from "./wallet-adapter.js";
import { DerivationPath, pathOfChildIndex } from "./derivation-path.js";
export interface RelayConnectionState {
id: string;
@ -45,6 +56,12 @@ export interface PendingSignRequest {
request: SignTransactionRequest;
}
/** A dapp's message-signing request, awaiting the user's approval. */
export interface PendingSignMessageRequest {
connectionId: string;
request: SignMessageRequest;
}
interface ActiveConnection {
id: string;
uri: string;
@ -61,6 +78,12 @@ interface ActiveConnection {
walletReadySentThisCycle: boolean;
/// Sign request sequences received on this connection, for cleanup on disconnect.
signSequences: Set<number>;
/**
* Message-signing requests awaiting a response, by sequence. Retained so the
* response path can check the adapter's signature against the exact message it
* was asked to sign.
*/
pendingSignMessages: Map<number, SignMessageRequest>;
notificationQueue: ProtocolMessage[];
notificationProcessor: ReturnType<typeof setInterval> | null;
/// Key exchange data embedded in wallet_ready
@ -71,6 +94,13 @@ interface ActiveConnection {
export type WalletConnectionManagerEvents = {
connectionStatusChanged: [connectionId: string, status: RelayStatus];
pendingSignRequest: [request: PendingSignRequest];
/**
* A dapp asked for a plain-message signature (`sign_message` extension).
* Host apps must show `request.message` verbatim and, on approval, call
* sendSignMessageResponse or sendSignMessageError to reject. A
* `signCancelled` event for the same sequence means the dapp gave up.
*/
pendingSignMessageRequest: [request: PendingSignMessageRequest];
connectionsChanged: [];
remoteDisconnect: [
connectionId: string,
@ -132,6 +162,7 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
dappDiscovered: false,
walletReadySentThisCycle: false,
signSequences: new Set(),
pendingSignMessages: new Map(),
notificationQueue: [],
notificationProcessor: null,
walletPublicKeyHex: "",
@ -317,6 +348,122 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
await conn.client.relay(response);
}
/**
* Send a signed message back to the dapp.
*
* Derives the public key and address from the signature by recovery rather than
* taking the adapter's word for them, so the three values in the response
* cannot contradict each other.
*
* Then, whenever the signing key is identifiable the dapp named a path, or the
* adapter echoed one checks the recovered key against the adapter's own key
* for that path. Recovery alone cannot catch a signature over the wrong text:
* it succeeds regardless and simply yields a different key. Comparing against
* the key that should have signed is what turns that into a detectable error, so
* a wallet's derivation or encoding mistake surfaces here at the call site
* instead of as an opaque rejection on the far side of a relay.
*/
async sendSignMessageResponse(
connectionId: string,
sequence: number,
result: SignMessageResult,
): Promise<void> {
const conn = this.connections.get(connectionId);
if (!conn?.client) {
throw new Error(`Connection ${connectionId} not found or not connected`);
}
const request = conn.pendingSignMessages.get(sequence);
if (!request) {
throw new Error(
`No pending sign_message request with sequence ${sequence} on connection ${connectionId}`,
);
}
const signer = recoverMessageSigner(request.message, result.signature);
if (!signer) {
throw new Error(
"signMessage produced a malformed signature — build it with " +
"signBitcoinMessage() from @wizardconnect/core",
);
}
// Which key was this supposed to be? The adapter's echo wins over the
// request, because under MODE_WALLET_CHOICE the adapter is the only one who
// knows.
const path = result.path ?? request.path;
const addressIndex = result.addressIndex ?? request.addressIndex;
if (path !== undefined && addressIndex !== undefined) {
const derivationPath = pathOfChildIndex(childIndexOfPathName(path) ?? -1);
if (derivationPath !== undefined) {
const expected = this.adapter.getPublicKey(
derivationPath,
BigInt(addressIndex),
);
if (binToHex(signer.publicKey) !== binToHex(expected)) {
throw new Error(
`signMessage signed with ${binToHex(signer.publicKey)}, but ` +
`${path}/${addressIndex} is ${binToHex(expected)} — the message was ` +
`signed with the wrong key, or over text other than request.message`,
);
}
}
}
const address = messageSignatureAddress(
request.message,
result.signature,
result.addressPrefix ?? "bitcoincash",
);
if (!address) {
throw new Error("Could not derive an address from the signature");
}
conn.pendingSignMessages.delete(sequence);
this.persistCompletedSequence(conn, sequence);
const response: SignMessageSuccess = {
action: RelayMsgAction.SignMessageResponse,
sequence,
signature: result.signature,
publicKey: binToHex(signer.publicKey),
address,
scheme: SCHEME_BITCOIN_SIGNED_MESSAGE,
...(result.path !== undefined ? { path: result.path } : {}),
...(result.addressIndex !== undefined
? { addressIndex: result.addressIndex }
: {}),
time: Math.floor(Date.now() / 1000),
};
await conn.client.relay(response);
}
/** Tell the dapp the message was not signed (user rejected, or unsupported). */
async sendSignMessageError(
connectionId: string,
sequence: number,
errorMessage: string,
): Promise<void> {
const conn = this.connections.get(connectionId);
if (!conn?.client) {
return; // Already disconnected, nothing to do
}
conn.pendingSignMessages.delete(sequence);
// Same reasoning as sendSignError: keep in the dedup guard until doDisconnect
// so a replayed request cannot re-prompt the user.
this.persistCompletedSequence(conn, sequence);
const response: SignMessageFailure = {
action: RelayMsgAction.SignMessageResponse,
sequence,
error: errorMessage,
time: Math.floor(Date.now() / 1000),
};
await conn.client.relay(response);
}
// --- Private helpers ---
/**
@ -407,6 +554,9 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
case RelayMsgAction.SignCancel:
this.handleSignCancel(conn, message as SignCancelMessage);
break;
case RelayMsgAction.SignMessageRequest:
this.handleSignMessageRequest(conn, message);
break;
case RelayMsgAction.Ping:
this.handlePing(conn, message as PingMessage);
break;
@ -495,9 +645,20 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
];
const adapterExtensions = this.adapter.getExtensions?.();
// Advertise sign_message from the adapter's actual capability rather than
// making every wallet remember to declare it, so the handshake cannot claim
// support an adapter does not implement. An adapter that declares the key
// itself wins: spreading it last means an explicit declaration (with extra
// schemes, say) is never overwritten by this default.
const signMessageExtension = this.adapter.signMessage
? signMessageExtensionAdvertisement(
this.adapter.signMessageModes?.() ?? [MODE_DAPP_PATH],
)
: {};
const extensions = { ...signMessageExtension, ...adapterExtensions };
const hdwv1Session: Hdwalletv1Session = {
paths,
...(adapterExtensions ? { extensions: adapterExtensions } : {}),
...(Object.keys(extensions).length > 0 ? { extensions } : {}),
};
const msg: WalletReadyMessage = {
@ -521,6 +682,95 @@ export class WalletConnectionManager extends EventEmitter<WalletConnectionManage
this.flushNotificationQueue(conn);
}
/**
* A dapp asked for a plain-message signature.
*
* Deduplicated against the same sequence set as transaction signing. That is
* correct rather than merely convenient: the dapp allocates every sequence from
* one per-connection counter (RelayClient.nextSequence), so a sequence
* identifies a request regardless of kind which is also what lets one
* `sign_cancel` cancel either. Relays replay stored events on reconnect and the
* dapp re-sends pending requests on wallet_ready, so without the guard the user
* is prompted twice for one request.
*/
private handleSignMessageRequest(
conn: ActiveConnection,
msg: ProtocolMessage & { sequence?: number },
): void {
const sequence = msg.sequence;
if (typeof sequence !== "number") {
// No sequence means no way to address a reply, so there is nothing useful
// to say back. Drop it.
console.warn(
"[wizardconnect/wallet] sign_message_request without a sequence, ignoring",
);
return;
}
if (this.activeSignSequences.has(sequence)) {
return;
}
// Malformed requests are answered, not dropped: a request with only half a
// key selection is ambiguous, and leaving the dapp waiting for a response
// that will never come is worse than telling it why.
if (!isSignMessageRequest(msg)) {
void this.sendSignMessageError(
conn.id,
sequence,
"Malformed sign_message_request",
);
return;
}
// Refuse rather than hang if this wallet cannot sign messages. The dapp
// should have checked the advertised extension, but a stale dapp must get an
// answer either way.
if (!this.adapter.signMessage) {
void this.sendSignMessageError(
conn.id,
sequence,
"This wallet does not support message signing",
);
return;
}
const scheme = msg.scheme ?? SCHEME_BITCOIN_SIGNED_MESSAGE;
if (scheme !== SCHEME_BITCOIN_SIGNED_MESSAGE) {
// Checked before prompting: asking the user to approve a signature we
// cannot produce in the requested scheme wastes their decision.
void this.sendSignMessageError(
conn.id,
sequence,
`Unsupported signature scheme: ${scheme}`,
);
return;
}
const mode = signMessageRequestMode(msg);
const supportedModes = this.adapter.signMessageModes?.() ?? [
MODE_DAPP_PATH,
];
if (!supportedModes.includes(mode)) {
void this.sendSignMessageError(
conn.id,
sequence,
`This wallet does not support the ${mode} key-selection mode`,
);
return;
}
this.activeSignSequences.add(sequence);
conn.signSequences.add(sequence);
// Retained so sendSignMessageResponse can check the adapter's signature
// against the message it was supposed to sign.
conn.pendingSignMessages.set(sequence, msg);
this.emit("pendingSignMessageRequest", {
connectionId: conn.id,
request: msg,
});
}
private handleSignRequest(
conn: ActiveConnection,
msg: SignTransactionRequest,