BIP-137 или BIP-322 — это выбор между старым и новым способами доказать в Bitcoin контроль над адресом, подписав сообщение. BIP-137 описывает давно существующий формат «Bitcoin Signed Message»: 65-байтную подпись ECDSA, из которой можно восстановить ключ; байт заголовка этой подписи указывает, адрес какого типа выводить из восстановленного ключа. BIP-322 обобщает подпись на любой скрипт: сообщение фиксируется в виртуальной транзакции, а подписью служит тот witness, который потратил бы средства с этого адреса; проверяет его обычный интерпретатор скриптов.
Коротко
| BIP-137 | BIP-322 | |
|---|---|---|
| Статус | Deployed, внедрён (номер присвоен в 2019 году; сам формат намного старше) | Complete (завершён) начиная с версии 1.0.0 от 15 апреля 2026 года; версия 2.0.0 — 4 июня 2026 года |
| Хеш сообщения | Двойной SHA-256 от "\x18Bitcoin Signed Message:\n", длины и самого сообщения | Тегированный SHA-256 по BIP-340 с тегом BIP0322-signed-message |
| Что подписывается | Непосредственно хеш сообщения | Виртуальная транзакция to_sign, которая тратит виртуальный выход to_spend |
| Типы адресов | P2PKH; P2SH-P2WPKH и P2WPKH через диапазоны значений заголовка | Любой скрипт: P2PKH, P2WPKH, P2WSH, P2TR, мультиподпись, блокировки по времени |
| Что нужно проверяющей стороне | Подпись, сообщение, адрес; ключ восстанавливается | Подпись, сообщение, адрес; интерпретатор скриптов |
| Содержимое подписи | Байт заголовка (27–42), r, s | Стек witness (smp), полная транзакция (ful) или PSBT (pof) |
| Размер в base64 | 88 символов | 147 для P2WPKH, 88 для P2TR, 439 для мультиподписи P2WSH 3 из 3 (тестовые векторы) |
| Доказательство наличия средств | Нет | Да, с дополнительными входами в PSBT (pof) |
| Привязка к открытому ключу | Нет (восстановление можно подстроить так, чтобы получить связанный ключ) | Да, через скрипт, условия которого должны быть выполнены |
| Подпись на аппаратном кошельке | Режим подписи сообщений, если он поддерживается | Подпись транзакции в стиле PSBT |
Как работает BIP-137
Подписант хеширует сообщение с фиксированным префиксом, подписывает его с помощью ECDSA и сохраняет идентификатор восстановления в байте заголовка: 27–30 для P2PKH с несжатыми ключами, 31–34 для сжатых ключей, 35–38 для P2SH-P2WPKH и 39–42 для нативного SegWit P2WPKH. Проверяющая сторона с помощью восстановления открытого ключа вычисляет ключ по подписи и проверяет, что его хеш соответствует адресу.
Это работает, только если адрес — хеш одного открытого ключа. Адреса Taproot привязаны к модифицированному (tweaked) ключу и используют подписи Schnorr, из которых ключ восстановить нельзя; у мультиподписи и других скриптов единственного ключа нет вообще. Диапазоны заголовка для SegWit добавили позже, и не все кошельки их поддержали, поэтому подпись из одного кошелька может не пройти проверку в другом, а собственные команды Bitcoin Core signmessage и verifymessage до сих пор принимают только устаревшие адреса P2PKH.
Как работает BIP-322
BIP-322 хеширует сообщение тегированным хешем и помещает результат в скрипт входа виртуальной транзакции to_spend, единственный выход которой содержит scriptPubKey адреса. Вторая транзакция, to_sign, тратит этот выход в выход с OP_RETURN. Подписант создаёт тот же witness, который его кошелёк создал бы для траты средств с этого адреса; проверяющая сторона заново собирает обе транзакции и запускает интерпретатор скриптов. Поскольку to_spend тратит несуществующий вход, ни одна из этих транзакций никогда не может стать действительной в сети.
Простой формат (smp) содержит только стек witness. Полный формат (ful) содержит всю транзакцию to_sign; он нужен для скриптов с блокировками по времени или со входами без witness, и тогда проверяющая сторона сообщает время и возраст, начиная с которых доказательство действительно. Доказательство наличия средств (pof) — это финализированная PSBT, которая также тратит реальные UTXO, поэтому подпись показывает контроль над конкретными монетами. Устаревший формат BIP-137 остаётся допустимым только для адресов P2PKH.
Оба формата: проверка по тестовым векторам BIP-322
Скрипт ниже использует закрытый ключ (в формате WIF) и адрес из базовых тестовых векторов BIP-322. Ключ опубликован там для тестов, поэтому потратить средства с его адреса может кто угодно, и на этом адресе никогда не должно быть средств. Скрипт создаёт и проверяет подпись BIP-137 для «Hello World», заново вычисляет хеш сообщения BIP-322 и идентификаторы обеих виртуальных транзакций, а затем проверяет опубликованную в векторах подпись smp с хешем подписи по BIP-143 (Node.js 26, @noble/curves 2.3.0, @noble/hashes 2.3.0, @scure/base 2.4.0).
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { ripemd160 } from "@noble/hashes/legacy.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bytesToHex, concatBytes, utf8ToBytes } from "@noble/hashes/utils.js";
import { base64, bech32, createBase58check } from "@scure/base";
const sha256d = (b) => sha256(sha256(b));
const u32 = (n) => new Uint8Array([n, n >>> 8, n >>> 16, n >>> 24]);
const zero = (n) => new Uint8Array(n);
const push = (b) => concatBytes(new Uint8Array([b.length]), b); // lengths here are all below 0xfd
const txid = (tx) => bytesToHex(sha256d(tx).reverse());
// Test key published in the BIP-322 basic test vectors (bip-0322/basic-test-vectors.json).
// It is public, so never send funds to its address.
const wif = createBase58check(sha256).decode("L3VFeEujGtevx9w18HD1fhRbCH67Az2dpCymeRE1SoPK6XQtaN2k");
const secretKey = wif.subarray(1, 33);
const pub = secp256k1.getPublicKey(secretKey, true);
const h160 = ripemd160(sha256(pub));
const address = bech32.encode("bc", [0, ...bech32.toWords(h160)]);
console.log("address:", address);
// BIP-137: double SHA-256 of a prefixed message, compact signature with a header byte
const message = "Hello World";
const m = utf8ToBytes(message);
const legacyHash = sha256d(concatBytes(push(utf8ToBytes("Bitcoin Signed Message:\n")), push(m)));
const rec = secp256k1.sign(legacyHash, secretKey, { prehash: false, format: "recovered" });
const encoded = base64.encode(concatBytes(new Uint8Array([39 + rec[0]]), rec.subarray(1))); // 39-42: P2WPKH
console.log("\nBIP-137 signature:", encoded);
// Verifier side: read the header, recover the public key, compare its hash with the address
const sig137 = base64.decode(encoded);
const recovered = secp256k1.recoverPublicKey(concatBytes(new Uint8Array([(sig137[0] - 27) & 3]), sig137.subarray(1)), legacyHash, { prehash: false });
console.log("header", sig137[0], "-> recovered key matches address:", bytesToHex(ripemd160(sha256(recovered))) === bytesToHex(h160));
// BIP-322: tagged message hash, virtual to_spend and to_sign transactions
const tag = sha256(utf8ToBytes("BIP0322-signed-message"));
const messageHash = sha256(concatBytes(tag, tag, m));
const challenge = concatBytes(new Uint8Array([0x00, 0x14]), h160); // P2WPKH scriptPubKey
const toSpend = concatBytes(u32(0), new Uint8Array([1]), zero(32), u32(0xffffffff),
push(concatBytes(new Uint8Array([0x00, 0x20]), messageHash)), u32(0), new Uint8Array([1]), zero(8), push(challenge), u32(0));
const prevout = concatBytes(sha256d(toSpend), u32(0));
const opReturn = concatBytes(zero(8), push(new Uint8Array([0x6a])));
const toSign = concatBytes(u32(0), new Uint8Array([1]), prevout, push(zero(0)), u32(0), new Uint8Array([1]), opReturn, u32(0));
console.log("\nBIP-322 message hash:", bytesToHex(messageHash));
console.log("to_spend txid:", txid(toSpend));
console.log("to_sign txid: ", txid(toSign));
// Verify the "simple" signature from the test vectors: witness stack [signature, public key]
const vector = "smpAkcwRAIgZRfIY3p7/DoVTty6YZbWS71bc5Vct9p9Fia83eRmw2QCICK/ENGfwLtptFluMGs2KsqoNSk89pO7F29zJLUx9a/sASECx/EgAxlkQpQ9hYjgGu6EBCPMVPwVIVJqO4XCsMvViHI=";
const stack = base64.decode(vector.slice(3));
const der = stack.subarray(2, 2 + stack[1]);
const witnessKey = stack.subarray(3 + stack[1]);
// BIP-143 signature hash for input 0 of to_sign, SIGHASH_ALL
const scriptCode = push(concatBytes(new Uint8Array([0x76, 0xa9, 0x14]), h160, new Uint8Array([0x88, 0xac])));
const preimage = concatBytes(u32(0), sha256d(prevout), sha256d(u32(0)), prevout, scriptCode, zero(8), u32(0),
sha256d(opReturn), u32(0), u32(1));
const ok = secp256k1.verify(der.subarray(0, -1), sha256d(preimage), witnessKey, { prehash: false, format: "der" });
console.log("vector signature:", vector.slice(0, 3), "prefix,", stack[0], "witness items,", ok ? "valid" : "invalid");
console.log("witness key is the address key:", bytesToHex(witnessKey) === bytesToHex(pub));
address: bc1q9vza2e8x573nczrlzms0wvx3gsqjx7vavgkx0l
BIP-137 signature: KOW2xi+ebJLeBtr674l4QH76dqDoVjLV80R9EFKFQX5rBrlCXPIZaYs8Yuayg0ZqjyiCbLy9pzZIS7JWT65/nsU=
header 40 -> recovered key matches address: true
BIP-322 message hash: f0eb03b1a75ac6d9847f55c624a99169b5dccba2a31f5b23bea77ba270de0a7a
to_spend txid: b79d196740ad5217771c1098fc4a4b51e0535c32236c71f1ea4d61a2d603352b
to_sign txid: 88737ae86f2077145f93cc4b153ae9a1cb8d56afa511988c149c5c8c9d93bddf
vector signature: smp prefix, 2 witness items, valid
witness key is the address key: true
Хеш сообщения и оба идентификатора транзакций совпадают со значениями в basic-test-vectors.json, а опубликованная подпись проходит проверку. Подпись BIP-137 детерминированная (RFC 6979), поэтому один и тот же ключ и одно и то же сообщение всегда дают одни и те же 88 символов; байт заголовка 40 означает P2WPKH с идентификатором восстановления 1. Проверка сообщений Bitcoin поддерживает оба формата и адреса любого типа.
Что меняется для разработчиков
- Проверка становится тяжелее: для BIP-322 нужны сериализация транзакций, вычисление хеша подписи для каждого типа скрипта (устаревшего, BIP-143 для SegWit v0, BIP-341 для Taproot) и в идеале полноценный интерпретатор скриптов. Всё это есть в таких библиотеках, как btcd (источник тестовых векторов).
- Подпись использует готовый код для транзакций: кошельки и аппаратные устройства подписывают
to_signкак любую другую транзакцию, часто в виде PSBT, поэтому координаторы мультиподписи и кошельки Taproot работают без специального кода для сообщений. - Определение формата: версия 1.0.0 сделала трёхбуквенный префикс обязательным, и это изменение несовместимо с прежними версиями; для совместимости с более ранними реализациями проверяющие стороны могут считать подписи без префикса подписями
smp. - Ключ не восстанавливается: проверяющая сторона должна знать адрес, но обычно он и так передаётся.
- Те же ограничения, что у любой подписи: ни один из форматов не доказывает, что подписант всё ещё контролирует монеты или что он не стал бы подписывать от чужого имени.
Что когда выбирать
- Taproot, P2WSH, мультиподпись или адреса скриптов: BIP-322; BIP-137 не может их выразить.
- Биржи и кастодиальные сервисы, которые проверяют адреса для вывода средств (проверки в рамках travel rule): принимайте BIP-322 для всех типов адресов и BIP-137 для P2PKH и SegWit с одним ключом.
- Доказательство резервов или владения конкретными UTXO: доказательство наличия средств BIP-322 (
pof). - Устаревшие адреса P2PKH и старые кошельки: BIP-137 по-прежнему допустим, и именно такие подписи создаёт Bitcoin Core.
- Вход с помощью кошелька Bitcoin: простой формат BIP-322 для сообщения, которое содержит домен, nonce и срок действия, по тем же причинам, что и в Sign-In with Ethereum.
Заметки о миграции
- Проверяйте оба формата: пробуйте BIP-322, если у подписи есть префикс или адрес не P2PKH; для 65-байтных подписей с заголовками 27–42 возвращайтесь к BIP-137.
- Унифицируйте обработку заголовка: принимайте подписи SegWit, у которых заголовок попадает в диапазон P2PKH для сжатых ключей, только если восстановленный ключ соответствует адресу SegWit, и задокументируйте это решение.
- Выдавайте подписи с префиксом, когда подписываете по BIP-322, как требует версия 1.0.0.
- Тестируйте на опубликованных векторах (
basic-test-vectors.json,generated-test-vectors.json), включая случаи ошибок: неверные сообщения, неверные адреса и некорректные префиксы.