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 .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
64 changes: 47 additions & 17 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 @@ -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.
Expand Down Expand Up @@ -291,16 +296,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 Expand Up @@ -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) },
]
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
Loading