Перейти к содержимому
ecdsa.com
← Все статьи

Подпись сообщений Bitcoin: разбор BIP-137 и BIP-322

«Подпишите это сообщение своим адресом» звучит как одна операция. На деле это два несовместимых формата, три соглашения о заголовке и стандарт, который в этом году изменил кодировку подписей. Здесь разобрано, что подписывает каждый формат и какие кошельки что создают, а код проверки сверен с Bitcoin Core и с тестовыми векторами самого BIP.

Коротко. Форматов два. Устаревший, описанный задним числом как BIP-137, — это 65-байтная подпись ECDSA с восстановлением ключа, которая покрывает "\x18Bitcoin Signed Message:\n" ‖ length ‖ message; она доказывает только контроль над ключом, а её первый байт (от 27 до 42) сообщает, какой тип адреса имел в виду подписант. Bitcoin Core создаёт такую подпись только для устаревших адресов P2PKH, а для адресов SegWit кошельки заполняют заголовок по-разному. BIP-322 (статус Complete) доказывает контроль над адресом: он строит виртуальную транзакцию, которая тратит средства с этого адреса, а подписью служит witness этой траты, поэтому формат работает для P2WPKH, P2TR, мультиподписи и скриптов. С версии 2.0.0 этого BIP у подписей есть префикс: smp (простая), ful (полная) или pof (доказательство наличия средств). Проверяйте подписи библиотекой, которая понимает префикс, и принимайте простые подписи без префикса от старых кошельков.

Устаревший формат (BIP-137)

Дайджест — двойной SHA-256 от фиксированного префикса, длины сообщения в формате varint Bitcoin и байтов сообщения. Подпись имеет вид header ‖ r ‖ s, занимает 65 байт и обычно кодируется в base64. Заголовок содержит идентификатор восстановления (от 0 до 3), по которому проверяющая сторона вычисляет открытый ключ из подписи, и константу, обозначающую тип ключа и адреса (BIP-137, статус Deployed):

ЗаголовокЗначение
27–30P2PKH, несжатый открытый ключ
31–34P2PKH, сжатый открытый ключ
35–38P2SH-P2WPKH (вложенный SegWit)
39–42P2WPKH (нативный SegWit, bc1q…)

При проверке из подписи восстанавливают открытый ключ, выводят из него адрес того типа, который указан в заголовке, и сравнивают с заявленным. Ни транзакции, ни скрипта здесь нет: формат не может выразить «этот адрес с мультиподписью» или «этот адрес Taproot», только «владелец этого ключа». Для всего, что выходит за эти рамки, BIP-137 сам отсылает к BIP-322.

Модуль ниже реализует устаревший формат, а дальше в том же файле — простые подписи BIP-322 для P2WPKH; в нём используются @noble/curves и @scure/base:

btcmsg.mjs (@noble/curves 2.4.0, @noble/hashes 2.4.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 { 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 });
}

Что поддерживает Bitcoin Core

RPC-методы signmessage и verifymessage в Bitcoin Core реализуют устаревший формат, и только для устаревших адресов. Узел regtest, по одному адресу каждого типа:

run.sh (bitcoin/bitcoin:31.1)
#!/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 

Bitcoin Core возвращает ошибку Address does not refer to key для любого адреса SegWit и Taproot — и при подписи, и при проверке подписи, которую создал кто-то другой. Первый байт устаревшей подписи, в этих запусках 31 или 32 (H или I в base64), обозначает сжатый ключ P2PKH.

Наша реализация выдаёт те же байты, что и Bitcoin Core. Ключ сгенерировали специально для этого запуска и импортировали в кошелёк regtest как дескриптор pkh(), после чего обе реализации подписали одно и то же сообщение:

Вывод: legacy-prepare.mjs, затем crosscheck.sh в bitcoin/bitcoin:31.1
address mk1giEaBaAFPXNRMQcZNptPAp9kJmttKyp
core signature: H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
our signature:  H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
identical: yes
core verifymessage(our signature): true

Подписи совпадают, потому что обе реализации используют детерминированные nonce (RFC 6979) и нормализацию low-S; подписант со случайными nonce каждый раз получает другую, но столь же верную подпись.

Проблема заголовка для адресов SegWit

