Short answer. There are two formats. The legacy one, specified after the fact as BIP-137, is a 65-byte recoverable ECDSA signature over "\x18Bitcoin Signed Message:\n" ‖ length ‖ message; it only proves control of a key, and its first byte (27 to 42) says which address type the signer meant. Bitcoin Core signs it only for legacy P2PKH addresses, and wallets disagree on the header for SegWit addresses. BIP-322 (status Complete) proves control of an address: it builds a virtual transaction that spends from the address and the signature is the witness of that spend, so it works for P2WPKH, P2TR, multisig and scripts. Since version 2.0.0 of the BIP, signatures carry a prefix: smp (simple), ful (full) or pof (proof of funds). Verify with a library that understands the prefix, and accept unprefixed simple signatures from older wallets.
The legacy format (BIP-137)
The digest is double SHA-256 of a fixed prefix, the message length as a Bitcoin varint, and the message bytes. The signature is header ‖ r ‖ s, 65 bytes, usually base64-encoded. The header carries the recovery ID (0 to 3), which lets the verifier compute the public key from the signature, plus a constant for the key and address type (BIP-137, status Deployed):
| Header | Meaning |
|---|---|
| 27-30 | P2PKH, uncompressed public key |
| 31-34 | P2PKH, compressed public key |
| 35-38 | P2SH-P2WPKH (nested SegWit) |
| 39-42 | P2WPKH (native SegWit, bc1q…) |
Verification recovers the public key, derives the address of the type the header names, and compares. There is no transaction, no script: the format cannot express "this multisig address" or "this Taproot address", only "the holder of this key". BIP-137 itself points to BIP-322 for anything beyond that.
The module below implements the legacy format and, further down, BIP-322 simple signatures for P2WPKH, with @noble/curves and @scure/base:
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { ripemd160 } from "@noble/hashes/legacy.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bech32, bech32m, createBase58check } from "@scure/base";
const b58 = createBase58check(sha256);
const sha256d = (b) => sha256(sha256(Uint8Array.from(b)));
const hash160 = (b) => ripemd160(sha256(b));
const utf8 = (s) => new TextEncoder().encode(s);
const cat = (...parts) => Uint8Array.from(parts.flatMap((p) => [...p]));
const varint = (n) => (n < 0xfd ? [n] : [0xfd, n & 0xff, n >> 8]); // messages under 64 KiB
export const NETWORKS = {
mainnet: { p2pkh: 0x00, p2sh: 0x05, hrp: "bc" },
regtest: { p2pkh: 0x6f, p2sh: 0xc4, hrp: "bcrt" },
};
/** Addresses of one compressed public key. */
export function addressesOf(pubkey, net = NETWORKS.mainnet) {
const h = hash160(pubkey);
const redeem = cat([0x00, 0x14], h); // P2SH-P2WPKH redeem script
return {
p2pkh: b58.encode(cat([net.p2pkh], h)),
"p2sh-p2wpkh": b58.encode(cat([net.p2sh], hash160(redeem))),
p2wpkh: bech32.encode(net.hrp, [0, ...bech32.toWords(h)]),
};
}
/** scriptPubKey of an address, for BIP-322's message_challenge. */
export function scriptPubKey(address, net = NETWORKS.mainnet) {
if (address.toLowerCase().startsWith(`${net.hrp}1`)) {
const isV0 = address.toLowerCase().startsWith(`${net.hrp}1q`);
const { words } = (isV0 ? bech32 : bech32m).decode(address);
const program = (isV0 ? bech32 : bech32m).fromWords(words.slice(1));
return cat([words[0] === 0 ? 0x00 : 0x50 + words[0], program.length], program);
}
const raw = b58.decode(address);
if (raw[0] === net.p2pkh) return cat([0x76, 0xa9, 0x14], raw.slice(1), [0x88, 0xac]);
if (raw[0] === net.p2sh) return cat([0xa9, 0x14], raw.slice(1), [0x87]);
throw new Error(`unsupported address ${address}`);
}
// ---------------------------------------------------------------------------- BIP-137 (legacy)
/** The digest every legacy message signature covers. */
export const legacyDigest = (message) => {
const m = utf8(message);
return sha256d(cat([0x18], utf8("Bitcoin Signed Message:\n"), varint(m.length), m));
};
const HEADER_BASE = { p2pkh: 31, "p2sh-p2wpkh": 35, p2wpkh: 39 }; // compressed keys
/** 65-byte compact signature, base64. headerFor "electrum" keeps 31-34 for every address type. */
export function signLegacy(message, privateKey, type = "p2pkh", headerFor = "bip137") {
const sig = secp256k1.Signature.fromBytes(secp256k1.sign(legacyDigest(message), privateKey, { prehash: false, format: "recovered" }), "recovered");
const base = headerFor === "electrum" ? 31 : HEADER_BASE[type];
return Buffer.from(cat([base + sig.recovery], sig.toBytes("compact"))).toString("base64");
}
/** Recovers the key and checks it against the address; strict mode also checks the header type. */
export function verifyLegacy(address, message, signatureBase64, { net = NETWORKS.mainnet, strict = false } = {}) {
const raw = Buffer.from(signatureBase64, "base64");
if (raw.length !== 65) throw new Error(`expected 65 bytes, got ${raw.length}`);
const header = raw[0];
if (header < 27 || header > 42) throw new Error(`header byte ${header} out of range`);
const recovery = (header - 27) % 4;
const typeFromHeader = header >= 39 ? "p2wpkh" : header >= 35 ? "p2sh-p2wpkh" : "p2pkh";
const compressed = header >= 31;
const sig = secp256k1.Signature.fromBytes(raw.subarray(1), "compact").addRecoveryBit(recovery);
const pubkey = sig.recoverPublicKey(legacyDigest(message)).toBytes(compressed);
const candidates = addressesOf(pubkey, net);
const match = Object.entries(candidates).find(([, a]) => a === address)?.[0];
if (!match) return { valid: false };
if (strict && match !== typeFromHeader) return { valid: false, reason: `header says ${typeFromHeader}, address is ${match}` };
return { valid: true, type: match, header };
}
// ---------------------------------------------------------------------------- BIP-322 (simple, P2WPKH)
const tag = sha256(utf8("BIP0322-signed-message"));
/** sha256(tag || tag || m), a BIP-340 tagged hash of the raw message. */
export const messageHash = (message) => sha256(cat(tag, tag, utf8(message)));
const le32 = (n) => [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, n >>> 24];
const zero64 = new Array(8).fill(0);
/** The two virtual transactions of BIP-322, serialized without witness. Returns txids too. */
export function virtualTransactions(address, message, net = NETWORKS.mainnet) {
const challenge = scriptPubKey(address, net);
const toSpend = cat(
le32(0), [1], new Array(32).fill(0), le32(0xffffffff), // version 0, one input spending 000…000:FFFFFFFF
[34, 0x00, 0x20], messageHash(message), le32(0), // scriptSig OP_0 PUSH32[message_hash], nSequence 0
[1], zero64, [challenge.length], challenge, le32(0), // one output, value 0, scriptPubKey = challenge
);
const toSpendId = sha256d(toSpend); // internal byte order
const toSign = cat(le32(0), [1], toSpendId, le32(0), [0], le32(0), [1], zero64, [1, 0x6a], le32(0));
const display = (h) => Buffer.from(h).reverse().toString("hex");
return { challenge, toSpendId, toSpendTxid: display(toSpendId), toSignTxid: display(sha256d(toSign)) };
}
/** Verifies a BIP-322 simple signature for a P2WPKH address. Accepts the "smp" prefix or none. */
export function verifySimpleP2wpkh(address, message, signature, net = NETWORKS.mainnet) {
const b64 = signature.startsWith("smp") ? signature.slice(3) : signature;
const w = Buffer.from(b64, "base64");
// Witness stack: count, then <len><DER sig + sighash byte>, <len><33-byte pubkey>.
if (w[0] !== 2) throw new Error("expected a two-item witness stack");
const sigWithType = w.subarray(2, 2 + w[1]);
const pubkey = w.subarray(3 + w[1], 3 + w[1] + w[2 + w[1]]);
const { challenge, toSpendId } = virtualTransactions(address, message, net);
if (challenge[0] !== 0x00 || challenge[1] !== 20) throw new Error("not a P2WPKH address");
if (Buffer.compare(Buffer.from(hash160(pubkey)), Buffer.from(challenge.subarray(2))) !== 0) return false;
if (sigWithType.at(-1) !== 0x01) throw new Error("only SIGHASH_ALL is allowed");
// BIP-143 digest of to_sign input 0, spending to_spend:0 worth 0 sat.
const scriptCode = cat([0x19, 0x76, 0xa9, 0x14], challenge.subarray(2), [0x88, 0xac]);
const outputs = cat(zero64, [1, 0x6a]);
const preimage = cat(
le32(0), sha256d(cat(toSpendId, le32(0))), sha256d(le32(0)), toSpendId, le32(0),
scriptCode, zero64, le32(0), sha256d(outputs), le32(0), le32(1),
);
const der = sigWithType.subarray(0, -1);
return secp256k1.verify(der, sha256d(preimage), pubkey, { prehash: false, format: "der", lowS: true });
}
What Bitcoin Core supports
Bitcoin Core's signmessage and verifymessage RPCs implement the legacy format, and only for legacy addresses. A regtest node, one address of each type:
#!/bin/sh
# Bitcoin Core in regtest: which address types can sign and verify messages, and the exact errors.
set -e
DATA=/tmp/regtest
mkdir -p "$DATA"
bitcoind -regtest -datadir="$DATA" -daemonwait -fallbackfee=0.0001 >/dev/null
cli() { bitcoin-cli -regtest -datadir="$DATA" "$@"; }
cli -named createwallet wallet_name=lab >/dev/null
echo "$(bitcoind -version | head -1)"
MSG="ecdsa.com lab, 2026-10-20"
for type in legacy p2sh-segwit bech32 bech32m; do
addr=$(cli getnewaddress "" "$type")
printf '%-12s %s\n' "$type" "$addr"
if sig=$(cli signmessage "$addr" "$MSG" 2>/tmp/err); then
echo " signmessage: $sig"
echo " verifymessage: $(cli verifymessage "$addr" "$sig" "$MSG")"
echo "$addr $sig" > "/w/core-$type.txt"
else
echo " signmessage: $(tr '\n' ' ' < /tmp/err)"
fi
done
# A signature made elsewhere for a segwit address: Core cannot even attempt it.
seg=$(cli getnewaddress "" bech32)
leg=$(cut -d' ' -f2 /w/core-legacy.txt)
echo "verifymessage with a bech32 address: $(cli verifymessage "$seg" "$leg" "$MSG" 2>&1 | tr '\n' ' ')"
cli stop >/dev/null
Bitcoin Core daemon version v31.1.0 bitcoind
legacy mnaZRqP42LwTsVNQGfSb1eeTMvVzkaMoWp
signmessage: ICZ/Fs0I/i7XNJbC+ubYkwpDZXI0uFZU1oaqrSaJlS+hYavTSzdfPKmRkLJrUdi6/RXl9WsYNEW5OU9l9hfMHCI=
verifymessage: true
p2sh-segwit 2MyhmVFuFbTr4kAF1S4TtVmjEgkdouEntKK
signmessage: error code: -3 error message: Address does not refer to key
bech32 bcrt1qulxz0zapcld55a5twc9k88028y6dyxrggxug0n
signmessage: error code: -3 error message: Address does not refer to key
bech32m bcrt1pq8x39vj0qay20825g8m3kffzx7sqwu9lfzmtjejta94luunwkmfsdc0hnd
signmessage: error code: -3 error message: Address does not refer to key
verifymessage with a bech32 address: error code: -3 error message: Address does not refer to key
Every SegWit and Taproot address fails with Address does not refer to key, both for signing and for verifying a signature someone else made. The legacy signature's first byte, 31 or 32 in these runs (base64 H or I), marks a compressed P2PKH key.
Our implementation produces the same bytes as Bitcoin Core. With a key generated for the run and imported into the regtest wallet as a pkh() descriptor, both signed the same message:
address mk1giEaBaAFPXNRMQcZNptPAp9kJmttKyp
core signature: H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
our signature: H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
identical: yes
core verifymessage(our signature): true
The signatures are identical because both use deterministic nonces (RFC 6979) and low-S normalization; a signer that uses random nonces produces a different, equally valid signature each time.
The header problem for SegWit addresses
Wallets that sign legacy-format messages for SegWit addresses do not agree on the header. Electrum signs with header 27 to 34 for every address type and, when verifying, tries every single-key address type the recovered key could have (electrum/bitcoin.py). Trezor follows BIP-137 and adds 4 for P2SH-P2WPKH and 8 for P2WPKH, with an option (no_script_type) to produce the Electrum form instead (trezor-firmware, sign_message.py). A verifier therefore has to choose between a strict check (the header must name the address's type) and a loose one (any address of the recovered key). The two conventions, same key and message, against our verifier and bip322-js 4.0.0:
// Same key, same message, P2WPKH address: the BIP-137 header and the Electrum convention, and who accepts which.
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { Verifier } from "bip322-js";
import { addressesOf, signLegacy, verifyLegacy } from "./btcmsg.mjs";
const priv = secp256k1.utils.randomSecretKey();
const { p2pkh, p2wpkh } = addressesOf(secp256k1.getPublicKey(priv, true));
const message = "ecdsa.com lab, 2026-10-20";
const lib = (address, sig, strict) => {
try {
return Verifier.verifySignature(address, message, sig, strict);
} catch (e) {
return `throws: ${e.message}`;
}
};
for (const [label, sig] of [
["BIP-137 header (39-42)", signLegacy(message, priv, "p2wpkh", "bip137")],
["Electrum header (31-34)", signLegacy(message, priv, "p2wpkh", "electrum")],
]) {
const header = Buffer.from(sig, "base64")[0];
const loose = verifyLegacy(p2wpkh, message, sig);
const strict = verifyLegacy(p2wpkh, message, sig, { strict: true });
console.log(`${label}: header ${header}`);
console.log(` ours, loose: ${loose.valid} ours, strict: ${strict.valid}${strict.reason ? ` (${strict.reason})` : ""}`);
console.log(` bip322-js, loose: ${lib(p2wpkh, sig, false)} bip322-js, strict: ${lib(p2wpkh, sig, true)}`);
console.log(` same signature for the P2PKH address of the key: ours, loose ${verifyLegacy(p2pkh, message, sig).valid}`);
}
BIP-137 header (39-42): header 39
ours, loose: true ours, strict: true
bip322-js, loose: true bip322-js, strict: true
same signature for the P2PKH address of the key: ours, loose true
Electrum header (31-34): header 31
ours, loose: true ours, strict: false (header says p2pkh, address is p2wpkh)
bip322-js, loose: true bip322-js, strict: false
same signature for the P2PKH address of the key: ours, loose true
Loose verification accepts both, and also accepts the same signature as proof for the key's P2PKH address. That is the honest meaning of a legacy signature: whoever holds this key, for every address derived from it. Strict verification rejects every Electrum-style SegWit signature. If you must accept legacy signatures for SegWit addresses, verify loosely and record that what you verified is control of a key.
BIP-322: proving control of an address
BIP-322 (bip-0322.mediawiki, status Complete, version 2.0.0) reuses the transaction machinery instead of inventing a message format. Two virtual transactions are built:
to_spend: version 0, one input spending the non-existent outpoint000…000:FFFFFFFFwith a scriptSig ofOP_0 PUSH32[message_hash], one output of 0 satoshis whose scriptPubKey is the address being proven.message_hashis a BIP-340 tagged hash,SHA256(SHA256(tag) ‖ SHA256(tag) ‖ message)with tagBIP0322-signed-message.to_sign: version 0, one input spendingto_spend:0, one output of 0 satoshis toOP_RETURN.
Signing is spending to_spend:0 in to_sign as a wallet would spend any output of that address. Verification checks that spend under the standard script rules (SIGHASH_ALL or Taproot's default, low-S and strict DER, CLEANSTACK and others). Because neither transaction is valid on the network, a signature cannot be broadcast to move funds.
Encodings:
| Variant | Prefix | Content | Use |
|---|---|---|---|
| Simple | smp | the witness stack, base64 | native SegWit and Taproot, no time locks |
| Full | ful | the whole signed to_sign, base64 | any script, including nested SegWit, legacy and time locks |
| Full (proof of funds) | pof | a finalized PSBT of to_sign with additional inputs | proving control of specific UTXOs |
| Legacy | none | the BIP-137 signature | allowed only for legacy P2PKH addresses |
The variant prefixes are new in version 2.0.0, and signers MUST now include them. For compatibility "a verifier might assume the simple variant in the absence of a prefix", which is how signatures from wallets built against the earlier text keep working. Verification has three outcomes, not two: valid, invalid, or inconclusive when the verifier cannot evaluate the scripts involved.
Multisig and hardware wallets: signing through PSBT
For a multisig address the witness needs signatures from several keys, held by different people or devices. BIP-322 handles this with the tools multisig users already have: the to_sign transaction is passed around as a PSBT with a new global field, PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (type 0x09), carrying the message. A signer that sees that field, a first input spending output 0 of the matching to_spend, and a single zero-value OP_RETURN output must show the user the message and the address rather than a transaction, and then signs as it would sign a spend. Each cosigner adds a partial signature; the finalized result becomes a ful signature. A device that does not recognize the field sees what looks like a transaction spending 0 satoshis, which is why wallet support for this flow matters more than the cryptography.
Two reference implementations are listed in the BIP: a Bitcoin Core pull request for basic support (#24058), which was closed without being merged, and a btcd pull request with complete support (#2521), the source of the generated test vectors and still open on 20 October 2026. Neither ships in a release, which is part of why the wallets and libraries in the table below that support BIP-322 each implement it themselves.
Checked against the BIP's test vectors
BIP-322 ships basic test vectors: message hashes and transaction IDs for three messages, and simple signatures for a P2WPKH address. Verifying them needs only the address, message and signature. The same signatures, with and without the new prefix, went to bip322-js 4.0.0 as well:
// BIP-322's published test vectors (addresses, messages and signatures only), against btcmsg.mjs and bip322-js 4.0.0.
import { readFileSync } from "node:fs";
import { Verifier } from "bip322-js";
import { messageHash, verifySimpleP2wpkh, virtualTransactions } from "./btcmsg.mjs";
const vectors = JSON.parse(readFileSync("bip322-basic-test-vectors.json", "utf8"));
for (const v of vectors.tx_hashes) {
const { toSpendTxid, toSignTxid } = virtualTransactions(v.address, v.message);
const ok = Buffer.from(messageHash(v.message)).toString("hex") === v.message_hash && toSpendTxid === v.to_spend_tx_hash && toSignTxid === v.to_sign_tx_hash;
console.log(`hashes for ${JSON.stringify(v.message).padEnd(16).slice(0, 16)} message_hash, to_spend, to_sign match: ${ok}`);
}
for (const v of vectors.simple.filter((x) => x.type === "p2wpkh")) {
for (const sig of v.bip322_signatures) {
const ours = verifySimpleP2wpkh(v.address, v.message, sig);
const call = (s) => { try { return Verifier.verifySignature(v.address, v.message, s); } catch (e) { return `throws: ${e.message}`; } };
console.log(`${JSON.stringify(v.message).padEnd(14)} ${sig.slice(0, 12)}... ours: ${ours} | bip322-js: ${call(sig)} | bip322-js, prefix removed: ${call(sig.slice(3))}`);
}
}
const wrong = vectors.error.find((e) => e.description.startsWith("wrong message for valid simple p2wpkh"));
console.log(`error vector "${wrong.description}": ours ${verifySimpleP2wpkh(wrong.address, wrong.message, wrong.signature)}`);
hashes for "" message_hash, to_spend, to_sign match: true
hashes for "Hello World" message_hash, to_spend, to_sign match: true
hashes for "UTF-8 support: message_hash, to_spend, to_sign match: true
"" smpAkcwRAIgM... ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"" smpAkgwRQIhA... ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"Hello World" smpAkcwRAIgZ... ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"Hello World" smpAkgwRQIhA... ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
error vector "wrong message for valid simple p2wpkh signature (empty message was signed)": ours false
The implementation above reproduces every hash and verifies every simple P2WPKH signature, prefixed or not, and rejects the error vector. bip322-js 4.0.0, the widely used JavaScript library, does not understand the smp prefix: it decodes the prefix as base64 and fails with buffer too small, while the same signature without the prefix verifies. Signatures produced by newer wallets will therefore fail in services built on it until it is updated; stripping a known prefix before calling it is a workable bridge for simple signatures.
Who produces and accepts what
From each project's source code, or a test run where marked, as of October 2026:
| Software | Legacy (BIP-137) | BIP-322 | Notes |
|---|---|---|---|
| Bitcoin Core 31.1 | P2PKH only | no | tested above |
| Electrum (master) | P2PKH, P2WPKH, P2SH-P2WPKH, header 27-34 | no | verifies both header conventions; refuses addresses without a single key |
| Trezor firmware (main) | P2PKH, P2SH-P2WPKH, P2WPKH, BIP-137 headers | no | Taproot: "Unsupported script type" |
| Sparrow (drongo master) | yes | simple, with smp/ful/pof prefixes | verification: simple variant, single-key P2WPKH and P2TR |
| bip322-js 4.0.0 | yes, loose or strict | simple, P2WPKH, P2SH-P2WPKH, single-key P2TR | no prefixes; see the note above |
The practical consequences: a Taproot address can only be proven with BIP-322; a multisig address cannot be proven with the legacy format at all; and a service that wants to accept proofs from users of all these wallets needs to accept legacy signatures (loosely, for SegWit), unprefixed BIP-322 simple signatures, and prefixed ones.
How to verify in a service
- Fix the message on your side. Include your domain, a nonce or timestamp, and the purpose (
"Proof of ownership for example.com, nonce 7f3a…"), and accept each nonce once, exactly as in Sign-In with Ethereum. A signature over a generic text can be replayed to any other service that asks for the same text. - Compare the exact bytes. Both formats sign the raw message. Electrum's interface strips leading and trailing whitespace before signing; a trailing newline from a copy and paste, or CRLF line endings, produces a different hash and an "invalid signature".
- Detect the format, in this order: a
smp,fulorpofprefix; a base64 string that decodes to exactly 65 bytes with a first byte between 27 and 42 (legacy); otherwise try an unprefixed BIP-322 simple signature. - Decide what a legacy signature proves for SegWit addresses: loose verification proves control of the key, which for single-key addresses is control of the address.
- Treat inconclusive as not verified. A verifier that cannot evaluate a script (time locks, unusual scripts) must not report success.
- For proof of funds, check the chain. A
pofsignature proves control of the listed outputs when signed; whether they are still unspent is a separate lookup, and it never proves they are all the outputs of an address.
Sources
- BIP-137: Signatures of Messages using Private Keys
- BIP-322: Generic Signed Message Format, version 2.0.0, and its basic test vectors
- BIP-340 (tagged hashes), BIP-143 (SegWit signature hash), BIP-174 (PSBT)
- RFC 6979: Deterministic DSA and ECDSA
- Bitcoin Core 31.1,
signmessageandverifymessageRPCs - Electrum
bitcoin.py, Trezorsign_message.py, Sparrow drongoBip322.java, bip322-js