diff --git a/.changeset/canonical-utxo-address-brand.md b/.changeset/canonical-utxo-address-brand.md new file mode 100644 index 0000000000..e63691b085 --- /dev/null +++ b/.changeset/canonical-utxo-address-brand.md @@ -0,0 +1,5 @@ +--- +'@vultisig/sdk': patch +--- + +Add a canonical UTXO address-brand validator and enforce it in the transaction decoder. diff --git a/packages/sdk/src/chains/utxo/addressBrand.ts b/packages/sdk/src/chains/utxo/addressBrand.ts new file mode 100644 index 0000000000..ceb5cade26 --- /dev/null +++ b/packages/sdk/src/chains/utxo/addressBrand.ts @@ -0,0 +1,96 @@ +import { bech32, bech32m } from '@scure/base' +import bs58check from 'bs58check' + +import { isValidCashAddr } from '../../utils/cashaddr' + +export type UtxoChainName = 'Bitcoin' | 'Litecoin' | 'Dogecoin' | 'Dash' | 'Bitcoin-Cash' | 'Zcash' + +const BASE58_VERSION_BYTES: Partial>> = { + Bitcoin: new Set([0x00, 0x05]), + Litecoin: new Set([0x30, 0x32]), + Dogecoin: new Set([0x1e, 0x16]), + Dash: new Set([0x4c, 0x10]), +} + +const SEGWIT_HRP: Partial> = { + Bitcoin: 'bc', + Litecoin: 'ltc', +} + +function hasExpectedSegwitBrand(address: string, expectedHrp: string): boolean { + for (const codec of [bech32, bech32m]) { + try { + const { prefix, words } = codec.decode(address as `${string}1${string}`) + const version = words[0] + if (prefix !== expectedHrp || version === undefined || version > 16) continue + + const program = codec.fromWords(words.slice(1)) + if (program.length < 2 || program.length > 40) continue + if (version === 0) return codec === bech32 && (program.length === 20 || program.length === 32) + return codec === bech32m + } catch { + // Try the other checksum variant, then the non-SegWit formats below. + } + } + + return false +} + +function hasExpectedZcashSaplingBrand(address: string): boolean { + try { + const decoded = bech32.decode(address as `${string}1${string}`) + return decoded.prefix === 'zs' && bech32.fromWords(decoded.words).length === 43 + } catch { + return false + } +} + +function hasExpectedBase58Version(address: string, chain: UtxoChainName): boolean { + let decoded: Uint8Array + try { + decoded = bs58check.decode(address) + } catch { + return false + } + + if (chain === 'Zcash') { + return decoded.length === 22 && decoded[0] === 0x1c && (decoded[1] === 0xb8 || decoded[1] === 0xbd) + } + + const versions = BASE58_VERSION_BYTES[chain] + return decoded.length === 21 && decoded[0] !== undefined && versions?.has(decoded[0]) === true +} + +/** + * Return whether a checksummed UTXO address carries the brand for `chain`. + * + * This validates chain identity, not whether every branded script type is + * spendable by the current transaction builder. The policy is intentionally + * mainnet-only: + * - Bitcoin / Litecoin: Bech32/Bech32m HRP or exact Base58Check version byte + * - Dogecoin / Dash: exact Base58Check version byte + * - Bitcoin Cash: checksummed mainnet CashAddr + * - Zcash: transparent t1/t3 Base58Check versions or Sapling zs Bech32 HRP + * + * Ambiguous legacy Bitcoin Cash and Litecoin P2SH encodings are excluded so a + * Bitcoin-looking address is never accepted for another chain by accident. + */ +export function isUtxoAddressBrandValid(address: string, chain: UtxoChainName): boolean { + const normalized = address.trim() + if (normalized === '') return false + + if (chain === 'Bitcoin-Cash') return isValidCashAddr(normalized) + if (chain === 'Zcash' && hasExpectedZcashSaplingBrand(normalized)) return true + + const expectedHrp = SEGWIT_HRP[chain] + if (expectedHrp && hasExpectedSegwitBrand(normalized, expectedHrp)) return true + + return hasExpectedBase58Version(normalized, chain) +} + +/** Fail closed when an address is malformed or belongs to another UTXO chain. */ +export function assertUtxoAddressBrand(address: string, chain: UtxoChainName): void { + if (!isUtxoAddressBrandValid(address, chain)) { + throw new Error(`UTXO address brand mismatch: expected a valid ${chain} address`) + } +} diff --git a/packages/sdk/src/chains/utxo/index.ts b/packages/sdk/src/chains/utxo/index.ts index ee39b771f2..2bc77b66c8 100644 --- a/packages/sdk/src/chains/utxo/index.ts +++ b/packages/sdk/src/chains/utxo/index.ts @@ -1,3 +1,5 @@ +export type { UtxoChainName } from './addressBrand' +export { assertUtxoAddressBrand, isUtxoAddressBrandValid } from './addressBrand' export type { BroadcastUtxoTxOptions, EstimateUtxoFeeOptions, @@ -15,7 +17,6 @@ export type { DecodedAddress, SighashBIP143Options, SighashLegacyOptions, - UtxoChainName, UtxoInput, UtxoTxBuilderResult, } from './tx' diff --git a/packages/sdk/src/chains/utxo/tx.ts b/packages/sdk/src/chains/utxo/tx.ts index 46ae971c6e..b2cfa21311 100644 --- a/packages/sdk/src/chains/utxo/tx.ts +++ b/packages/sdk/src/chains/utxo/tx.ts @@ -34,6 +34,7 @@ import { getZcashConventionalFee } from '@vultisig/core-chain/chains/utxo/fee/zi import bs58check from 'bs58check' import { CASHADDR_CHARSET, verifyCashAddrChecksum } from '../../utils/cashaddr' +import { assertUtxoAddressBrand, type UtxoChainName } from './addressBrand' // --------------------------------------------------------------------------- // Chain identifiers — string-typed to keep the module free of @vultisig/core-chain. @@ -41,7 +42,7 @@ import { CASHADDR_CHARSET, verifyCashAddrChecksum } from '../../utils/cashaddr' // typed overload. // --------------------------------------------------------------------------- -export type UtxoChainName = 'Bitcoin' | 'Litecoin' | 'Dogecoin' | 'Dash' | 'Bitcoin-Cash' | 'Zcash' +export type { UtxoChainName } from './addressBrand' type UtxoScriptKind = 'p2pkh' | 'p2wpkh' | 'p2sh' @@ -324,17 +325,25 @@ function decodeBase58Address(address: string, chain: UtxoChainName): DecodedAddr * (DOGE/DASH/Zcash/legacy BTC). */ export function decodeAddressToPubKeyHash(address: string, chain: UtxoChainName): DecodedAddress { + assertUtxoAddressBrand(address, chain) + const normalized = address.trim() + const caseInsensitive = normalized.toLowerCase() + + if (chain === 'Zcash' && caseInsensitive.startsWith('zs1')) { + throw new Error(`Cannot decode address: ${address} — Zcash shielded outputs are not supported by this SDK build`) + } + // bech32 (BTC bc1q..., LTC ltc1q...) // We deliberately try bech32 first; non-bech32 addresses fall through to // CashAddr / base58 below. We DO NOT swallow the 32-byte (P2WSH) error // because the caller asked us to encode a script that this SDK can't yet // build — silently treating the 32-byte witness program as a 20-byte // P2WPKH would lock funds under a hash matching no spendable script. - const bech32Decoded = decodeBech32Address(address) + const bech32Decoded = decodeBech32Address(normalized) if (bech32Decoded) return bech32Decoded // CashAddr (BCH bitcoincash:q...) - const cashAddrDecoded = decodeCashAddrAddress(address) + const cashAddrDecoded = decodeCashAddrAddress(caseInsensitive) if (cashAddrDecoded) return cashAddrDecoded // base58check (DOGE D..., Zcash t1..., legacy BTC 1...) @@ -345,7 +354,7 @@ export function decodeAddressToPubKeyHash(address: string, chain: UtxoChainName) // under a generic catch silently re-routes wrong-chain-paste cases (e.g. a // 22-byte Zcash t-address under chain='Dogecoin') back to the same vague // "Cannot decode address" message instead of surfacing the length mismatch. - const base58Decoded = decodeBase58Address(address, chain) + const base58Decoded = decodeBase58Address(normalized, chain) if (base58Decoded) return base58Decoded throw new Error(`Cannot decode address: ${address}`) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 15977e0b23..b9cfd31e97 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -113,6 +113,11 @@ export { checkChainPrefix } from './utils/chainPrefix' export type { ParsedThorSwapMemo } from './utils/thorSwapMemo' export { parseThorSwapMemo } from './utils/thorSwapMemo' +// Canonical UTXO wrong-chain guard. Consumers should import this instead of +// maintaining local bech32 HRP / Base58Check version / CashAddr matrices. +export type { UtxoChainName } from './chains/utxo/addressBrand' +export { assertUtxoAddressBrand, isUtxoAddressBrandValid } from './chains/utxo/addressBrand' + // ============================================================================ // PUBLIC API - Tx Shape Normalization (pure, vault-free) // ============================================================================ diff --git a/packages/sdk/src/platforms/react-native/chains/utxo/index.ts b/packages/sdk/src/platforms/react-native/chains/utxo/index.ts index b4e780fc1c..96515f862f 100644 --- a/packages/sdk/src/platforms/react-native/chains/utxo/index.ts +++ b/packages/sdk/src/platforms/react-native/chains/utxo/index.ts @@ -27,6 +27,7 @@ export type { UtxoTxBuilderResult, } from '../../../../chains/utxo' export { + assertUtxoAddressBrand, broadcastUtxoTx, buildUtxoSendTx, decodeAddressToPubKeyHash, @@ -39,6 +40,7 @@ export { getUtxoBalance, getUtxoChainSpec, getUtxos, + isUtxoAddressBrandValid, selectUtxoInputs, ZCASH_BRANCH_ID_NU6_1, ZCASH_BRANCH_ID_NU6_2, diff --git a/packages/sdk/src/platforms/react-native/index.ts b/packages/sdk/src/platforms/react-native/index.ts index 464818c2de..cab0560de0 100644 --- a/packages/sdk/src/platforms/react-native/index.ts +++ b/packages/sdk/src/platforms/react-native/index.ts @@ -787,6 +787,11 @@ export { ValidationHelpers } from '../../utils/validation' // schema and canonical hashes as Node/browser/desktop clients. export * from '../../signable-transaction' +// Canonical RN-safe UTXO wrong-chain guard. Keep this static export in parity +// with the generic entry so the app can remove its local brand matrix. +export type { UtxoChainName } from '../../chains/utxo/addressBrand' +export { assertUtxoAddressBrand, isUtxoAddressBrandValid } from '../../chains/utxo/addressBrand' + // Dangerous/burn-address guard. Single source of truth for "is this destination // a burn/black-hole address that no key controls?" across EVM, Solana, UTXO and // XRP. Pure address-string matching (no chain-client deps), so RN-safe as a diff --git a/packages/sdk/src/utils/addressFormat.ts b/packages/sdk/src/utils/addressFormat.ts index b785c13253..91e84d478b 100644 --- a/packages/sdk/src/utils/addressFormat.ts +++ b/packages/sdk/src/utils/addressFormat.ts @@ -16,15 +16,15 @@ * - intent-match (does the address match what the user asked for) * - grounding (is the address present in tool output) * - prompt-injection / fabrication detection - * - checksum verification (shape-valid is the contract here, same as the - * backend extractors which validate shape, not cryptographic checksum) + * - checksum verification for non-UTXO families (shape-valid is their + * contract here, same as the backend extractors) * Those stay in the agent backend's judgement layer. This is pure crypto * format-validation, RN-safe (bs58 is pure JS), no network, no signing. */ import bs58 from 'bs58' -import { isValidCashAddr } from './cashaddr' +import { isUtxoAddressBrandValid } from '../chains/utxo/addressBrand' import { normalizeChain } from './normalizeChain' /** @@ -64,8 +64,9 @@ const reBTCLegacy = /^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/ /** * Bitcoin Cash cashaddr SHAPE regex (q/p prefix, 41 chars, optional scheme). * Used only by the loose `classifyAddress` family heuristic. The authoritative - * fund-safety gate uses `isValidCashAddr` (polymod checksum), NOT this regex — - * the regex accepts a single-char typo whose checksum is wrong. + * fund-safety gate uses the canonical UTXO brand validator (including the + * CashAddr polymod checksum), NOT this regex — the regex accepts a single-char + * typo whose checksum is wrong. */ const reBCH = /^(?:bitcoincash:)?[qp][a-z0-9]{41}$/ /** Litecoin bech32 (ltc1...). */ @@ -98,6 +99,10 @@ const reCardano = /** base58-alphabet pre-filter (32-44 chars) before the more expensive decode. */ const reSolanaBase58Alphabet = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ +/** Lowercase a uniformly-cased Bech32/CashAddr value; reject mixed case. */ +const normalizeCaseInsensitiveEncoding = (address: string): string | undefined => + address === address.toLowerCase() || address === address.toUpperCase() ? address.toLowerCase() : undefined + /** * Well-known bech32 HRPs that, when a base58-decodable string starts with one, * mean it is NOT a Solana key even if it decodes to 32 bytes by coincidence. @@ -291,16 +296,17 @@ const chainFormatRules: Record = { // EVM family — all share 0x + 40 hex. ...Object.fromEntries(evmChains.map(chain => [chain, [evmRule]])), solana: [isSolanaAddress], - bitcoin: [re(reBTCNativeSegWit), re(reBTCLegacy)], - // Authoritative fund-safety gate: full CashAddr polymod checksum, not just - // the shape regex — rejects a mistyped BCH address the regex would pass. - bitcoincash: [isValidCashAddr], - litecoin: [re(reLTCBech32), re(reLTCLegacy)], - dogecoin: [re(reDOGE)], - dash: [re(reDASH)], + // UTXO chains use the canonical SDK brand validator so this generic gate, + // the tx decoder, and downstream consumers share one HRP / version-byte / + // CashAddr policy. + bitcoin: [addr => isUtxoAddressBrandValid(addr, 'Bitcoin')], + bitcoincash: [addr => isUtxoAddressBrandValid(addr, 'Bitcoin-Cash')], + litecoin: [addr => isUtxoAddressBrandValid(addr, 'Litecoin')], + dogecoin: [addr => isUtxoAddressBrandValid(addr, 'Dogecoin')], + dash: [addr => isUtxoAddressBrandValid(addr, 'Dash')], ripple: [re(reXRP)], ton: [re(reTON)], - zcash: [re(reZcashT), re(reZcashZ)], + zcash: [addr => isUtxoAddressBrandValid(addr, 'Zcash')], sui: [re(reSui)], tron: [re(reTron)], polkadot: [re(rePolkadot)], @@ -396,16 +402,40 @@ const familyMatchers: Array<{ family: AddressFamily; match: Matcher }> = [ { family: 'sui', match: re(reSui) }, { family: 'evm', match: re(reEVM) }, { family: 'solana', match: isSolanaAddress }, - { family: 'bitcoincash', match: re(reBCH) }, - { family: 'litecoin', match: addr => reLTCBech32.test(addr) || reLTCLegacy.test(addr) }, + { + family: 'bitcoincash', + match: addr => { + const normalized = normalizeCaseInsensitiveEncoding(addr) + return normalized !== undefined && reBCH.test(normalized) + }, + }, + { + family: 'litecoin', + match: addr => { + const normalized = normalizeCaseInsensitiveEncoding(addr) + return (normalized !== undefined && reLTCBech32.test(normalized)) || reLTCLegacy.test(addr) + }, + }, { family: 'dogecoin', match: re(reDOGE) }, { family: 'dash', match: re(reDASH) }, - { family: 'btc', match: addr => reBTCNativeSegWit.test(addr) || reBTCLegacy.test(addr) }, + { + family: 'btc', + match: addr => { + const normalized = normalizeCaseInsensitiveEncoding(addr) + return (normalized !== undefined && reBTCNativeSegWit.test(normalized)) || reBTCLegacy.test(addr) + }, + }, { family: 'cardano', match: re(reCardano) }, { family: 'ton', match: re(reTON) }, { family: 'tron', match: re(reTron) }, { family: 'xrp', match: re(reXRP) }, - { family: 'zcash', match: addr => reZcashT.test(addr) || reZcashZ.test(addr) }, + { + family: 'zcash', + match: addr => { + const normalized = normalizeCaseInsensitiveEncoding(addr) + return reZcashT.test(addr) || (normalized !== undefined && reZcashZ.test(normalized)) + }, + }, { family: 'polkadot', match: re(rePolkadot) }, { family: 'bittensor', match: re(reBittensor) }, ] diff --git a/packages/sdk/src/utils/cashaddr.ts b/packages/sdk/src/utils/cashaddr.ts index 90a9ac51dd..15a53f00a2 100644 --- a/packages/sdk/src/utils/cashaddr.ts +++ b/packages/sdk/src/utils/cashaddr.ts @@ -46,12 +46,15 @@ export function verifyCashAddrChecksum(prefix: string, data5: number[]): boolean /** * Full validity check for a mainnet BCH CashAddr (P2PKH `q...` / P2SH `p...`), * with or without the `bitcoincash:` prefix. Enforces the canonical 42-symbol - * payload length, the base32 charset (so b/i/o/1 and any uppercase are - * rejected — mixed-case CashAddr is invalid), and the polymod checksum. + * payload length, uniform casing, the base32 charset (so b/i/o/1 are + * rejected), and the polymod checksum. */ export function isValidCashAddr(address: string): boolean { const trimmed = address.trim() - const payload = trimmed.startsWith('bitcoincash:') ? trimmed.slice('bitcoincash:'.length) : trimmed + const lowercase = trimmed.toLowerCase() + if (trimmed !== lowercase && trimmed !== trimmed.toUpperCase()) return false + + const payload = lowercase.startsWith('bitcoincash:') ? lowercase.slice('bitcoincash:'.length) : lowercase // Mainnet P2PKH/P2SH CashAddr payloads are exactly 42 base32 symbols // (1 version symbol + 33 hash symbols + 8 checksum symbols). if (payload.length !== 42) return false diff --git a/packages/sdk/tests/unit/chains/utxo-address-brand.test.ts b/packages/sdk/tests/unit/chains/utxo-address-brand.test.ts new file mode 100644 index 0000000000..1f6fe8178b --- /dev/null +++ b/packages/sdk/tests/unit/chains/utxo-address-brand.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' + +import { + assertUtxoAddressBrand, + isUtxoAddressBrandValid, + type UtxoChainName, +} from '../../../src/chains/utxo/addressBrand' +import { decodeAddressToPubKeyHash } from '../../../src/chains/utxo/tx' +import { isAddressValidForChain } from '../../../src/utils/addressFormat' + +const GOLDEN_ADDRESSES: Record = { + Bitcoin: [ + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + '16L5yRNPTuciSgXGHqYwn9N6NeoKqopAu', + '31nM1WuowNDzocNxPPW9NQWJEtwWpjfcLj', + ], + Litecoin: [ + 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9', + 'LKKHMBjCU89fyFNgSRprDoD8Jb25N8uWvd', + 'M7zVKQKmtV5Rc7erVGVVC3khZbXxsS5HEX', + ], + Dogecoin: ['D5ERdEN1gsouFSs7zsq7VYJxyWP6dP28H1', '9rXbkMyi1S6thykRoXAZcY8fwUKYsy6cXE'], + Dash: ['XanAvE5GMB8CsPH78B9moJq9viEVKvCS4f', '7SVyqiBykMKdoNuuf1AehnVxASmtdfqsFF'], + 'Bitcoin-Cash': [ + 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', + 'bitcoincash:ppm2qsznhks23z7629mms6s4cwef74vcwvn0h829pq', + ], + Zcash: ['t1Hxw6JqWMnhDK5jRCieg5bFHM2qt7UtQvu', 't3Jex1rKwuh1bQFRrKpKGWDcDVZ8bbQuNrB'], +} + +const CHAINS = Object.keys(GOLDEN_ADDRESSES) as UtxoChainName[] +const ZCASH_SAPLING_ADDRESS = 'zs1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7ruszzg3rysjjvfeg9y4zkvtfdeq' +const ZCASH_SAPLING_BECH32M_VARIANT = 'zs1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7ruszzg3rysjjvfeg9y4zkehepuz' +const INVALID_BITCOIN_SEGWIT_ADDRESSES = [ + 'bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7vqh2y7hd', + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kemeawh', + 'BC130XLXVLHEMJA6C4DQV22UAPCTQUPFHLXM9H8Z3K2E72Q4K9HCZ7VQ7ZWS8R', + 'bc1pw5dgrnzv', + 'bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7v8n0nx0muaewav253zgeav', + 'BC1QR508D6QEJXTDG4Y5R3ZARVARYV98GJ9P', + 'bc1p0xlxvlhemja6c4dqv22uapctqupfhlxm9h8z3k2e72q4k9hcz7v07qwwzcrf', +] + +describe('UTXO address brand validation', () => { + it.each(CHAINS)('accepts golden %s addresses', chain => { + for (const address of GOLDEN_ADDRESSES[chain]) { + expect(isUtxoAddressBrandValid(address, chain)).toBe(true) + expect(() => assertUtxoAddressBrand(address, chain)).not.toThrow() + } + }) + + it.each(CHAINS)('rejects every other UTXO chain brand as %s', expectedChain => { + for (const actualChain of CHAINS) { + if (actualChain === expectedChain) continue + expect(isUtxoAddressBrandValid(GOLDEN_ADDRESSES[actualChain][0]!, expectedChain)).toBe(false) + } + }) + + it('rejects malformed and checksum-invalid addresses', () => { + expect(isUtxoAddressBrandValid('', 'Bitcoin')).toBe(false) + expect(isUtxoAddressBrandValid('bc1not-a-valid-address', 'Bitcoin')).toBe(false) + expect(isUtxoAddressBrandValid('bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6x', 'Bitcoin-Cash')).toBe( + false + ) + }) + + it('accepts uniform uppercase encodings and rejects mixed case', () => { + const bitcoin = GOLDEN_ADDRESSES.Bitcoin[0]! + const bitcoinCash = GOLDEN_ADDRESSES['Bitcoin-Cash'][0]! + + expect(isUtxoAddressBrandValid(bitcoin.toUpperCase(), 'Bitcoin')).toBe(true) + expect(isUtxoAddressBrandValid(bitcoinCash.toUpperCase(), 'Bitcoin-Cash')).toBe(true) + expect(isUtxoAddressBrandValid(ZCASH_SAPLING_ADDRESS.toUpperCase(), 'Zcash')).toBe(true) + expect(isUtxoAddressBrandValid(`${bitcoin.slice(0, -1)}T`, 'Bitcoin')).toBe(false) + expect(isUtxoAddressBrandValid(`${bitcoinCash.slice(0, -1)}A`, 'Bitcoin-Cash')).toBe(false) + expect(decodeAddressToPubKeyHash(bitcoin.toUpperCase(), 'Bitcoin')).toEqual( + decodeAddressToPubKeyHash(bitcoin, 'Bitcoin') + ) + expect(() => decodeAddressToPubKeyHash(ZCASH_SAPLING_ADDRESS.toUpperCase(), 'Zcash')).toThrow( + 'Zcash shielded outputs are not supported by this SDK build' + ) + }) + + it.each(INVALID_BITCOIN_SEGWIT_ADDRESSES)('rejects the invalid BIP-350 SegWit vector %s', address => { + expect(isUtxoAddressBrandValid(address, 'Bitcoin')).toBe(false) + }) + + it('recognizes a checksummed Zcash Sapling address without treating it as a signable transparent output', () => { + expect(isUtxoAddressBrandValid(ZCASH_SAPLING_ADDRESS, 'Zcash')).toBe(true) + expect(isUtxoAddressBrandValid(ZCASH_SAPLING_ADDRESS, 'Bitcoin')).toBe(false) + expect(isUtxoAddressBrandValid(`${ZCASH_SAPLING_ADDRESS.slice(0, -1)}x`, 'Zcash')).toBe(false) + expect(isUtxoAddressBrandValid(ZCASH_SAPLING_BECH32M_VARIANT, 'Zcash')).toBe(false) + expect(isAddressValidForChain(ZCASH_SAPLING_ADDRESS, 'Zcash')).toBe(true) + expect(() => decodeAddressToPubKeyHash(ZCASH_SAPLING_ADDRESS, 'Zcash')).toThrow( + 'Zcash shielded outputs are not supported by this SDK build' + ) + }) + + it('guards the UTXO decoder before a same-length wrong-chain payload can be re-encoded', () => { + const dogecoinAddress = GOLDEN_ADDRESSES.Dogecoin[0]! + expect(() => decodeAddressToPubKeyHash(dogecoinAddress, 'Bitcoin')).toThrow( + 'UTXO address brand mismatch: expected a valid Bitcoin address' + ) + }) + + it('backs the generic per-chain address validator with the same canonical policy', () => { + const dogecoinAddress = GOLDEN_ADDRESSES.Dogecoin[0]! + expect(isAddressValidForChain(dogecoinAddress, 'Dogecoin')).toBe(true) + expect(isAddressValidForChain(dogecoinAddress, 'Bitcoin')).toBe(false) + }) +}) diff --git a/packages/sdk/tests/unit/chains/utxo-cashaddr.test.ts b/packages/sdk/tests/unit/chains/utxo-cashaddr.test.ts index 9ce70db8f1..2086a30956 100644 --- a/packages/sdk/tests/unit/chains/utxo-cashaddr.test.ts +++ b/packages/sdk/tests/unit/chains/utxo-cashaddr.test.ts @@ -27,19 +27,19 @@ describe('decodeAddressToPubKeyHash — CashAddr checksum', () => { // no longer produces 0. Before the fix, this decoded to random bytes // and looked like a valid P2PKH. const tampered = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfux' - expect(() => decodeAddressToPubKeyHash(tampered, 'Bitcoin-Cash')).toThrow(/Cannot decode/) + expect(() => decodeAddressToPubKeyHash(tampered, 'Bitcoin-Cash')).toThrow(/UTXO address brand mismatch/) }) it('rejects a CashAddr with mid-string transposition (valid base32, bad checksum)', () => { // Swap two mid-payload symbols: `y0q` → `0yq`. const transposed = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as40yqverfuy' - expect(() => decodeAddressToPubKeyHash(transposed, 'Bitcoin-Cash')).toThrow(/Cannot decode/) + expect(() => decodeAddressToPubKeyHash(transposed, 'Bitcoin-Cash')).toThrow(/UTXO address brand mismatch/) }) it('rejects a CashAddr with an out-of-alphabet character', () => { // `b` is not in the cashaddr charset ('qpzry9x8gf2tvdw0s3jn54khce6mua7l'). const bad = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcb7as4y0qverfuy' - expect(() => decodeAddressToPubKeyHash(bad, 'Bitcoin-Cash')).toThrow(/Cannot decode/) + expect(() => decodeAddressToPubKeyHash(bad, 'Bitcoin-Cash')).toThrow(/UTXO address brand mismatch/) }) it('accepts a CashAddr without the prefix (auto-prefixes bitcoincash:)', () => { diff --git a/packages/sdk/tests/unit/chains/utxo-p2sh.test.ts b/packages/sdk/tests/unit/chains/utxo-p2sh.test.ts index b132cb6b7b..bcfd1790ff 100644 --- a/packages/sdk/tests/unit/chains/utxo-p2sh.test.ts +++ b/packages/sdk/tests/unit/chains/utxo-p2sh.test.ts @@ -274,9 +274,7 @@ describe('decodeAddressToPubKeyHash — wrong-chain paste rejects 21-byte payloa it('throws when a Zcash t1-address is decoded as Dogecoin (22-byte payload → 21-byte slice)', () => { const zcashTAddr = buildZcashTAddress(0xb8, HASH_20) // t1... - expect(() => decodeAddressToPubKeyHash(zcashTAddr, 'Dogecoin')).toThrow( - /payload length 21 bytes for chain Dogecoin/ - ) + expect(() => decodeAddressToPubKeyHash(zcashTAddr, 'Dogecoin')).toThrow(/UTXO address brand mismatch/) }) it('throws when a Zcash t1-address is decoded as Bitcoin-Cash', () => { @@ -284,14 +282,12 @@ describe('decodeAddressToPubKeyHash — wrong-chain paste rejects 21-byte payloa // Note: BCH branch is CashAddr (`bitcoincash:...`); a base58 t-address // doesn't match the CashAddr prefix, so it falls into the base58 fallback // exactly as the Dogecoin case does. - expect(() => decodeAddressToPubKeyHash(zcashTAddr, 'Bitcoin-Cash')).toThrow( - /payload length 21 bytes for chain Bitcoin-Cash/ - ) + expect(() => decodeAddressToPubKeyHash(zcashTAddr, 'Bitcoin-Cash')).toThrow(/UTXO address brand mismatch/) }) it('throws when a Zcash t1-address is decoded as Dash', () => { const zcashTAddr = buildZcashTAddress(0xb8, HASH_20) - expect(() => decodeAddressToPubKeyHash(zcashTAddr, 'Dash')).toThrow(/payload length 21 bytes for chain Dash/) + expect(() => decodeAddressToPubKeyHash(zcashTAddr, 'Dash')).toThrow(/UTXO address brand mismatch/) }) it('still decodes a Zcash t1-address normally under chain=Zcash (Zcash branch handles before fallback)', () => { diff --git a/packages/sdk/tests/unit/utils/addressValidation.test.ts b/packages/sdk/tests/unit/utils/addressValidation.test.ts index 407ee7f2c0..c72a645409 100644 --- a/packages/sdk/tests/unit/utils/addressValidation.test.ts +++ b/packages/sdk/tests/unit/utils/addressValidation.test.ts @@ -12,6 +12,9 @@ const ADDR = { sol: '7EYnhQoR9YM3N7UoaKRoA44Uy8JeaZV3qyouov87awMs', // canonical example pubkey btcBech32: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq', btcLegacy: '1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa', // genesis coinbase + bitcoinCash: 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', + litecoinBech32: 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9', + zcashSapling: 'zs1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7ruszzg3rysjjvfeg9y4zkvtfdeq', sui: '0x0000000000000000000000000000000000000000000000000000000000000002', tron: 'TJRabPrwbZy45sbavfcjinPJC18kjpRTv8', xrp: 'rDsbeomae4FXwgQTJp9Rs64Qg9vDiTCdBv', @@ -44,6 +47,20 @@ describe('classifyAddress', () => { expect(classifyAddress(ADDR.eth)).toBe('evm') expect(classifyAddress(ADDR.sui)).toBe('sui') }) + + it.each([ + [ADDR.btcBech32, 'btc'], + [ADDR.bitcoinCash, 'bitcoincash'], + [ADDR.litecoinBech32, 'litecoin'], + [ADDR.zcashSapling, 'zcash'], + ])('classifies a uniform-uppercase %s address as %s', (addr, family) => { + expect(classifyAddress(addr.toUpperCase())).toBe(family) + }) + + it('does not normalize mixed-case Bech32 or CashAddr values for classification', () => { + expect(classifyAddress(`${ADDR.btcBech32.slice(0, -1)}Q`)).toBe('unknown') + expect(classifyAddress(`${ADDR.bitcoinCash.slice(0, -1)}A`)).toBe('unknown') + }) }) describe('isSolanaAddress', () => { @@ -154,6 +171,12 @@ describe('validate.chainPrefix', () => { expect(r.valid).toBe(true) expect(r.reason).toBe('match') }) + it('keeps validation and classification aligned for uniform-uppercase UTXO encodings', () => { + const result = checkChainPrefix(ADDR.bitcoinCash.toUpperCase(), 'Bitcoin-Cash') + expect(result.valid).toBe(true) + expect(result.reason).toBe('match') + expect(result.detectedFamily).toBe('bitcoincash') + }) it('fails open (valid) for an unknown chain', () => { const r = validate.chainPrefix(ADDR.eth, 'madeupchain') expect(r.valid).toBe(true) diff --git a/packages/sdk/tests/unit/utils/cashaddr.test.ts b/packages/sdk/tests/unit/utils/cashaddr.test.ts index e00013deca..3c0cb6dfa0 100644 --- a/packages/sdk/tests/unit/utils/cashaddr.test.ts +++ b/packages/sdk/tests/unit/utils/cashaddr.test.ts @@ -44,9 +44,13 @@ describe('isValidCashAddr — polymod checksum enforcement', () => { expect(isValidCashAddr(bad)).toBe(false) }) - it('rejects wrong length and uppercase', () => { + it('rejects wrong length', () => { expect(isValidCashAddr(VALID_P2PKH + 'q')).toBe(false) - expect(isValidCashAddr(VALID_P2PKH.toUpperCase())).toBe(false) + }) + + it('accepts uniform uppercase and rejects mixed case', () => { + expect(isValidCashAddr(VALID_P2PKH.toUpperCase())).toBe(true) + expect(isValidCashAddr(`${VALID_P2PKH.slice(0, -1)}A`)).toBe(false) }) }) diff --git a/scripts/quality-contracts.mjs b/scripts/quality-contracts.mjs index fb3c795b51..b02f83a0d8 100644 --- a/scripts/quality-contracts.mjs +++ b/scripts/quality-contracts.mjs @@ -323,6 +323,23 @@ assert.equal(typeof root.fiatToAmount, 'function', 'root exports fiatToAmount') assert.equal(typeof root.normalizeChain, 'function', 'root exports normalizeChain') assert.equal(typeof root.fromChainAmountExact, 'function', 'root exports fromChainAmountExact') assert.equal(typeof root.getBlockExplorerUrl, 'function', 'root exports getBlockExplorerUrl') +assert.equal(typeof root.assertUtxoAddressBrand, 'function', 'root exports assertUtxoAddressBrand') +assert.equal(typeof root.isUtxoAddressBrandValid, 'function', 'root exports isUtxoAddressBrandValid') +assert.equal( + root.isUtxoAddressBrandValid('D5ERdEN1gsouFSs7zsq7VYJxyWP6dP28H1', 'Dogecoin'), + true, + 'packed root validates a Dogecoin address for Dogecoin' +) +assert.equal( + root.isUtxoAddressBrandValid('D5ERdEN1gsouFSs7zsq7VYJxyWP6dP28H1', 'Bitcoin'), + false, + 'packed root rejects a Dogecoin address for Bitcoin' +) +assert.throws( + () => root.assertUtxoAddressBrand('D5ERdEN1gsouFSs7zsq7VYJxyWP6dP28H1', 'Bitcoin'), + /UTXO address brand mismatch/, + 'packed root exposes the throwing UTXO brand guard' +) assert.ok(root.chainRegistry !== undefined, 'root exports chainRegistry') assert.equal(typeof root.deriveFromChainRegistry, 'function', 'root exports deriveFromChainRegistry') assert.equal(typeof root.extendChainRegistry, 'function', 'root exports extendChainRegistry') @@ -375,7 +392,14 @@ const rnJs = path.join(pkgDir, 'dist/index.react-native.js') assert.ok(existsSync(rnJs), 'react-native bundle file exists on disk') const rnDts = path.join(pkgDir, 'dist/index.react-native.d.ts') assert.ok(existsSync(rnDts), 'react-native types exist on disk') -for (const symbol of ['chainRegistry', 'deriveFromChainRegistry', 'extendChainRegistry', 'getBlockExplorerUrl']) { +for (const symbol of [ + 'chainRegistry', + 'deriveFromChainRegistry', + 'extendChainRegistry', + 'getBlockExplorerUrl', + 'assertUtxoAddressBrand', + 'isUtxoAddressBrandValid', +]) { assert.ok(readFileSync(rnJs, 'utf8').includes(symbol), \`react-native bundle exports \${symbol}\`) assert.ok(readFileSync(rnDts, 'utf8').includes(symbol), \`react-native types export \${symbol}\`) } @@ -401,7 +425,14 @@ assert.ok(existsSync(electronMainDts), 'electron main types exist on disk') writeFileSync(path.join(consumer, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2) + '\n') writeFileSync( path.join(consumer, 'types-smoke.ts'), - `import { Chain, chainRegistry, deriveFromChainRegistry, extendChainRegistry } from '@vultisig/sdk' + `import { + Chain, + assertUtxoAddressBrand, + chainRegistry, + deriveFromChainRegistry, + extendChainRegistry, + isUtxoAddressBrandValid, +} from '@vultisig/sdk' import type { ChainDescriptor, ChainDescriptorRegistry, @@ -409,10 +440,16 @@ import type { ChainExtensionRecord, ChainKind, ExtendedChainRegistry, + UtxoChainName, } from '@vultisig/sdk' +import { + assertUtxoAddressBrand as assertReactNativeUtxoAddressBrand, + isUtxoAddressBrandValid as isReactNativeUtxoAddressBrandValid, +} from '@vultisig/sdk/react-native' import type { ChainDescriptor as ReactNativeChainDescriptor, ExtendedChainRegistry as ReactNativeExtendedChainRegistry, + UtxoChainName as ReactNativeUtxoChainName, } from '@vultisig/sdk/react-native' import type { Vultisig } from '@vultisig/sdk/node' import type { ElectronMainCrypto, Vultisig as ElectronMainVultisig } from '@vultisig/sdk/electron/main' @@ -425,6 +462,21 @@ const registry: ChainDescriptorRegistry = chainRegistry const explorer: ChainExplorerDescriptor = descriptor.explorer const extension: ChainExtensionRecord = deriveFromChainRegistry(({ kind }) => ({ kind })) const extended: ExtendedChainRegistry = extendChainRegistry(extension) +const utxoChain: UtxoChainName = 'Bitcoin' +const reactNativeUtxoChain: ReactNativeUtxoChainName = 'Litecoin' +const rootUtxoBrandValid: boolean = isUtxoAddressBrandValid( + 'bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', + utxoChain +) +const reactNativeUtxoBrandValid: boolean = isReactNativeUtxoAddressBrandValid( + 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9', + reactNativeUtxoChain +) +assertUtxoAddressBrand('bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4', utxoChain) +assertReactNativeUtxoAddressBrand( + 'ltc1qw508d6qejxtdg4y5r3zarvary0c5xw7kgmn4n9', + reactNativeUtxoChain +) export type X = Chain export type Y = Vultisig @@ -440,6 +492,8 @@ export type ReactNativeExtended = ReactNativeExtendedChainRegistry