Кошельки, которые подписывают сообщения в устаревшем формате для адресов SegWit, расходятся в выборе заголовка. Electrum для адреса любого типа ставит заголовок от 27 до 34, а при проверке перебирает все типы адресов с одним ключом, которые могут соответствовать восстановленному ключу (electrum/bitcoin.py). Trezor следует BIP-137 и прибавляет 4 для P2SH-P2WPKH и 8 для P2WPKH, а параметр no_script_type позволяет вместо этого получить вариант Electrum (trezor-firmware, sign_message.py). Поэтому проверяющей стороне приходится выбирать между строгой проверкой (заголовок должен указывать тип именно этого адреса) и нестрогой (подходит любой адрес восстановленного ключа). Вот оба соглашения на одном и том же ключе и сообщении, проверенные нашим кодом и библиотекой bip322-js 4.0.0:

headers.mjs
// 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}`);
}
Вывод (node:24.22.0-bookworm-slim)
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

Нестрогая проверка принимает обе подписи, а заодно принимает ту же подпись как доказательство и для адреса P2PKH этого ключа. Таков честный смысл устаревшей подписи: «владелец этого ключа» — для любого адреса, выведенного из него. Строгая проверка отклоняет любую подпись для SegWit в стиле Electrum. Если вам приходится принимать устаревшие подписи для адресов SegWit, проверяйте их нестрого и фиксируйте, что проверен именно контроль над ключом.

BIP-322: доказательство контроля над адресом

BIP-322 (bip-0322.mediawiki, статус Complete, версия 2.0.0) не изобретает формат сообщений, а использует механизм транзакций. Строятся две виртуальные транзакции:

  • to_spend: версия 0, один вход, который тратит несуществующий выход (outpoint) 000…000:FFFFFFFF со scriptSig OP_0 PUSH32[message_hash], и один выход на 0 сатоши, в scriptPubKey которого стоит адрес, контроль над которым доказывается. message_hash — тегированный хеш BIP-340, SHA256(SHA256(tag) ‖ SHA256(tag) ‖ message) с тегом BIP0322-signed-message.
  • to_sign: версия 0, один вход, который тратит to_spend:0, и один выход на 0 сатоши в OP_RETURN.

Подписать сообщение — значит потратить to_spend:0 в to_sign так, как кошелёк потратил бы любой выход этого адреса. При проверке эту трату оценивают по стандартным правилам скриптов (SIGHASH_ALL или значение по умолчанию в Taproot, low-S и строгий DER, CLEANSTACK и другие). Ни одна из двух транзакций не действительна в сети, поэтому подпись невозможно отправить в сеть, чтобы переместить средства.

Кодировки:

ВариантПрефиксСодержимоеПрименение
Простойsmpстек witness в base64нативный SegWit и Taproot без блокировок по времени
Полныйfulвся подписанная транзакция to_sign в base64любой скрипт, включая вложенный SegWit, устаревшие адреса и блокировки по времени
Полный (доказательство наличия средств)pofфинализированная PSBT транзакции to_sign с дополнительными входамидоказательство контроля над конкретными UTXO
Устаревшийнетподпись BIP-137допускается только для устаревших адресов P2PKH

Префиксы вариантов появились в версии 2.0.0, и теперь подписанты ДОЛЖНЫ их указывать. Для совместимости «при отсутствии префикса проверяющая сторона может считать вариант простым» — благодаря этому подписи кошельков, написанных по прежней редакции, продолжают проходить проверку. У проверки три исхода, а не два: подпись верна, неверна или результат не определён (inconclusive), если проверяющая сторона не может выполнить задействованные скрипты.

Мультиподпись и аппаратные кошельки: подпись через PSBT

Для адреса с мультиподписью witness должен содержать подписи нескольких ключей, которые хранятся у разных людей или на разных устройствах. BIP-322 решает эту задачу инструментами, которые у пользователей мультиподписи уже есть: транзакцию to_sign передают между участниками как PSBT с новым глобальным полем PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (тип 0x09), в котором лежит сообщение. Если подписант видит это поле, первый вход, который тратит выход 0 соответствующей транзакции to_spend, и единственный выход OP_RETURN с нулевой суммой, он должен показать пользователю сообщение и адрес, а не транзакцию, а затем подписать так же, как подписал бы трату. Каждый из совместных подписантов добавляет частичную подпись; финализированный результат становится подписью ful. Устройство, которое не знает этого поля, видит нечто похожее на транзакцию, тратящую 0 сатоши, поэтому поддержка этого сценария в кошельках важнее криптографии.

