Skip to content

Encode BIP137 SegWit message signature headers - #454

Merged
j0ntz merged 2 commits into
masterfrom
jon/bip137-btc-message-signing
Aug 5, 2026
Merged

Encode BIP137 SegWit message signature headers#454
j0ntz merged 2 commits into
masterfrom
jon/bip137-btc-message-signing

Conversation

@j0ntz

@j0ntz j0ntz commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

Dependencies

none

Description

Asana task

Edge's BTC message signing (used for CEX/ramp withdrawal ownership proofs, e.g. Bringin / NiceHash Travel Rule) produced a signature whose header byte always encoded a legacy P2PKH type (27-34), regardless of the signing address. BIP137 verifiers reject a legacy-header signature that comes from a SegWit address: nested SegWit (P2SH-P2WPKH) requires header 35-38 and native SegWit (P2WPKH / bech32) requires 39-42. Because Edge BTC wallets default to SegWit, the signatures Edge emitted were rejected by Bringin; a user had to run a script to rewrite the header to BIP137 before it was accepted, or fall back to selfie/ID verification.

signMessageBase64 in keymanager.ts called bitcoinMessage.sign(message, privKey, compressed) with no segwitType, so the header byte only reflected the compressed flag. This threads the address's derivation format through and selects the matching segwitType:

  • bip84 (native SegWit / P2WPKH) -> header 39-42
  • bip49 (nested SegWit / P2SH-P2WPKH) -> header 35-38
  • bip44 / bip32 (legacy P2PKH) -> header 31-34 (unchanged)

The header selection routes through the canonical BIP43PurposeTypeEnum rather than raw format string literals, so a newly-added SegWit format fails loudly in the purpose mapping instead of silently falling through to a legacy header.

The ECDSA signature is unchanged for legacy P2PKH signing on Bitcoin; only the header byte differs there. The fix is general across all UTXO coins that support SegWit, since path.format reflects the actual address derivation.

BIP137 is opt-in

asUtxoSignMessageOtherParams carries signatureFormat: 'electrum' | 'bip137', defaulting to electrum. segwitType is applied only when bip137 is requested, so existing callers (bityProvider, EdgeProviderServer) keep the header byte they produce today. Emitting BIP137 unconditionally would have been a silent behavior change for them, and it would have broken EdgeApp/edge-react-gui#6065 depending on merge order: that PR's "Standard (Electrum)" option would have started emitting BIP137 headers. Making it opt-in removes the ordering hazard, and the GUI now sends the option instead of rewriting the header itself.

Scoped purpose lookup

bip43PurposeNumberToTypeEnum throws on a purpose it does not map, so resolving it unconditionally would let a future CurrencyFormat (bip86 Taproot being the obvious candidate) break message signing on the default Electrum path, which never needs it. The lookup lives in a getBip137SegwitType helper that returns before touching it unless BIP137 is requested, so an unmapped format can only affect BIP137 signing.

AddressNotOwnedError

signMessage throws a named AddressNotOwnedError when the wallet cannot sign for the requested address, whether it fails to parse or simply is not ours. Callers branch on error.name rather than matching parser prose, following the existing asMaybeInsufficientFundsErrorPlus pattern.

Coin message prefixes

The header byte alone is not enough for non-Bitcoin coins. signMessageBase64 previously magic-hashed with bitcoinjs-message's default "Bitcoin Signed Message:\n" for every coin, so an altcoin signature carried the right header but the wrong prefix and would not verify against that coin's own addresses. It now hashes with the coin's own prefixes.messagePrefix[0].

