Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 .changeset/canonical-utxo-address-brand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@vultisig/sdk': patch
---

Add a canonical UTXO address-brand validator and enforce it in the transaction decoder.
96 changes: 96 additions & 0 deletions packages/sdk/src/chains/utxo/addressBrand.ts
Original file line number Diff line number Diff line change
@@ -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<Record<UtxoChainName, ReadonlySet<number>>> = {
Bitcoin: new Set([0x00, 0x05]),
Litecoin: new Set([0x30, 0x32]),
Dogecoin: new Set([0x1e, 0x16]),
Dash: new Set([0x4c, 0x10]),
}

const SEGWIT_HRP: Partial<Record<UtxoChainName, string>> = {
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`)
}
}
3 changes: 2 additions & 1 deletion packages/sdk/src/chains/utxo/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export type { UtxoChainName } from './addressBrand'
export { assertUtxoAddressBrand, isUtxoAddressBrandValid } from './addressBrand'
export type {
BroadcastUtxoTxOptions,
EstimateUtxoFeeOptions,
Expand All @@ -15,7 +17,6 @@ export type {
DecodedAddress,
SighashBIP143Options,
SighashLegacyOptions,
UtxoChainName,
UtxoInput,
UtxoTxBuilderResult,
} from './tx'
Expand Down
17 changes: 13 additions & 4 deletions packages/sdk/src/chains/utxo/tx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@ 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.
// Consumers pass the Chain enum value as a string; the RN wrapper supplies a
// typed overload.
// ---------------------------------------------------------------------------

export type UtxoChainName = 'Bitcoin' | 'Litecoin' | 'Dogecoin' | 'Dash' | 'Bitcoin-Cash' | 'Zcash'
export type { UtxoChainName } from './addressBrand'

type UtxoScriptKind = 'p2pkh' | 'p2wpkh' | 'p2sh'

Expand Down Expand Up @@ -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...)
Expand All @@ -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}`)
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment thread
rcoderdev marked this conversation as resolved.

// ============================================================================
// PUBLIC API - Tx Shape Normalization (pure, vault-free)
// ============================================================================
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/platforms/react-native/chains/utxo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type {
UtxoTxBuilderResult,
} from '../../../../chains/utxo'
export {
assertUtxoAddressBrand,
broadcastUtxoTx,
buildUtxoSendTx,
decodeAddressToPubKeyHash,
Expand All @@ -39,6 +40,7 @@ export {
getUtxoBalance,
getUtxoChainSpec,
getUtxos,
isUtxoAddressBrandValid,
selectUtxoInputs,
ZCASH_BRANCH_ID_NU6_1,
ZCASH_BRANCH_ID_NU6_2,
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/src/platforms/react-native/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 15 additions & 13 deletions packages/sdk/src/utils/addressFormat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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...). */
Expand Down Expand Up @@ -291,16 +292,17 @@ const chainFormatRules: Record<string, Matcher[]> = {
// 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)],
Expand Down
9 changes: 6 additions & 3 deletions packages/sdk/src/utils/cashaddr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
rcoderdev marked this conversation as resolved.
// 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
Expand Down
111 changes: 111 additions & 0 deletions packages/sdk/tests/unit/chains/utxo-address-brand.test.ts
Original file line number Diff line number Diff line change
@@ -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<UtxoChainName, readonly string[]> = {
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)
})
})
Loading
Loading