В BIP указаны две эталонные реализации: pull request в Bitcoin Core с базовой поддержкой (#24058), который закрыли без слияния, и pull request в btcd с полной поддержкой (#2521), из которого взяты сгенерированные тестовые векторы и который на 20 октября 2026 года всё ещё открыт. Ни одна из них не вошла в релиз, и отчасти поэтому каждый из тех кошельков и библиотек в таблице ниже, которые поддерживают BIP-322, реализует его самостоятельно.

Проверка на тестовых векторах BIP

В состав BIP-322 входят базовые тестовые векторы: хеши сообщений и идентификаторы транзакций для трёх сообщений, а также простые подписи для адреса P2WPKH. Для их проверки нужны только адрес, сообщение и подпись. Те же подписи, с новым префиксом и без него, мы передали и в bip322-js 4.0.0:

vectors.mjs
// 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)}`);
Вывод (node:24.22.0-bookworm-slim)
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

Реализация выше воспроизводит каждый хеш, успешно проверяет каждую простую подпись P2WPKH, с префиксом и без, и отклоняет вектор ошибки. bip322-js 4.0.0, широко используемая библиотека на JavaScript, не понимает префикс smp: она декодирует его как base64 и выбрасывает ошибку buffer too small, хотя та же подпись без префикса проходит проверку. Поэтому подписи, созданные новыми кошельками, не пройдут проверку в сервисах на этой библиотеке, пока её не обновят; для простых подписей рабочий обходной путь — отрезать известный префикс перед её вызовом.

Кто что создаёт и принимает

По исходному коду каждого проекта или, где это отмечено, по тестовому запуску, по состоянию на октябрь 2026 года:

ПрограммаУстаревший формат (BIP-137)BIP-322Примечания
Bitcoin Core 31.1только P2PKHнетпроверено выше
Electrum (master)P2PKH, P2WPKH, P2SH-P2WPKH, заголовок 27–34нетпроверяет оба соглашения о заголовке; отклоняет адреса, у которых не один ключ
Прошивка Trezor (main)P2PKH, P2SH-P2WPKH, P2WPKH, заголовки BIP-137нетTaproot: «Unsupported script type»
Sparrow (drongo master)дапростые подписи с префиксами smp/ful/pofпроверка: простой вариант, P2WPKH и P2TR с одним ключом
bip322-js 4.0.0да, нестрогая или строгая проверкапростые подписи: P2WPKH, P2SH-P2WPKH, P2TR с одним ключомбез префиксов; см. примечание выше

Практические выводы: контроль над адресом Taproot можно доказать только через BIP-322; для адреса с мультиподписью устаревший формат не подходит вообще; а сервису, который хочет принимать доказательства от пользователей всех этих кошельков, нужно принимать устаревшие подписи (для SegWit — с нестрогой проверкой), простые подписи BIP-322 без префикса и подписи с префиксом.

Как проверять подписи в сервисе

  1. Задавайте сообщение сами. Включите в него свой домен, nonce или метку времени и назначение ("Proof of ownership for example.com, nonce 7f3a…") и принимайте каждый nonce только один раз — точно так же, как в Sign-In with Ethereum. Подпись под типовым текстом можно повторно предъявить любому другому сервису, который просит подписать тот же текст.
  2. Сравнивайте байты точно. Оба формата подписывают сообщение как есть. Интерфейс Electrum перед подписью удаляет пробельные символы в начале и в конце; лишний перевод строки в конце после копирования и вставки или окончания строк CRLF дают другой хеш, и подпись оказывается «неверной».
  3. Определяйте формат в таком порядке: префикс smp, ful или pof; строка base64, которая декодируется ровно в 65 байт с первым байтом от 27 до 42 (устаревший формат); в остальных случаях пробуйте простую подпись BIP-322 без префикса.
  4. Решите, что доказывает устаревшая подпись для адресов SegWit: нестрогая проверка доказывает контроль над ключом, а для адресов с одним ключом это и есть контроль над адресом.
  5. Считайте неопределённый результат непроверенным. Проверяющая сторона, которая не может выполнить скрипт (блокировки по времени, необычные скрипты), не должна сообщать об успехе.
  6. Для доказательства наличия средств сверяйтесь с блокчейном. Подпись pof доказывает контроль над перечисленными выходами на момент подписи; не потрачены ли они с тех пор, нужно проверять отдельно, и такая подпись никогда не доказывает, что это все выходы адреса.

Источники