Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- added: `signatureFormat` option on `signMessage`, accepting `electrum` (the default) or `bip137`. Existing callers keep the legacy Electrum header byte; BIP137 is opt-in.
- changed: `signMessage` throws a named `AddressNotOwnedError` when the wallet cannot sign for the requested address, so callers no longer match on error message text.
- fixed: Correct the `messagePrefix` magic strings for 14 coins so signed messages verify against each chain's own node. Affects dash, pivx, qtum, ravencoin, smartcash, uniformfiscalobject, ecash, zcoin, litecoin, dogecoin, digibyte, feathercoin, bitcoingold and bitcoingoldtestnet.
- fixed: Encode BIP137 message signatures with the correct header byte for SegWit addresses (bip49 nested SegWit and bip84 native SegWit) and with each coin's own message prefix, so signatures verify on BIP137-compliant platforms across all UTXO currencies.

## 3.11.0 (2026-07-13)

- added: Support the `<code>-wif:` protohandler prefix (e.g. `bch-wif:`) in `parseUri` so CashStamps private keys can be swept.
Expand Down
23 changes: 18 additions & 5 deletions src/common/utxobased/engine/UtxoEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ import {
scriptPubkeyToAddress,
signTx
} from '../keymanager/keymanager'
import { asMaybeInsufficientFundsErrorPlus } from '../keymanager/types'
import {
AddressNotOwnedError,
asMaybeInsufficientFundsErrorPlus
} from '../keymanager/types'
import { transactionSizeFromHex } from '../keymanager/utxopicker/utils'
import { createPayment, getPaymentDetails, sendPayment } from './paymentRequest'
import {
Expand Down Expand Up @@ -797,11 +800,20 @@ export async function makeUtxoEngine(
opts: EdgeSignMessageOptions
): Promise<string> {
const otherParams = asUtxoSignMessageOtherParams(opts.otherParams)
const { publicAddress } = otherParams
const scriptPubkey = walletTools.addressToScriptPubkey(publicAddress)
const { publicAddress, signatureFormat } = otherParams
// An address the wallet cannot sign for reaches us two ways: it parses
// but is not ours, or it is not an address of this chain at all and
// `addressToScriptPubkey` throws. Both are the same thing to a caller,
// so give them one named error rather than assorted parser prose.
let scriptPubkey: string
try {
scriptPubkey = walletTools.addressToScriptPubkey(publicAddress)
} catch {
throw new AddressNotOwnedError()
}
const addressData = await dataLayer.fetchAddress(scriptPubkey)
if (addressData?.path == null) {
throw new Error('Missing data-layer address to sign with')
throw new AddressNotOwnedError()
}
const privateKey = asMaybeCurrencyPrivateKey(privateKeys)

Expand All @@ -818,7 +830,8 @@ export async function makeUtxoEngine(
const signature = await walletTools.signMessageBase64({
path: addressData?.path,
message,
xprivKeys
xprivKeys,
signatureFormat
})
return signature
},
Expand Down
17 changes: 15 additions & 2 deletions src/common/utxobased/engine/UtxoWalletTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
xprivToPrivateKey,
xpubToPubkey
} from '../keymanager/keymanager'
import { UtxoSignatureFormat } from '../keymanager/types'
import {
CurrencyFormatKeys,
currencyFormatToPurposeType,
Expand Down Expand Up @@ -92,6 +93,7 @@ interface SignMessageArgs {
path: AddressPath
message: string
xprivKeys: CurrencyFormatKeys
signatureFormat: UtxoSignatureFormat
}

export function makeUtxoWalletTools(
Expand Down Expand Up @@ -236,12 +238,23 @@ export function makeUtxoWalletTools(
return { address, scriptPubkey, redeemScript }
},

signMessageBase64({ path, message, xprivKeys }: SignMessageArgs): string {
signMessageBase64({
path,
message,
xprivKeys,
signatureFormat
}: SignMessageArgs): string {
const privKey = fns.getPrivateKey({
path,
xprivKeys
})
return signMessageBase64(message, privKey)
return signMessageBase64(
message,
privKey,
path.format,
coin,
signatureFormat
)
}
}

Expand Down
8 changes: 7 additions & 1 deletion src/common/utxobased/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { EdgeSpendInfo } from 'edge-core-js/types'

import { asTxOptions } from '../../plugin/types'
import { asUtxoSignatureFormat } from '../keymanager/types'
import { Input, Output } from '../keymanager/utxopicker/types'

export interface UtxoInitOptions {
Expand Down Expand Up @@ -42,7 +43,12 @@ export type UtxoSignMessageOtherParams = ReturnType<
typeof asUtxoSignMessageOtherParams
>
export const asUtxoSignMessageOtherParams = asObject({
publicAddress: asString
publicAddress: asString,
// Defaults to the legacy Electrum encoding so existing callers keep the
// format they already produce. BIP-137 is opt-in, since emitting a BIP-137
// header to a verifier expecting the legacy one is just as broken as the
// reverse.
signatureFormat: asMaybe(asUtxoSignatureFormat, 'electrum')
})

const asOutputSort = asValue('bip69', 'targets')
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/bitcoingold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const coinInfo: CoinInfo = {
coinType: 156,

prefixes: {
messagePrefix: ['\x18Bitcoin Gold Signed Message:\n'],
messagePrefix: ['\x1dBitcoin Gold Signed Message:\n'],
wif: [0x80],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/bitcoingoldtestnet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export const coinInfo: CoinInfo = {
coinType: 1,

prefixes: {
messagePrefix: ['\x18Bitcoin Gold Signed Message:\n'],
messagePrefix: ['\x1dBitcoin Gold Signed Message:\n'],
wif: [0xef],
legacyXPriv: [0x04358394],
legacyXPub: [0x043587cf],
Expand Down
3 changes: 2 additions & 1 deletion src/common/utxobased/info/dash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ export const coinInfo: CoinInfo = {
segwit: false,
coinType: 5,
prefixes: {
messagePrefix: ['unused'],
// Dash kept its pre-rebrand DarkCoin magic; not a typo.
messagePrefix: ['\x19DarkCoin Signed Message:\n'],
wif: [0xcc],
legacyXPriv: [0x02fe52f8],
legacyXPub: [0x02fe52cc],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/digibyte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export const coinInfo: CoinInfo = {
segwit: true,
coinType: 20,
prefixes: {
messagePrefix: ['\x18Digibyte Signed Message:\n'],
messagePrefix: ['\x19DigiByte Signed Message:\n'],
wif: [0x80, 0x9e],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/dogecoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export const coinInfo: CoinInfo = {
coinType: 3,
utxoPicker: makeDogeUtxoPicker(),
prefixes: {
messagePrefix: ['\x18Dogecoin Signed Message:\n'],
messagePrefix: ['\x19Dogecoin Signed Message:\n'],
wif: [0x9e],
legacyXPriv: [0x02fac398],
legacyXPub: [0x02facafd],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/ecash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const coinInfo: CoinInfo = {
includeCashaddrPrefix: true,

prefixes: {
messagePrefix: ['\x18Bitcoin Signed Message::\n'],
messagePrefix: ['\x16eCash Signed Message:\n'],
wif: [0x80],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/feathercoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const coinInfo: CoinInfo = {
segwit: true,
coinType: 8,
prefixes: {
messagePrefix: ['\x18Feathercoin Signed Message:\n'],
messagePrefix: ['\x1cFeathercoin Signed Message:\n'],
wif: [0x8e],
legacyXPriv: [0x0488daee],
legacyXPub: [0x0488bc26],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/litecoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const coinInfo: CoinInfo = {
segwit: true,
coinType: 2,
prefixes: {
messagePrefix: ['\x18Litecoin Signed Message:\n'],
messagePrefix: ['\x19Litecoin Signed Message:\n'],
wif: [0xb0],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
3 changes: 2 additions & 1 deletion src/common/utxobased/info/pivx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export const coinInfo: CoinInfo = {
/*
[Prefix reference](https://github.com/PIVX-Project/PIVX/blob/b89a9fc04e9e31bb3fd009df0506c957a00ad536/src/chainparams.cpp#L329)
*/
messagePrefix: ['PIVX Signed Message:\n'],
// PIVX inherited DarkNet from its Dash fork ancestry; not a typo.
messagePrefix: ['\x18DarkNet Signed Message:\n'],
wif: [0xd4],
legacyXPriv: [0x0221312b],
legacyXPub: [0x022d2533],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/qtum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export const coinInfo: CoinInfo = {
segwit: false,
coinType: 2301,
prefixes: {
messagePrefix: ['\x18Bitcoin Signed Message:\n'],
messagePrefix: ['\x15Qtum Signed Message:\n'],
wif: [0x80],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/ravencoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export const coinInfo: CoinInfo = {
segwit: false,
coinType: 175,
prefixes: {
messagePrefix: ['\x18Bitcoin Signed Message:\n'],
messagePrefix: ['\x16Raven Signed Message:\n'],
wif: [0x80],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/smartcash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const coinInfo: CoinInfo = {
bs58EncodeFunc: base58smart.encode,
wifEncodeFunc: wifsmart.encode,
prefixes: {
messagePrefix: ['\x18Bitcoin Signed Message:\n'],
messagePrefix: ['\x1aSmartCash Signed Message:\n'],
wif: [0xbf],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/ufo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const coinInfo: CoinInfo = {
segwit: true,
coinType: 202,
prefixes: {
messagePrefix: ['\x18Bitcoin Signed Message:\n'],
messagePrefix: ['\x14UFO Signed Message:\n'],
wif: [0x9b],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
1 change: 1 addition & 0 deletions src/common/utxobased/info/vertcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export const coinInfo: CoinInfo = {
segwit: true,
coinType: 28,
prefixes: {
// Vertcoin Core genuinely uses Bitcoin's magic; not an oversight.
messagePrefix: ['\x18Bitcoin Signed Message:\n'],
wif: [0x80],
legacyXPriv: [0x0488ade4],
Expand Down
2 changes: 1 addition & 1 deletion src/common/utxobased/info/zcoin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const coinInfo: CoinInfo = {
segwit: false,
coinType: 136,
prefixes: {
messagePrefix: ['\x18Zcoin Signed Message:\n'],
messagePrefix: ['\x16Zcoin Signed Message:\n'],
wif: [0xd2],
legacyXPriv: [0x0488ade4],
legacyXPub: [0x0488b21e],
Expand Down
62 changes: 58 additions & 4 deletions src/common/utxobased/keymanager/keymanager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import { EdgeLog, EdgeMemo } from 'edge-core-js/types'

import { indexAtProtected } from '../../../util/indexAtProtected'
import { undefinedIfEmptyString } from '../../../util/undefinedIfEmptyString'
import { ChangePath, CoinInfo, CoinPrefixes, FeeInfo } from '../../plugin/types'
import {
ChangePath,
CoinInfo,
CoinPrefixes,
CurrencyFormat,
FeeInfo
} from '../../plugin/types'
import { UtxoData } from '../db/types'
import { ScriptTemplate, ScriptTemplates } from '../info/scriptTemplates/types'
import { sortInputs, sortOutputs } from './bip69'
Expand All @@ -28,7 +34,7 @@ import {
hashToCashAddress
} from './bitcoincashUtils/cashAddress'
import { getCoinFromString } from './coinmapper'
import { InsufficientFundsErrorPlus } from './types'
import { InsufficientFundsErrorPlus, UtxoSignatureFormat } from './types'
import * as utxopicker from './utxopicker'
import * as pickerUtils from './utxopicker/utils'

Expand Down Expand Up @@ -938,14 +944,62 @@ export function privateKeyEncodingToPubkey(
}).publicKey.toString('hex')
}

export function signMessageBase64(message: string, privateKey: string): string {
/**
* The `segwitType` to hand `bitcoinjs-message`, or `undefined` to leave the
* header byte in the legacy Electrum range. Only the BIP137 encoding cares
* about the derivation purpose, so only it resolves one.
*/
function getBip137SegwitType(
signatureFormat: UtxoSignatureFormat,
format: CurrencyFormat
): 'p2wpkh' | 'p2sh(p2wpkh)' | undefined {
if (signatureFormat !== 'bip137') return undefined
const purposeType = bip43PurposeNumberToTypeEnum(
parseInt(format.replace('bip', ''))
)
switch (purposeType) {
case BIP43PurposeTypeEnum.Segwit:
return 'p2wpkh'
case BIP43PurposeTypeEnum.WrappedSegwit:
return 'p2sh(p2wpkh)'
default:
return undefined
}
}

export function signMessageBase64(
message: string,
privateKey: string,
format: CurrencyFormat,
coin: string,
signatureFormat: UtxoSignatureFormat = 'electrum'
): string {
const ECPair = getECPair()
const keyPair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'))
if (keyPair.privateKey == null) {
throw new Error('Address could not sign message')
}
// BIP137 encodes the address type in the signature's header byte. Native
// SegWit (bip84 / P2WPKH) and nested SegWit (bip49 / P2SH-P2WPKH) each have
// their own header range; verifiers such as Bringin reject a SegWit address
// whose message was signed with a legacy (bip44) header byte. The derivation
// path is what tells us the script type, so no caller has to infer it from
// the address string. Callers opt in, since emitting a BIP137 header to a
// verifier that expects the legacy Electrum encoding is just as broken.
// The purpose lookup stays inside the BIP137 branch on purpose:
// `bip43PurposeNumberToTypeEnum` throws on a purpose it does not map, so
// computing it unconditionally would let a future `CurrencyFormat` (bip86
// Taproot being the obvious candidate) break the default Electrum path,
// which never needs it.
const segwitType = getBip137SegwitType(signatureFormat, format)
// Magic-hash the message with the coin's own prefix (e.g. "Litecoin Signed
// Message:\n") so the signature verifies against that coin's addresses.
// Every UTXO coin routes through here, not just Bitcoin.
const messagePrefix = getCoinFromString(coin).prefixes.messagePrefix[0]
Comment thread
j0ntz marked this conversation as resolved.
return bitcoinMessage
.sign(message, keyPair.privateKey, keyPair.compressed)
.sign(message, keyPair.privateKey, keyPair.compressed, messagePrefix, {
segwitType
})
.toString('base64')
}

Expand Down
29 changes: 28 additions & 1 deletion src/common/utxobased/keymanager/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { Cleaner } from 'cleaners'
import { asValue, Cleaner } from 'cleaners'
import { EdgeTokenId, InsufficientFundsError } from 'edge-core-js/types'

/**
* How to encode the header byte of a signed message.
* - `electrum`: the legacy format, whose header carries only the recovery id
* and key compression. Every verifier understands it, but it says nothing
* about the script type.
* - `bip137`: additionally encodes the signing address' script type, which
* verifiers such as Bringin require for SegWit addresses.
*/
export const asUtxoSignatureFormat = asValue('electrum', 'bip137')
export type UtxoSignatureFormat = ReturnType<typeof asUtxoSignatureFormat>

interface InsufficientFundsErrorOptsPlus {
// The currency we need more of:
tokenId: EdgeTokenId
Expand All @@ -27,6 +38,22 @@ function asMaybeError<T>(name: string): Cleaner<T | undefined> {
}
}

/**
* The wallet cannot sign for the requested address: either it does not derive
* it, or the string is not an address of this chain at all. Callers get a
* stable `name` to branch on instead of matching against the message prose.
*/
export class AddressNotOwnedError extends Error {
constructor() {
super('Wallet does not own the address to sign with')
this.name = 'AddressNotOwnedError'
}
}

export const asMaybeAddressNotOwnedError = asMaybeError<AddressNotOwnedError>(
'AddressNotOwnedError'
)

export const asMaybeInsufficientFundsErrorPlus = asMaybeError<InsufficientFundsErrorPlus>(
// Share the same name because this error type is a subtype of
// InsufficientFundsError and therefore backwards compatible
Expand Down
Loading
Loading