That made the prefix data load-bearing for the first time, and an audit by @peachbits (commit absorbed here, authored by them and ordered first) found 14 coins wrong. bitcoinjs-message copies the prefix into the hash verbatim and only varint-encodes the message length, so the prefix must carry its own leading CompactSize byte, mirroring ss << strMessageMagic in each coin's C++. Two failure classes: a wrong length byte (litecoin, dogecoin, feathercoin, zcoin, bitcoingold and its testnet) and a wrong string, usually Bitcoin's default left in place (dash, pivx, ecash, qtum, ravencoin, smartcash, ufo, digibyte). Dash's DarkCoin, PIVX's DarkNet, and Vertcoin's genuine use of Bitcoin's magic are pre-rebrand or intentional and are commented so they do not get "fixed" back.

Testing

  • Unit tests pin both encodings on bitcoin and litecoin: the default produces the same H… header for bip44, bip49 and bip84, and only an explicit bip137 request shifts bip49 to I… and bip84 to J…. 12 cases, so swapping the two formats fails the suite. tsc, eslint, and the full mocha suite pass (1251 tests); npm run verify passes.
  • messagePrefix.spec.ts asserts every registered coin's prefix leading byte matches its string length, covering 24 coins. This is the guard that makes a silently-unverifiable prefix a build failure.
  • Litecoin sign-message vectors verify via bitcoinjs-message.verify against the derived legacy, P2SH-P2WPKH, and bech32 LTC addresses, and are confirmed NOT to verify under Bitcoin's prefix, so a regression back to the default would fail the suite.
  • Offline round-trip: each Bitcoin format's signature verifies via bitcoinjs-message.verify against its real P2PKH / P2SH-P2WPKH / bech32 address.
  • In-app (iOS sim, edge-funds BTC wallet): drove the real EdgeCurrencyWallet.signMessage path with this build linked, on the wallet's own receive addresses. The nested-SegWit 3... address produced header 35 and the native-SegWit bc1q... address produced header 40, both verifying against their respective addresses (screenshot attached). Before the fix both emitted header 31, which fails BIP137 verification for a SegWit address.
  • In-app, opt-in round (iOS sim, this build linked into edge-react-gui#6065 via updot): signing the same message with bip84 address bc1q7f5wkp5k0847utsec7p8v59cqj7et6wrzz7dv4 produced header 32 under the default and header 40 under signatureFormat: 'bip137', identical r/s, both verifying against that address via bitcoinMessage.verify(..., checkSegwitAlways: true). That confirms the default no longer emits a SegWit header.

Asana: https://app.asana.com/1/9976422036640/project/1215088146871429/task/1216403654258303


Note

Medium Risk
Changes message-signing output for altcoins (prefix fixes) and opt-in BIP137 headers; default Electrum behavior is preserved but wrong-prefix fixes alter signatures that may have been used in production for non-BTC chains.

Overview
UTXO message signing is extended so ramp/CEX ownership proofs (e.g. BIP137 verifiers) can succeed without clients rewriting signatures.

signMessage accepts optional signatureFormat: 'electrum' | 'bip137' (default electrum). With bip137, signMessageBase64 sets bitcoinjs-message segwitType from the address derivation path (bip49 → nested SegWit, bip84 → native SegWit). Signing now uses each coin’s messagePrefix instead of Bitcoin’s default, and 14 altcoins had wrong prefix strings/length bytes corrected.

signMessage throws AddressNotOwnedError when the address is invalid for the chain or not owned by the wallet (replacing generic errors).

Tests add Bitcoin/Litecoin vectors for both encodings and messagePrefix.spec guards prefix CompactSize bytes across all registered coins.

Reviewed by Cursor Bugbot for commit 10d018f. Bugbot is set up for automated code reviews on this repo. Configure here.

@j0ntz

j0ntz commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence: in-app BIP137 signatures (iOS sim, edge-funds BTC wallet)

agent proof 1216403654258303 01 bip137 signatures

agent proof 1216403654258303 01 bip137 signatures

Captured by the agent's in-app test run (build-and-test).

@j0ntz j0ntz left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated code review (workflow-backed, high effort). All findings are advisory; the core BIP137 header fix is correct and matches the task requirement. Three notes below.

Comment thread src/common/utxobased/keymanager/keymanager.ts Outdated
Comment thread src/common/utxobased/keymanager/keymanager.ts Outdated
Comment thread test/common/utxobased/keymanager/coins/keymanagertest.spec.ts Outdated
@j0ntz
j0ntz force-pushed the jon/bip137-btc-message-signing branch from 0c9e6a2 to 9594db2 Compare July 9, 2026 20:15
Comment thread src/common/utxobased/keymanager/keymanager.ts
j0ntz pushed a commit that referenced this pull request Aug 3, 2026
These values have been inert until now: bitcoinjs-lib never reads
network.messagePrefix, and signMessageBase64 passed no prefix, so every coin
signed with bitcoinjs-message's Bitcoin default. PR #454 wires messagePrefix
into the magic hash, which makes 14 of them wrong in a way that silently
produces unverifiable signatures.

bitcoinjs-message copies the prefix into the hash verbatim and only
varint-encodes the message length, so the prefix must carry its own leading
CompactSize byte. This mirrors `ss << strMessageMagic` in the coins' C++, which
serializes the std::string as CompactSize(len) + bytes. Two failure classes:

Wrong length byte (string was right):
  litecoin            \x18 -> \x19
  dogecoin            \x18 -> \x19
  feathercoin         \x18 -> \x1c
  zcoin               \x18 -> \x16
  bitcoingold         \x18 -> \x1d
  bitcoingoldtestnet  \x18 -> \x1d

Wrong string (mostly Bitcoin's default left in place):
  dash        'unused'                      -> '\x19DarkCoin Signed Message:\n'
  pivx        'PIVX Signed Message:\n'      -> '\x18DarkNet Signed Message:\n'
  ecash       'Bitcoin Signed Message::\n'  -> '\x16eCash Signed Message:\n'
  qtum        'Bitcoin Signed Message:\n'   -> '\x15Qtum Signed Message:\n'
  ravencoin   'Bitcoin Signed Message:\n'   -> '\x16Raven Signed Message:\n'
  smartcash   'Bitcoin Signed Message:\n'   -> '\x1aSmartCash Signed Message:\n'
  ufo         'Bitcoin Signed Message:\n'   -> '\x14UFO Signed Message:\n'
  digibyte    '\x18Digibyte...'             -> '\x19DigiByte Signed Message:\n'

Dash's DarkCoin and PIVX's DarkNet are pre-rebrand names their Core still uses,
and Vertcoin genuinely signs with Bitcoin's magic; all three are commented so
they don't get "fixed" back.

Sources (MESSAGE_MAGIC / strMessageMagic in each coin's Core):

  dash        https://github.com/dashpay/dash/blob/master/src/util/message.cpp
  pivx        https://github.com/PIVX-Project/PIVX/blob/master/src/util/validation.cpp#L27
  zcoin       https://github.com/firoorg/firo/blob/master/src/validation.cpp
  litecoin    https://github.com/litecoin-project/litecoin/blob/master/src/util/message.cpp
  dogecoin    https://github.com/dogecoin/dogecoin/blob/master/src/validation.cpp
  digibyte    https://github.com/digibyte-core/digibyte/blob/master/src/util/message.cpp
  feathercoin https://github.com/FeatherCoin/Feathercoin/blob/master/src/util/message.cpp
  bitcoingold https://github.com/BTCGPU/BTCGPU/blob/master/src/util/message.cpp
  qtum        https://github.com/qtumproject/qtum/blob/master/src/util/signstr.h
  ravencoin   https://github.com/RavenProject/Ravencoin/blob/master/src/validation.cpp
  smartcash   https://github.com/SmartCash/Core-Smart/blob/master/src/validation.cpp
  ufo         https://github.com/UFOCoins/ufo/blob/master/src/validation.cpp
  ecash       https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/src/common/signmessage.cpp

Verified correct and left alone: bitcoin (+testnets), bitcoincash (+testnet),
bitcoinsv, badcoin, groestlcoin and vertcoin.

  groestlcoin https://github.com/Groestlcoin/groestlcoin/blob/master/src/common/signmessage.cpp
  vertcoin    https://github.com/vertcoin-project/vertcoin-core/blob/master/src/util/message.cpp
  bitcoinsv   https://github.com/bitcoin-sv/bitcoin-sv/blob/master/src/validation.cpp
  bitcoincash https://github.com/bitcoin-cash-node/bitcoin-cash-node/blob/master/src/validation.cpp

eboost is left as-is: the project is dead and its source is gone, so its magic
could not be verified.

Adds a test asserting every messagePrefix's leading byte matches its own
length, since a mismatch is invisible until someone tries to verify a
signature. Confirmed it fails when litecoin is reverted to \x18.
@j0ntz
j0ntz force-pushed the jon/bip137-btc-message-signing branch from 9594db2 to 2dcb071 Compare August 3, 2026 21:48
@j0ntz

j0ntz commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Updated in 4cb6e2b to resolve the cross-PR conflict raised on EdgeApp/edge-react-gui#6065.

BIP-137 is now opt-in rather than unconditional. asUtxoSignMessageOtherParams carries signatureFormat: 'electrum' | 'bip137', defaulting to electrum, threaded through UtxoEngine.signMessageUtxoWalletTools.signMessageBase64keymanager.signMessageBase64, which applies segwitType only when BIP-137 is requested. Existing callers (bityProvider, EdgeProviderServer) keep the header byte they produce today, so this stops being a silent behavior change for them and merge order with #6065 no longer matters.

Also here:

  • signMessage throws a named AddressNotOwnedError for an address the wallet cannot sign for, whether it fails to parse or simply is not ours. Callers branch on error.name instead of matching parser prose (same pattern as asMaybeInsufficientFundsErrorPlus).
  • The keymanager spec covers both directions on bitcoin and litecoin: the default encoding yields the same H… header for bip44/bip49/bip84, and only an explicit bip137 request shifts bip49 to I… and bip84 to J…. 12 cases, so swapping the two formats fails the suite.

npm run verify passes locally (1251 tests). Driven end to end on the iOS sim with this build linked into the GUI: signing with a bip84 bc1q… address produced header 32 under the default and header 40 under BIP-137, identical r/s, both verifying against that address via bitcoinMessage.verify(..., checkSegwitAlways: true).

Comment thread src/common/utxobased/keymanager/keymanager.ts Outdated
@j0ntz
j0ntz force-pushed the jon/bip137-btc-message-signing branch from 4cb6e2b to d8f9bbc Compare August 5, 2026 21:06

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit d8f9bbc. Configure here.

Comment thread src/common/utxobased/keymanager/keymanager.ts Outdated
peachbits and others added 2 commits August 5, 2026 16:09
These values have been inert until now: bitcoinjs-lib never reads
network.messagePrefix, and signMessageBase64 passed no prefix, so every coin
signed with bitcoinjs-message's Bitcoin default. PR #454 wires messagePrefix
into the magic hash, which makes 14 of them wrong in a way that silently
produces unverifiable signatures.

bitcoinjs-message copies the prefix into the hash verbatim and only
varint-encodes the message length, so the prefix must carry its own leading
CompactSize byte. This mirrors `ss << strMessageMagic` in the coins' C++, which
serializes the std::string as CompactSize(len) + bytes. Two failure classes:

Wrong length byte (string was right):
  litecoin            \x18 -> \x19
  dogecoin            \x18 -> \x19
  feathercoin         \x18 -> \x1c
  zcoin               \x18 -> \x16
  bitcoingold         \x18 -> \x1d
  bitcoingoldtestnet  \x18 -> \x1d

Wrong string (mostly Bitcoin's default left in place):
  dash        'unused'                      -> '\x19DarkCoin Signed Message:\n'
  pivx        'PIVX Signed Message:\n'      -> '\x18DarkNet Signed Message:\n'
  ecash       'Bitcoin Signed Message::\n'  -> '\x16eCash Signed Message:\n'
  qtum        'Bitcoin Signed Message:\n'   -> '\x15Qtum Signed Message:\n'
  ravencoin   'Bitcoin Signed Message:\n'   -> '\x16Raven Signed Message:\n'
  smartcash   'Bitcoin Signed Message:\n'   -> '\x1aSmartCash Signed Message:\n'
  ufo         'Bitcoin Signed Message:\n'   -> '\x14UFO Signed Message:\n'
  digibyte    '\x18Digibyte...'             -> '\x19DigiByte Signed Message:\n'

Dash's DarkCoin and PIVX's DarkNet are pre-rebrand names their Core still uses,
and Vertcoin genuinely signs with Bitcoin's magic; all three are commented so
they don't get "fixed" back.

Sources (MESSAGE_MAGIC / strMessageMagic in each coin's Core):

  dash        https://github.com/dashpay/dash/blob/master/src/util/message.cpp
  pivx        https://github.com/PIVX-Project/PIVX/blob/master/src/util/validation.cpp#L27
  zcoin       https://github.com/firoorg/firo/blob/master/src/validation.cpp
  litecoin    https://github.com/litecoin-project/litecoin/blob/master/src/util/message.cpp
  dogecoin    https://github.com/dogecoin/dogecoin/blob/master/src/validation.cpp
  digibyte    https://github.com/digibyte-core/digibyte/blob/master/src/util/message.cpp
  feathercoin https://github.com/FeatherCoin/Feathercoin/blob/master/src/util/message.cpp
  bitcoingold https://github.com/BTCGPU/BTCGPU/blob/master/src/util/message.cpp
  qtum        https://github.com/qtumproject/qtum/blob/master/src/util/signstr.h
  ravencoin   https://github.com/RavenProject/Ravencoin/blob/master/src/validation.cpp
  smartcash   https://github.com/SmartCash/Core-Smart/blob/master/src/validation.cpp
  ufo         https://github.com/UFOCoins/ufo/blob/master/src/validation.cpp
  ecash       https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/src/common/signmessage.cpp

Verified correct and left alone: bitcoin (+testnets), bitcoincash (+testnet),
bitcoinsv, badcoin, groestlcoin and vertcoin.

  groestlcoin https://github.com/Groestlcoin/groestlcoin/blob/master/src/common/signmessage.cpp
  vertcoin    https://github.com/vertcoin-project/vertcoin-core/blob/master/src/util/message.cpp
  bitcoinsv   https://github.com/bitcoin-sv/bitcoin-sv/blob/master/src/validation.cpp
  bitcoincash https://github.com/bitcoin-cash-node/bitcoin-cash-node/blob/master/src/validation.cpp

eboost is left as-is: the project is dead and its source is gone, so its magic
could not be verified.

Adds a test asserting every messagePrefix's leading byte matches its own
length, since a mismatch is invisible until someone tries to verify a
signature. Confirmed it fails when litecoin is reverted to \x18.
signMessageBase64 always emitted a legacy header byte (27-34) regardless
of the signing address type, so BIP137 verifiers (e.g. Bringin) rejected
signatures from SegWit addresses. Thread the address format through and
select the matching segwitType: bip49 -> p2sh(p2wpkh) (header 35-38),
bip84 -> p2wpkh (header 39-42), bip44/bip32 unchanged (31-34).

Also magic-hash with each coin's own messagePrefix rather than always
Bitcoin's, so the signature verifies against that coin's addresses.
@j0ntz
j0ntz force-pushed the jon/bip137-btc-message-signing branch from 2bc9171 to 10d018f Compare August 5, 2026 23:10
@j0ntz
j0ntz enabled auto-merge August 5, 2026 23:10
@j0ntz
j0ntz merged commit d1fb8c1 into master Aug 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants