From 08293aabff7e66b1adaa79e299cf3e0deb3d1405 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 14:53:05 +0200 Subject: [PATCH 01/24] feat(bitcoin): decode Zcash addresses and accept the transparent kinds --- packages/sdk-provider-bitcoin/package.json | 3 +- .../src/utils/zcashAddress.ts | 77 +++++++++++++++++++ .../src/utils/zcashAddress.unit.spec.ts | 72 +++++++++++++++++ pnpm-lock.yaml | 3 + 4 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts create mode 100644 packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts diff --git a/packages/sdk-provider-bitcoin/package.json b/packages/sdk-provider-bitcoin/package.json index 59ee451dc..068ebb8a9 100644 --- a/packages/sdk-provider-bitcoin/package.json +++ b/packages/sdk-provider-bitcoin/package.json @@ -48,7 +48,8 @@ "@bitcoinerlab/secp256k1": "^2.0.0", "@lifi/sdk": "workspace:*", "bech32": "^2.0.0", - "bitcoinjs-lib": "^7.0.2" + "bitcoinjs-lib": "^7.0.2", + "bs58check": "^4.0.0" }, "devDependencies": { "@lifi/data-types": "^7.1.6", diff --git a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts new file mode 100644 index 000000000..981429392 --- /dev/null +++ b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts @@ -0,0 +1,77 @@ +import { type BechLib, bech32, bech32m } from 'bech32' +import bs58check from 'bs58check' + +/** The mainnet Zcash address kinds, named by the receiver they encode. */ +export type ZcashAddressKind = 'p2pkh' | 'p2sh' | 'tex' | 'sapling' | 'unified' + +/** + * The kinds a LI.FI route may send to. The API handles transparent receivers + * only; add a kind here once the backend confirms it. + */ +export const acceptedZcashAddressKinds: ReadonlySet = + new Set(['p2pkh', 'p2sh']) + +// Unified addresses outgrow Bech32's default 90-character limit: the longest +// official test vector (zcash-test-vectors, unified_address.json) has 509. +const bech32LengthLimit = 1024 + +// A transparent address decodes to a 2-byte version prefix and a 20-byte hash. +const transparentPayloadLength = 22 + +const parseTransparentAddress = ( + address: string +): ZcashAddressKind | undefined => { + const payload = bs58check.decodeUnsafe(address) + if (payload?.length !== transparentPayloadLength || payload[0] !== 0x1c) { + return undefined + } + if (payload[1] === 0xb8) { + return 'p2pkh' + } + if (payload[1] === 0xbd) { + return 'p2sh' + } + return undefined +} + +interface Bech32Kind { + kind: ZcashAddressKind + prefix: string + encoding: BechLib + /** Unified addresses are F4Jumbled, so only their checksum is checked. */ + payloadLength?: number +} + +const bech32Kinds: Bech32Kind[] = [ + { kind: 'tex', prefix: 'tex', encoding: bech32m, payloadLength: 20 }, + { kind: 'sapling', prefix: 'zs', encoding: bech32, payloadLength: 43 }, + { kind: 'unified', prefix: 'u', encoding: bech32m }, +] + +const parseBech32Address = (address: string): ZcashAddressKind | undefined => + bech32Kinds.find(({ prefix, encoding, payloadLength }) => { + const decoded = encoding.decodeUnsafe(address, bech32LengthLimit) + if (decoded?.prefix !== prefix) { + return false + } + const payload = encoding.fromWordsUnsafe(decoded.words) + return ( + payload !== undefined && + (payloadLength === undefined || payload.length === payloadLength) + ) + })?.kind + +/** + * Decodes a mainnet Zcash address and verifies its checksum. A testnet address, + * or any other version or prefix, returns `undefined`. + */ +export const parseZcashAddress = ( + address: string +): ZcashAddressKind | undefined => + parseTransparentAddress(address) ?? parseBech32Address(address) + +/** Whether `address` is a Zcash address a LI.FI route may send to. */ +export const isZcashAddress = (address: string): boolean => { + const kind = parseZcashAddress(address) + return kind !== undefined && acceptedZcashAddressKinds.has(kind) +} diff --git a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts new file mode 100644 index 000000000..8c162087c --- /dev/null +++ b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import { + acceptedZcashAddressKinds, + isZcashAddress, + parseZcashAddress, +} from './zcashAddress.js' + +// Sources: ZIP 320 (t1 and its TEX form), ZIP 214 (t3), zcash-test-vectors +// unified_address.json (u1, and the Sapling raw address of row 0 encoded as zs1). +const t1 = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC' +const t3 = 't3LmX1cxWPPPqL4TZHx42HU3U5ghbFjRiif' +const tex = 'tex1s2rt77ggv6q989lr49rkgzmh5slsksa9khdgte' +const sapling = + 'zs1mrhc9y7jdh5r9ece8u5khgvj9kg0zgkxzdduyv0whkg7lkcrkx5xqem3e48avjq9wn2rukydkwn' +const unified = + 'u1l8xunezsvhq8fgzfl7404m450nwnd76zshscn6nfys7vyz2ywyh4cc5daaq0c7q2su5lqfh23sp7fkf3kt27ve5948mzpfdvckzaect2jtte308mkwlycj2u0eac077wu70vqcetkxf' +// The longest entry in unified_address.json: past Bech32's default 90-character limit. +const unifiedLongest = + 'u1tqx832p4wsfe9pd67ggm3qsmfuvdhqvw2259y7uwug7y0lpeu87fmgpqh3zmamex3fzs0d4ct4hhsg2csj5z0q5f3f7n656ap8e4nlng9c4440rz9s7ekxanfw6g84f7vu82fumtmlz3vstl2a9ufa0970k4knsz2wpsjt2xycqeay76pt4fx3ak9y7mps2q6qe2n2h7wkakxr7xu6vd36zhhzgln7ttmrzc0f9ye3jmyu2pp8l8rect87lfxj2fgckcwz3svdx70a947fz04kgu7e907enzrk676zdkdmuyw2kyrclkmj62kmyy2rjetpus7knmxfuu7z0m63uwfhdynhuu3yrjqu5y089v8zwnh60mw5ngc0kszdjmc339fk9mjn396m5ekv7h7td7fa0u9097xph3y5vth9af4sw6ykxdms84wr544mxxqtmgj027d9e8rnlrazge0kwyydyhder3chwhmaqjk9skuxgxzternw4xx962qed' +// The t1 above with its last character changed: the checksum no longer matches. +const t1WithBadChecksum = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yD' +// Testnet forms of the same hash: version 0x1D25 and HRP `textest`. +const testnetTransparent = 'tmMcWbZU8t39htCR1fQRfRSHtkW4p3Ax5Se' +const testnetTex = 'textest1s2rt77ggv6q989lr49rkgzmh5slsksa90ej7wz' +const bitcoinSegwit = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' +const bitcoinLegacy = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2' + +describe('parseZcashAddress', () => { + it.each([ + ['p2pkh', t1], + ['p2sh', t3], + ['tex', tex], + ['sapling', sapling], + ['unified', unified], + ['unified', unifiedLongest], + ])('decodes a %s address', (kind, address) => { + expect(parseZcashAddress(address)).toBe(kind) + }) + + it.each([ + ['a corrupted checksum', t1WithBadChecksum], + ['a testnet transparent address', testnetTransparent], + ['a testnet TEX address', testnetTex], + ['a Bitcoin segwit address', bitcoinSegwit], + ['a Bitcoin legacy address', bitcoinLegacy], + ['an EVM address', '0xB095274743941e953c746F9C228DA9c18Bb6ec29'], + ['an empty string', ''], + ['free text', 'not-an-address'], + ])('rejects %s', (_, address) => { + expect(parseZcashAddress(address)).toBeUndefined() + }) +}) + +describe('isZcashAddress', () => { + it('accepts only the transparent kinds a route may send to', () => { + expect([...acceptedZcashAddressKinds].sort()).toEqual(['p2pkh', 'p2sh']) + expect(isZcashAddress(t1)).toBe(true) + expect(isZcashAddress(t3)).toBe(true) + }) + + it('refuses kinds the backend does not handle yet', () => { + expect(isZcashAddress(tex)).toBe(false) + expect(isZcashAddress(sapling)).toBe(false) + expect(isZcashAddress(unified)).toBe(false) + }) + + it('refuses a corrupted checksum and addresses of other chains', () => { + expect(isZcashAddress(t1WithBadChecksum)).toBe(false) + expect(isZcashAddress(bitcoinSegwit)).toBe(false) + expect(isZcashAddress(bitcoinLegacy)).toBe(false) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0de91866d..f57d0fe89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: bitcoinjs-lib: specifier: ^7.0.2 version: 7.0.2(typescript@7.0.2) + bs58check: + specifier: ^4.0.0 + version: 4.0.0 devDependencies: '@lifi/data-types': specifier: ^7.1.6 From f2fc211fcd0bdad4c380474a06387ec08837a83b Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 14:55:44 +0200 Subject: [PATCH 02/24] feat(sdk): validate an address against a destination chain --- .../src/BitcoinProvider.ts | 20 +++++++- .../src/BitcoinProvider.unit.spec.ts | 46 ++++++++++++++++++- .../src/EthereumProvider.ts | 7 ++- .../src/EthereumProvider.unit.spec.ts | 22 ++++++++- .../src/SolanaProvider.unit.spec.ts | 9 +++- .../src/StellarProvider.unit.spec.ts | 9 +++- .../src/SuiProvider.unit.spec.ts | 9 +++- .../src/TronProvider.unit.spec.ts | 8 ++++ packages/sdk/src/types/core.ts | 10 +++- 9 files changed, 131 insertions(+), 9 deletions(-) diff --git a/packages/sdk-provider-bitcoin/src/BitcoinProvider.ts b/packages/sdk-provider-bitcoin/src/BitcoinProvider.ts index 64787460b..5630c40b6 100644 --- a/packages/sdk-provider-bitcoin/src/BitcoinProvider.ts +++ b/packages/sdk-provider-bitcoin/src/BitcoinProvider.ts @@ -1,9 +1,25 @@ import { isUTXOAddress } from '@bigmi/core' -import { ChainType, type StepExecutorOptions } from '@lifi/sdk' +import { ChainId, ChainType, type StepExecutorOptions } from '@lifi/sdk' import { getBitcoinBalance } from './actions/getBitcoinBalance.js' import { resolveBitcoinAddress } from './actions/resolveBitcoinAddress.js' import { BitcoinStepExecutor } from './core/BitcoinStepExecutor.js' import type { BitcoinProviderOptions, BitcoinSDKProvider } from './types.js' +import { isZcashAddress } from './utils/zcashAddress.js' + +// Each UTXO chain has its own address format. A chain this provider does not +// know is refused, so a Bitcoin address never passes as its receiver. The chain +// ID must not reach bigmi, which reads a second argument as a network. +const isAddress = (address: string, chainId?: ChainId): boolean => { + switch (chainId) { + case undefined: + case ChainId.BTC: + return isUTXOAddress(address) + case ChainId.ZEC: + return isZcashAddress(address) + default: + return false + } +} export function BitcoinProvider( options?: BitcoinProviderOptions @@ -13,7 +29,7 @@ export function BitcoinProvider( get type() { return ChainType.UTXO }, - isAddress: isUTXOAddress, + isAddress, resolveAddress: resolveBitcoinAddress, getBalance: getBitcoinBalance, async getStepExecutor( diff --git a/packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts b/packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts index 92f764b0d..6cf00326b 100644 --- a/packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts +++ b/packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts @@ -1,4 +1,4 @@ -import { ChainType } from '@lifi/sdk' +import { ChainId, ChainType } from '@lifi/sdk' import { describe, expect, it, vi } from 'vitest' import { BitcoinProvider } from './BitcoinProvider.js' @@ -52,4 +52,48 @@ describe('BitcoinProvider', () => { expect(executor).toBeDefined() expect(mockGetWalletClient).toHaveBeenCalledOnce() }) + + describe('isAddress', () => { + const provider = BitcoinProvider() + const bitcoinSegwit = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + const bitcoinLegacy = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2' + const zcashP2pkh = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC' + const zcashP2sh = 't3LmX1cxWPPPqL4TZHx42HU3U5ghbFjRiif' + const zcashUnified = + 'u1l8xunezsvhq8fgzfl7404m450nwnd76zshscn6nfys7vyz2ywyh4cc5daaq0c7q2su5lqfh23sp7fkf3kt27ve5948mzpfdvckzaect2jtte308mkwlycj2u0eac077wu70vqcetkxf' + + it('keeps today’s answer without a chain ID and for Bitcoin', () => { + for (const address of [ + bitcoinSegwit, + bitcoinLegacy, + zcashP2pkh, + 'not-an-address', + ]) { + expect(provider.isAddress(address, ChainId.BTC)).toBe( + provider.isAddress(address) + ) + } + expect(provider.isAddress(bitcoinSegwit)).toBe(true) + expect(provider.isAddress(zcashP2pkh)).toBe(false) + }) + + it('accepts only transparent Zcash addresses for ZEC', () => { + expect(provider.isAddress(zcashP2pkh, ChainId.ZEC)).toBe(true) + expect(provider.isAddress(zcashP2sh, ChainId.ZEC)).toBe(true) + expect(provider.isAddress(zcashUnified, ChainId.ZEC)).toBe(false) + }) + + it('refuses a Bitcoin address for ZEC and a Zcash address for BTC', () => { + expect(provider.isAddress(bitcoinSegwit, ChainId.ZEC)).toBe(false) + expect(provider.isAddress(bitcoinLegacy, ChainId.ZEC)).toBe(false) + expect(provider.isAddress(zcashP2pkh, ChainId.BTC)).toBe(false) + }) + + it('refuses every address for a UTXO chain it does not know', () => { + for (const chainId of [ChainId.LTC, ChainId.BCH, ChainId.DGE]) { + expect(provider.isAddress(bitcoinSegwit, chainId)).toBe(false) + expect(provider.isAddress(zcashP2pkh, chainId)).toBe(false) + } + }) + }) }) diff --git a/packages/sdk-provider-ethereum/src/EthereumProvider.ts b/packages/sdk-provider-ethereum/src/EthereumProvider.ts index 28cca177d..e441435a0 100644 --- a/packages/sdk-provider-ethereum/src/EthereumProvider.ts +++ b/packages/sdk-provider-ethereum/src/EthereumProvider.ts @@ -10,6 +10,11 @@ function isEthereumTokenAddress(address: string): boolean { return isAddress(address, { strict: false }) } +/** viem reads a second argument as options, so a chain ID must not reach it. */ +function isEthereumAddress(address: string): boolean { + return isAddress(address) +} + export function EthereumProvider( options?: EthereumProviderOptions ): EthereumSDKProvider { @@ -21,7 +26,7 @@ export function EthereumProvider( get options() { return _options }, - isAddress, + isAddress: isEthereumAddress, isTokenAddress: isEthereumTokenAddress, resolveAddress: resolveEthereumAddress, getBalance: getEthereumBalance, diff --git a/packages/sdk-provider-ethereum/src/EthereumProvider.unit.spec.ts b/packages/sdk-provider-ethereum/src/EthereumProvider.unit.spec.ts index 0066d4864..a4eb31df1 100644 --- a/packages/sdk-provider-ethereum/src/EthereumProvider.unit.spec.ts +++ b/packages/sdk-provider-ethereum/src/EthereumProvider.unit.spec.ts @@ -1,4 +1,4 @@ -import { ChainType } from '@lifi/sdk' +import { ChainId, ChainType } from '@lifi/sdk' import { describe, expect, it, vi } from 'vitest' import { EthereumProvider } from './EthereumProvider.js' @@ -86,4 +86,24 @@ describe('EthereumProvider', () => { expect(executor).toBeDefined() expect(mockGetWalletClient).toHaveBeenCalledOnce() }) + + describe('isAddress with a chain ID', () => { + const provider = EthereumProvider() + const usdt = '0xdAC17F958D2ee523a2206206994597C13D831ec7' + + it('answers as without one, for every EVM chain', () => { + for (const address of [ + usdt, + usdt.toLowerCase(), + `0x${usdt.slice(2).toUpperCase()}`, + 'laptop', + ]) { + for (const chainId of [ChainId.ETH, ChainId.ARB]) { + expect(provider.isAddress(address, chainId)).toBe( + provider.isAddress(address) + ) + } + } + }) + }) }) diff --git a/packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts b/packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts index 555d37bb4..b38ef3d1e 100644 --- a/packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts +++ b/packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts @@ -1,4 +1,4 @@ -import { ChainType, ProviderError } from '@lifi/sdk' +import { ChainId, ChainType, ProviderError } from '@lifi/sdk' import { describe, expect, it, vi } from 'vitest' import { SolanaProvider } from './SolanaProvider.js' @@ -66,4 +66,11 @@ describe('SolanaProvider', () => { expect(executor).toBeDefined() expect(mockGetWalletAdapter).toHaveBeenCalledOnce() }) + + it('answers isAddress as without a chain ID', () => { + const provider = SolanaProvider() + const usdcMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' + expect(provider.isAddress(usdcMint, ChainId.SOL)).toBe(true) + expect(provider.isAddress('laptop', ChainId.SOL)).toBe(false) + }) }) diff --git a/packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts b/packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts index 06bf8cf7d..fb5aac426 100644 --- a/packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts +++ b/packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts @@ -1,4 +1,4 @@ -import { ChainType } from '@lifi/sdk' +import { ChainId, ChainType } from '@lifi/sdk' import { Account, Keypair, MuxedAccount, StrKey } from '@stellar/stellar-sdk' import { describe, expect, it } from 'vitest' import { StellarProvider } from './StellarProvider.js' @@ -87,4 +87,11 @@ describe('StellarProvider', () => { expect(provider.isTokenAddress?.('')).toBe(false) }) }) + + it('answers isAddress as without a chain ID', () => { + const provider = StellarProvider() + const g = Keypair.random().publicKey() + expect(provider.isAddress(g, ChainId.XLM)).toBe(true) + expect(provider.isAddress('laptop', ChainId.XLM)).toBe(false) + }) }) diff --git a/packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts b/packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts index 6285a16b8..ba43b71a6 100644 --- a/packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts +++ b/packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts @@ -1,4 +1,4 @@ -import { ChainType, ProviderError } from '@lifi/sdk' +import { ChainId, ChainType, ProviderError } from '@lifi/sdk' import { describe, expect, it, vi } from 'vitest' import { SuiProvider } from './SuiProvider.js' import { SuiTokenLongAddress, SuiTokenShortAddress } from './types.js' @@ -99,4 +99,11 @@ describe('SuiProvider', () => { expect(mockGetClient).toHaveBeenCalledOnce() expect(mockGetSigner).toHaveBeenCalledOnce() }) + + it('answers isAddress as without a chain ID', () => { + const provider = SuiProvider() + const wallet = `0x${'ab'.repeat(32)}` + expect(provider.isAddress(wallet, ChainId.SUI)).toBe(true) + expect(provider.isAddress('laptop', ChainId.SUI)).toBe(false) + }) }) diff --git a/packages/sdk-provider-tron/src/TronProvider.unit.spec.ts b/packages/sdk-provider-tron/src/TronProvider.unit.spec.ts index b55fb7f50..30634415b 100644 --- a/packages/sdk-provider-tron/src/TronProvider.unit.spec.ts +++ b/packages/sdk-provider-tron/src/TronProvider.unit.spec.ts @@ -1,3 +1,4 @@ +import { ChainId } from '@lifi/sdk' import { describe, expect, it } from 'vitest' import { TronProvider } from './TronProvider.js' @@ -19,4 +20,11 @@ describe('TronProvider', () => { expect(provider.isTokenAddress?.('')).toBe(false) }) }) + + it('answers isAddress as without a chain ID', () => { + const provider = TronProvider() + const usdtContract = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' + expect(provider.isAddress(usdtContract, ChainId.TRN)).toBe(true) + expect(provider.isAddress('laptop', ChainId.TRN)).toBe(false) + }) }) diff --git a/packages/sdk/src/types/core.ts b/packages/sdk/src/types/core.ts index 64f4ea666..f28b1b55d 100644 --- a/packages/sdk/src/types/core.ts +++ b/packages/sdk/src/types/core.ts @@ -91,7 +91,15 @@ export type RPCUrls = Partial> export interface SDKProvider { readonly type: ChainType - isAddress(address: string): boolean + /** + * Validates a wallet address. Without `chainId`, the provider accepts any + * address format of its ecosystem, as before. `chainId`, when given, is a + * chain of this provider's ecosystem: a provider whose chains share one + * address format may ignore it; a provider whose chains differ accepts only + * that chain's format and refuses a chain it does not know. Never forward + * `chainId` to a library function whose second parameter means something else. + */ + isAddress(address: string, chainId?: ChainId): boolean /** * Validates a token identifier, which several ecosystems shape unlike a * wallet address. A provider that omits the method has no token address From 071bde440f4014fb14170e296b4f866ddbcd1f08 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 14:56:18 +0200 Subject: [PATCH 03/24] fix(bitcoin): leave balances of other UTXO chains unknown --- .../src/actions/getBitcoinBalance.ts | 5 ++ .../actions/getBitcoinBalance.unit.spec.ts | 51 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts index 1b07c60ad..8e54ed355 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts @@ -20,6 +20,11 @@ export const getBitcoinBalance = async ( console.warn('Requested tokens have to be on the same chain.') } } + // The client reads Bitcoin only. Another UTXO chain, such as ZEC, shares this + // provider but not its balances, so its amount stays unknown, not zero. + if (chainId !== ChainId.BTC) { + return tokens.map((token) => ({ ...token })) + } const bigmiClient = await getBitcoinPublicClient(client, ChainId.BTC) const [balance, blockCount] = await Promise.allSettled([ bigmiClient.getBalance({ address: walletAddress }), diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts new file mode 100644 index 000000000..22978cf08 --- /dev/null +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts @@ -0,0 +1,51 @@ +import { ChainId, createClient, type Token } from '@lifi/sdk' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getBitcoinBalance } from './getBitcoinBalance.js' + +const mocks = vi.hoisted(() => ({ getBitcoinPublicClient: vi.fn() })) + +vi.mock('../client/publicClient.js', () => ({ + getBitcoinPublicClient: mocks.getBitcoinPublicClient, +})) + +const client = createClient({ integrator: 'lifi-sdk' }) +const walletAddress = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' + +const nativeToken = (chainId: ChainId, symbol: string): Token => + ({ + chainId, + address: symbol.toLowerCase(), + symbol, + name: symbol, + decimals: 8, + priceUSD: '0', + }) as Token + +describe('getBitcoinBalance', () => { + beforeEach(() => { + mocks.getBitcoinPublicClient.mockReset() + mocks.getBitcoinPublicClient.mockResolvedValue({ + getBalance: vi.fn().mockResolvedValue(1234n), + getBlockCount: vi.fn().mockResolvedValue(900_000), + }) + }) + + it('reads the Bitcoin balance for BTC', async () => { + const [balance] = await getBitcoinBalance(client, walletAddress, [ + nativeToken(ChainId.BTC, 'BTC'), + ]) + + expect(balance.amount).toBe(1234n) + expect(balance.blockNumber).toBe(900_000n) + }) + + it('leaves the amount unknown for ZEC and asks no Bitcoin node', async () => { + const [balance] = await getBitcoinBalance(client, walletAddress, [ + nativeToken(ChainId.ZEC, 'ZEC'), + ]) + + expect(balance.amount).toBeUndefined() + expect(balance.symbol).toBe('ZEC') + expect(mocks.getBitcoinPublicClient).not.toHaveBeenCalled() + }) +}) From a75a5b3f30716b53f6dca4b6b1bb83ba887c5243 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 14:56:55 +0200 Subject: [PATCH 04/24] chore: add changesets for the Zcash address check --- .changeset/chain-aware-is-address.md | 5 +++++ .changeset/ethereum-is-address-wrapper.md | 5 +++++ .changeset/zcash-destination-address.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/chain-aware-is-address.md create mode 100644 .changeset/ethereum-is-address-wrapper.md create mode 100644 .changeset/zcash-destination-address.md diff --git a/.changeset/chain-aware-is-address.md b/.changeset/chain-aware-is-address.md new file mode 100644 index 000000000..5eedb8ce0 --- /dev/null +++ b/.changeset/chain-aware-is-address.md @@ -0,0 +1,5 @@ +--- +'@lifi/sdk': minor +--- + +`SDKProvider.isAddress` accepts an optional `chainId`. Without it, a provider answers as before. With it, a provider whose chains use different address formats accepts only that chain's format and refuses a chain it does not know. A provider must never forward the chain ID to a library function whose second parameter means something else. diff --git a/.changeset/ethereum-is-address-wrapper.md b/.changeset/ethereum-is-address-wrapper.md new file mode 100644 index 000000000..b41cd3378 --- /dev/null +++ b/.changeset/ethereum-is-address-wrapper.md @@ -0,0 +1,5 @@ +--- +'@lifi/sdk-provider-ethereum': patch +--- + +`EthereumProvider.isAddress` no longer passes a second argument to viem's `isAddress`, whose second parameter is `options`. Results are unchanged. diff --git a/.changeset/zcash-destination-address.md b/.changeset/zcash-destination-address.md new file mode 100644 index 000000000..bfc65346b --- /dev/null +++ b/.changeset/zcash-destination-address.md @@ -0,0 +1,5 @@ +--- +'@lifi/sdk-provider-bitcoin': minor +--- + +`BitcoinProvider.isAddress(address, ChainId.ZEC)` validates a Zcash receiver with its checksum. Transparent `t1` and `t3` addresses are accepted; TEX (`tex1`), Sapling (`zs1`) and unified (`u1`) addresses are recognised and refused until the API supports them. Any UTXO chain other than BTC and ZEC is refused, so a Bitcoin address no longer passes as the receiver of a chain whose format the provider does not know. `getBitcoinBalance` now leaves the amount of a non-BTC token unknown instead of reporting the Bitcoin balance for it. From 03b37731e5506c8e38749e1e8ccd72dc0c21fb45 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 14:57:43 +0200 Subject: [PATCH 05/24] test(sdk): pin isAddress with a chain ID to the answer without one --- packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts | 5 +++++ .../sdk-provider-stellar/src/StellarProvider.unit.spec.ts | 5 +++++ packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts | 5 +++++ packages/sdk-provider-tron/src/TronProvider.unit.spec.ts | 5 +++++ 4 files changed, 20 insertions(+) diff --git a/packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts b/packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts index b38ef3d1e..96e98dddd 100644 --- a/packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts +++ b/packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts @@ -71,6 +71,11 @@ describe('SolanaProvider', () => { const provider = SolanaProvider() const usdcMint = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' expect(provider.isAddress(usdcMint, ChainId.SOL)).toBe(true) + for (const address of [usdcMint, 'laptop']) { + expect(provider.isAddress(address, ChainId.SOL)).toBe( + provider.isAddress(address) + ) + } expect(provider.isAddress('laptop', ChainId.SOL)).toBe(false) }) }) diff --git a/packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts b/packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts index fb5aac426..6c4a8976b 100644 --- a/packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts +++ b/packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts @@ -92,6 +92,11 @@ describe('StellarProvider', () => { const provider = StellarProvider() const g = Keypair.random().publicKey() expect(provider.isAddress(g, ChainId.XLM)).toBe(true) + for (const address of [g, 'laptop']) { + expect(provider.isAddress(address, ChainId.XLM)).toBe( + provider.isAddress(address) + ) + } expect(provider.isAddress('laptop', ChainId.XLM)).toBe(false) }) }) diff --git a/packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts b/packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts index ba43b71a6..1f5b2fbf7 100644 --- a/packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts +++ b/packages/sdk-provider-sui/src/SuiProvider.unit.spec.ts @@ -104,6 +104,11 @@ describe('SuiProvider', () => { const provider = SuiProvider() const wallet = `0x${'ab'.repeat(32)}` expect(provider.isAddress(wallet, ChainId.SUI)).toBe(true) + for (const address of [wallet, 'laptop']) { + expect(provider.isAddress(address, ChainId.SUI)).toBe( + provider.isAddress(address) + ) + } expect(provider.isAddress('laptop', ChainId.SUI)).toBe(false) }) }) diff --git a/packages/sdk-provider-tron/src/TronProvider.unit.spec.ts b/packages/sdk-provider-tron/src/TronProvider.unit.spec.ts index 30634415b..18eb18132 100644 --- a/packages/sdk-provider-tron/src/TronProvider.unit.spec.ts +++ b/packages/sdk-provider-tron/src/TronProvider.unit.spec.ts @@ -25,6 +25,11 @@ describe('TronProvider', () => { const provider = TronProvider() const usdtContract = 'TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t' expect(provider.isAddress(usdtContract, ChainId.TRN)).toBe(true) + for (const address of [usdtContract, 'laptop']) { + expect(provider.isAddress(address, ChainId.TRN)).toBe( + provider.isAddress(address) + ) + } expect(provider.isAddress('laptop', ChainId.TRN)).toBe(false) }) }) From cca4db95bbc007a9dd1418b9cccbbe802b9f94ea Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 15:02:24 +0200 Subject: [PATCH 06/24] test(bitcoin): read a Bitcoin balance, not Polygon tokens, in the integration spec --- .../src/actions/getBitcoinBalance.int.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.int.spec.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.int.spec.ts index 72ef0adcb..5fdddb604 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.int.spec.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.int.spec.ts @@ -50,15 +50,12 @@ describe('getBitcoinBalance integration tests', () => { } } - it('should work for ERC20 on POL', { + it('should work for BTC on Bitcoin', { retry: retryTimes, timeout, }, async () => { const walletAddress = defaultWalletAddress - const tokens = [ - findDefaultToken(CoinKey.USDC, ChainId.POL), - findDefaultToken(CoinKey.USDT, ChainId.POL), - ] + const tokens = [findDefaultToken(CoinKey.BTC, ChainId.BTC)] await loadAndCompareTokenAmounts(client, walletAddress, tokens) }) From 99e862096140f8bc47ac9d702fcf3228ada1fc3e Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 17:55:05 +0200 Subject: [PATCH 07/24] fix(bitcoin): give the Bitcoin balance to BTC tokens only in a mixed list --- .../src/actions/getBitcoinBalance.ts | 14 ++++++++------ .../src/actions/getBitcoinBalance.unit.spec.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts index 8e54ed355..6c2a0bcfc 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts @@ -22,7 +22,9 @@ export const getBitcoinBalance = async ( } // The client reads Bitcoin only. Another UTXO chain, such as ZEC, shares this // provider but not its balances, so its amount stays unknown, not zero. - if (chainId !== ChainId.BTC) { + const isBitcoinToken = (token: Token): boolean => + token.chainId === ChainId.BTC + if (!tokens.some(isBitcoinToken)) { return tokens.map((token) => ({ ...token })) } const bigmiClient = await getBitcoinPublicClient(client, ChainId.BTC) @@ -43,9 +45,9 @@ export const getBitcoinBalance = async ( })) } - return tokens.map((token) => ({ - ...token, - amount: balance.value, - blockNumber, - })) + return tokens.map((token) => + isBitcoinToken(token) + ? { ...token, amount: balance.value, blockNumber } + : { ...token } + ) } diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts index 22978cf08..71493703a 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts @@ -48,4 +48,18 @@ describe('getBitcoinBalance', () => { expect(balance.symbol).toBe('ZEC') expect(mocks.getBitcoinPublicClient).not.toHaveBeenCalled() }) + + it('gives the Bitcoin balance to BTC tokens only in a mixed list', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + for (const tokens of [ + [nativeToken(ChainId.BTC, 'BTC'), nativeToken(ChainId.ZEC, 'ZEC')], + [nativeToken(ChainId.ZEC, 'ZEC'), nativeToken(ChainId.BTC, 'BTC')], + ]) { + const balances = await getBitcoinBalance(client, walletAddress, tokens) + const bySymbol = Object.fromEntries( + balances.map((balance) => [balance.symbol, balance.amount]) + ) + expect(bySymbol).toEqual({ BTC: 1234n, ZEC: undefined }) + } + }) }) From c7c61f22ce96f2cfa6dce1801e9bd09cc4af50af Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 17:55:09 +0200 Subject: [PATCH 08/24] docs(sdk): state what isAddress answers without a chain ID --- packages/sdk/src/types/core.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/types/core.ts b/packages/sdk/src/types/core.ts index f28b1b55d..6b42b26d4 100644 --- a/packages/sdk/src/types/core.ts +++ b/packages/sdk/src/types/core.ts @@ -92,8 +92,9 @@ export type RPCUrls = Partial> export interface SDKProvider { readonly type: ChainType /** - * Validates a wallet address. Without `chainId`, the provider accepts any - * address format of its ecosystem, as before. `chainId`, when given, is a + * Validates a wallet address. Without `chainId`, the provider answers as + * before, which may not cover every chain of its ecosystem: the UTXO + * provider accepts Bitcoin formats only. `chainId`, when given, is a * chain of this provider's ecosystem: a provider whose chains share one * address format may ignore it; a provider whose chains differ accepts only * that chain's format and refuses a chain it does not know. Never forward From 11cfe3d8a7e3bd7e9400a24f804515fcbc4c025a Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 17:55:13 +0200 Subject: [PATCH 09/24] fix(bitcoin): decode Base58Check with named imports that work in both builds bs58check exports only a default, which the CommonJS build resolved to the whole exports object, so isAddress(t1, ChainId.ZEC) threw for CommonJS users. @scure/base and @noble/hashes ship named exports for both builds. --- packages/sdk-provider-bitcoin/package.json | 5 +++-- .../src/utils/zcashAddress.ts | 16 ++++++++++++++-- pnpm-lock.yaml | 9 ++++++--- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/sdk-provider-bitcoin/package.json b/packages/sdk-provider-bitcoin/package.json index 068ebb8a9..dc7bb5b7c 100644 --- a/packages/sdk-provider-bitcoin/package.json +++ b/packages/sdk-provider-bitcoin/package.json @@ -47,9 +47,10 @@ "@bigmi/core": "^0.9.2", "@bitcoinerlab/secp256k1": "^2.0.0", "@lifi/sdk": "workspace:*", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6", "bech32": "^2.0.0", - "bitcoinjs-lib": "^7.0.2", - "bs58check": "^4.0.0" + "bitcoinjs-lib": "^7.0.2" }, "devDependencies": { "@lifi/data-types": "^7.1.6", diff --git a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts index 981429392..d55028057 100644 --- a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts +++ b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts @@ -1,5 +1,6 @@ +import { sha256 } from '@noble/hashes/sha2' +import { createBase58check } from '@scure/base' import { type BechLib, bech32, bech32m } from 'bech32' -import bs58check from 'bs58check' /** The mainnet Zcash address kinds, named by the receiver they encode. */ export type ZcashAddressKind = 'p2pkh' | 'p2sh' | 'tex' | 'sapling' | 'unified' @@ -18,10 +19,21 @@ const bech32LengthLimit = 1024 // A transparent address decodes to a 2-byte version prefix and a 20-byte hash. const transparentPayloadLength = 22 +const base58check = createBase58check(sha256) + +/** The payload of a Base58Check string, or `undefined` for a bad checksum or alphabet. */ +const decodeBase58check = (value: string): Uint8Array | undefined => { + try { + return base58check.decode(value) + } catch { + return undefined + } +} + const parseTransparentAddress = ( address: string ): ZcashAddressKind | undefined => { - const payload = bs58check.decodeUnsafe(address) + const payload = decodeBase58check(address) if (payload?.length !== transparentPayloadLength || payload[0] !== 0x1c) { return undefined } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f57d0fe89..509eb1471 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,15 +252,18 @@ importers: '@lifi/sdk': specifier: workspace:* version: link:../sdk + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 + '@scure/base': + specifier: ^1.2.6 + version: 1.2.6 bech32: specifier: ^2.0.0 version: 2.0.0 bitcoinjs-lib: specifier: ^7.0.2 version: 7.0.2(typescript@7.0.2) - bs58check: - specifier: ^4.0.0 - version: 4.0.0 devDependencies: '@lifi/data-types': specifier: ^7.1.6 From 3feaf803385d44906f8f9ca054357905aa8b4ddf Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 18:10:02 +0200 Subject: [PATCH 10/24] refactor(bitcoin): check only the transparent Zcash formats a route accepts --- .changeset/zcash-destination-address.md | 2 +- .../src/utils/zcashAddress.ts | 93 +++---------------- .../src/utils/zcashAddress.unit.spec.ts | 52 +++-------- 3 files changed, 26 insertions(+), 121 deletions(-) diff --git a/.changeset/zcash-destination-address.md b/.changeset/zcash-destination-address.md index bfc65346b..7bb48cbac 100644 --- a/.changeset/zcash-destination-address.md +++ b/.changeset/zcash-destination-address.md @@ -2,4 +2,4 @@ '@lifi/sdk-provider-bitcoin': minor --- -`BitcoinProvider.isAddress(address, ChainId.ZEC)` validates a Zcash receiver with its checksum. Transparent `t1` and `t3` addresses are accepted; TEX (`tex1`), Sapling (`zs1`) and unified (`u1`) addresses are recognised and refused until the API supports them. Any UTXO chain other than BTC and ZEC is refused, so a Bitcoin address no longer passes as the receiver of a chain whose format the provider does not know. `getBitcoinBalance` now leaves the amount of a non-BTC token unknown instead of reporting the Bitcoin balance for it. +`BitcoinProvider.isAddress(address, ChainId.ZEC)` validates a Zcash receiver with its checksum. Transparent `t1` and `t3` addresses are accepted; TEX (`tex1`), Sapling (`zs1`) and unified (`u1`) addresses are refused until the API supports them. Any UTXO chain other than BTC and ZEC is refused, so a Bitcoin address no longer passes as the receiver of a chain whose format the provider does not know. `getBitcoinBalance` now leaves the amount of a non-BTC token unknown instead of reporting the Bitcoin balance for it. diff --git a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts index d55028057..751e854cc 100644 --- a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts +++ b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts @@ -1,89 +1,24 @@ import { sha256 } from '@noble/hashes/sha2' import { createBase58check } from '@scure/base' -import { type BechLib, bech32, bech32m } from 'bech32' -/** The mainnet Zcash address kinds, named by the receiver they encode. */ -export type ZcashAddressKind = 'p2pkh' | 'p2sh' | 'tex' | 'sapling' | 'unified' +const base58check = createBase58check(sha256) /** - * The kinds a LI.FI route may send to. The API handles transparent receivers - * only; add a kind here once the backend confirms it. + * Whether `address` is a mainnet transparent Zcash address (`t1` P2PKH or `t3` + * P2SH) with a valid checksum. The API handles transparent receivers only, so + * TEX, Sapling and unified addresses, and testnet forms, return `false`. */ -export const acceptedZcashAddressKinds: ReadonlySet = - new Set(['p2pkh', 'p2sh']) - -// Unified addresses outgrow Bech32's default 90-character limit: the longest -// official test vector (zcash-test-vectors, unified_address.json) has 509. -const bech32LengthLimit = 1024 - -// A transparent address decodes to a 2-byte version prefix and a 20-byte hash. -const transparentPayloadLength = 22 - -const base58check = createBase58check(sha256) - -/** The payload of a Base58Check string, or `undefined` for a bad checksum or alphabet. */ -const decodeBase58check = (value: string): Uint8Array | undefined => { +export const isZcashAddress = (address: string): boolean => { + let payload: Uint8Array try { - return base58check.decode(value) + payload = base58check.decode(address) } catch { - return undefined - } -} - -const parseTransparentAddress = ( - address: string -): ZcashAddressKind | undefined => { - const payload = decodeBase58check(address) - if (payload?.length !== transparentPayloadLength || payload[0] !== 0x1c) { - return undefined - } - if (payload[1] === 0xb8) { - return 'p2pkh' + return false } - if (payload[1] === 0xbd) { - return 'p2sh' - } - return undefined -} - -interface Bech32Kind { - kind: ZcashAddressKind - prefix: string - encoding: BechLib - /** Unified addresses are F4Jumbled, so only their checksum is checked. */ - payloadLength?: number -} - -const bech32Kinds: Bech32Kind[] = [ - { kind: 'tex', prefix: 'tex', encoding: bech32m, payloadLength: 20 }, - { kind: 'sapling', prefix: 'zs', encoding: bech32, payloadLength: 43 }, - { kind: 'unified', prefix: 'u', encoding: bech32m }, -] - -const parseBech32Address = (address: string): ZcashAddressKind | undefined => - bech32Kinds.find(({ prefix, encoding, payloadLength }) => { - const decoded = encoding.decodeUnsafe(address, bech32LengthLimit) - if (decoded?.prefix !== prefix) { - return false - } - const payload = encoding.fromWordsUnsafe(decoded.words) - return ( - payload !== undefined && - (payloadLength === undefined || payload.length === payloadLength) - ) - })?.kind - -/** - * Decodes a mainnet Zcash address and verifies its checksum. A testnet address, - * or any other version or prefix, returns `undefined`. - */ -export const parseZcashAddress = ( - address: string -): ZcashAddressKind | undefined => - parseTransparentAddress(address) ?? parseBech32Address(address) - -/** Whether `address` is a Zcash address a LI.FI route may send to. */ -export const isZcashAddress = (address: string): boolean => { - const kind = parseZcashAddress(address) - return kind !== undefined && acceptedZcashAddressKinds.has(kind) + // A 2-byte version prefix (0x1CB8 or 0x1CBD) and a 20-byte hash. + return ( + payload.length === 22 && + payload[0] === 0x1c && + (payload[1] === 0xb8 || payload[1] === 0xbd) + ) } diff --git a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts index 8c162087c..3f88ec269 100644 --- a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts +++ b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - acceptedZcashAddressKinds, - isZcashAddress, - parseZcashAddress, -} from './zcashAddress.js' +import { isZcashAddress } from './zcashAddress.js' // Sources: ZIP 320 (t1 and its TEX form), ZIP 214 (t3), zcash-test-vectors // unified_address.json (u1, and the Sapling raw address of row 0 encoded as zs1). @@ -14,59 +10,33 @@ const sapling = 'zs1mrhc9y7jdh5r9ece8u5khgvj9kg0zgkxzdduyv0whkg7lkcrkx5xqem3e48avjq9wn2rukydkwn' const unified = 'u1l8xunezsvhq8fgzfl7404m450nwnd76zshscn6nfys7vyz2ywyh4cc5daaq0c7q2su5lqfh23sp7fkf3kt27ve5948mzpfdvckzaect2jtte308mkwlycj2u0eac077wu70vqcetkxf' -// The longest entry in unified_address.json: past Bech32's default 90-character limit. -const unifiedLongest = - 'u1tqx832p4wsfe9pd67ggm3qsmfuvdhqvw2259y7uwug7y0lpeu87fmgpqh3zmamex3fzs0d4ct4hhsg2csj5z0q5f3f7n656ap8e4nlng9c4440rz9s7ekxanfw6g84f7vu82fumtmlz3vstl2a9ufa0970k4knsz2wpsjt2xycqeay76pt4fx3ak9y7mps2q6qe2n2h7wkakxr7xu6vd36zhhzgln7ttmrzc0f9ye3jmyu2pp8l8rect87lfxj2fgckcwz3svdx70a947fz04kgu7e907enzrk676zdkdmuyw2kyrclkmj62kmyy2rjetpus7knmxfuu7z0m63uwfhdynhuu3yrjqu5y089v8zwnh60mw5ngc0kszdjmc339fk9mjn396m5ekv7h7td7fa0u9097xph3y5vth9af4sw6ykxdms84wr544mxxqtmgj027d9e8rnlrazge0kwyydyhder3chwhmaqjk9skuxgxzternw4xx962qed' // The t1 above with its last character changed: the checksum no longer matches. const t1WithBadChecksum = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yD' // Testnet forms of the same hash: version 0x1D25 and HRP `textest`. const testnetTransparent = 'tmMcWbZU8t39htCR1fQRfRSHtkW4p3Ax5Se' const testnetTex = 'textest1s2rt77ggv6q989lr49rkgzmh5slsksa90ej7wz' -const bitcoinSegwit = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' -const bitcoinLegacy = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2' -describe('parseZcashAddress', () => { +describe('isZcashAddress', () => { it.each([ ['p2pkh', t1], ['p2sh', t3], - ['tex', tex], - ['sapling', sapling], - ['unified', unified], - ['unified', unifiedLongest], - ])('decodes a %s address', (kind, address) => { - expect(parseZcashAddress(address)).toBe(kind) + ])('accepts a %s address', (_, address) => { + expect(isZcashAddress(address)).toBe(true) }) it.each([ + ['a TEX address', tex], + ['a Sapling address', sapling], + ['a unified address', unified], ['a corrupted checksum', t1WithBadChecksum], ['a testnet transparent address', testnetTransparent], ['a testnet TEX address', testnetTex], - ['a Bitcoin segwit address', bitcoinSegwit], - ['a Bitcoin legacy address', bitcoinLegacy], + ['a Bitcoin segwit address', 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'], + ['a Bitcoin legacy address', '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'], ['an EVM address', '0xB095274743941e953c746F9C228DA9c18Bb6ec29'], ['an empty string', ''], ['free text', 'not-an-address'], - ])('rejects %s', (_, address) => { - expect(parseZcashAddress(address)).toBeUndefined() - }) -}) - -describe('isZcashAddress', () => { - it('accepts only the transparent kinds a route may send to', () => { - expect([...acceptedZcashAddressKinds].sort()).toEqual(['p2pkh', 'p2sh']) - expect(isZcashAddress(t1)).toBe(true) - expect(isZcashAddress(t3)).toBe(true) - }) - - it('refuses kinds the backend does not handle yet', () => { - expect(isZcashAddress(tex)).toBe(false) - expect(isZcashAddress(sapling)).toBe(false) - expect(isZcashAddress(unified)).toBe(false) - }) - - it('refuses a corrupted checksum and addresses of other chains', () => { - expect(isZcashAddress(t1WithBadChecksum)).toBe(false) - expect(isZcashAddress(bitcoinSegwit)).toBe(false) - expect(isZcashAddress(bitcoinLegacy)).toBe(false) + ])('refuses %s', (_, address) => { + expect(isZcashAddress(address)).toBe(false) }) }) From 8ef92d392dd641148691bc406ddf98da27ead7fe Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 18:10:06 +0200 Subject: [PATCH 11/24] fix(bitcoin): keep the Bitcoin block number off non-BTC tokens when the RPC fails --- .../src/actions/getBitcoinBalance.ts | 16 ++++------------ .../src/actions/getBitcoinBalance.unit.spec.ts | 3 ++- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts index 6c2a0bcfc..4e8c0344e 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts @@ -35,19 +35,11 @@ export const getBitcoinBalance = async ( const blockNumber = blockCount.status === 'fulfilled' ? BigInt(blockCount.value) : 0n - - if (balance.status !== 'fulfilled') { - // RPC failed — leave amount undefined so callers can distinguish - // an unknown balance from a known zero. - return tokens.map((token) => ({ - ...token, - blockNumber, - })) - } + // RPC failed — leave amount undefined so callers can distinguish + // an unknown balance from a known zero. + const amount = balance.status === 'fulfilled' ? balance.value : undefined return tokens.map((token) => - isBitcoinToken(token) - ? { ...token, amount: balance.value, blockNumber } - : { ...token } + isBitcoinToken(token) ? { ...token, amount, blockNumber } : { ...token } ) } diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts index 71493703a..b42d6f6fe 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts @@ -50,7 +50,7 @@ describe('getBitcoinBalance', () => { }) it('gives the Bitcoin balance to BTC tokens only in a mixed list', async () => { - vi.spyOn(console, 'warn').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) for (const tokens of [ [nativeToken(ChainId.BTC, 'BTC'), nativeToken(ChainId.ZEC, 'ZEC')], [nativeToken(ChainId.ZEC, 'ZEC'), nativeToken(ChainId.BTC, 'BTC')], @@ -61,5 +61,6 @@ describe('getBitcoinBalance', () => { ) expect(bySymbol).toEqual({ BTC: 1234n, ZEC: undefined }) } + warn.mockRestore() }) }) From bcbb00e874e4b04d6f7cca1c22b611750f08850a Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 18:10:10 +0200 Subject: [PATCH 12/24] docs(sdk): describe isAddress by behaviour, not history --- packages/sdk/src/types/core.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/types/core.ts b/packages/sdk/src/types/core.ts index 6b42b26d4..1db6d0a8e 100644 --- a/packages/sdk/src/types/core.ts +++ b/packages/sdk/src/types/core.ts @@ -92,13 +92,10 @@ export type RPCUrls = Partial> export interface SDKProvider { readonly type: ChainType /** - * Validates a wallet address. Without `chainId`, the provider answers as - * before, which may not cover every chain of its ecosystem: the UTXO - * provider accepts Bitcoin formats only. `chainId`, when given, is a - * chain of this provider's ecosystem: a provider whose chains share one - * address format may ignore it; a provider whose chains differ accepts only - * that chain's format and refuses a chain it does not know. Never forward - * `chainId` to a library function whose second parameter means something else. + * Validates a wallet address. With `chainId`, a provider whose chains use + * different address formats accepts only that chain's format and refuses a + * chain it does not know. Never forward `chainId` to a library function whose + * second parameter means something else. */ isAddress(address: string, chainId?: ChainId): boolean /** From 8827b318e9f32eb12e627a626625a918b546bf4e Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 18:27:40 +0200 Subject: [PATCH 13/24] refactor(bitcoin): reuse bitcoinjs-lib's sha256 for Base58Check --- packages/sdk-provider-bitcoin/package.json | 1 - packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts | 4 ++-- pnpm-lock.yaml | 3 --- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/sdk-provider-bitcoin/package.json b/packages/sdk-provider-bitcoin/package.json index dc7bb5b7c..9f446f6db 100644 --- a/packages/sdk-provider-bitcoin/package.json +++ b/packages/sdk-provider-bitcoin/package.json @@ -47,7 +47,6 @@ "@bigmi/core": "^0.9.2", "@bitcoinerlab/secp256k1": "^2.0.0", "@lifi/sdk": "workspace:*", - "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", "bech32": "^2.0.0", "bitcoinjs-lib": "^7.0.2" diff --git a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts index 751e854cc..a9bcc138e 100644 --- a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts +++ b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts @@ -1,7 +1,7 @@ -import { sha256 } from '@noble/hashes/sha2' import { createBase58check } from '@scure/base' +import { crypto } from 'bitcoinjs-lib' -const base58check = createBase58check(sha256) +const base58check = createBase58check(crypto.sha256) /** * Whether `address` is a mainnet transparent Zcash address (`t1` P2PKH or `t3` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 509eb1471..3495b0d1e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,9 +252,6 @@ importers: '@lifi/sdk': specifier: workspace:* version: link:../sdk - '@noble/hashes': - specifier: ^1.8.0 - version: 1.8.0 '@scure/base': specifier: ^1.2.6 version: 1.2.6 From 0b7e3b8c63683eb30c45f84f883dff306eb44387 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 23 Sep 2026 18:27:44 +0200 Subject: [PATCH 14/24] chore: drop the changeset for an invisible Ethereum provider change The @lifi/sdk minor bump already re-releases the Ethereum provider. --- .changeset/ethereum-is-address-wrapper.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/ethereum-is-address-wrapper.md diff --git a/.changeset/ethereum-is-address-wrapper.md b/.changeset/ethereum-is-address-wrapper.md deleted file mode 100644 index b41cd3378..000000000 --- a/.changeset/ethereum-is-address-wrapper.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@lifi/sdk-provider-ethereum': patch ---- - -`EthereumProvider.isAddress` no longer passes a second argument to viem's `isAddress`, whose second parameter is `options`. Results are unchanged. From e7c6c201815c324a253271512dd99024c42fca2a Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 30 Sep 2026 14:09:12 +0200 Subject: [PATCH 15/24] feat(sdk): let a provider serve listed chains beside the generic provider of its type --- .changeset/chain-aware-is-address.md | 2 ++ packages/sdk/src/client/createClient.ts | 13 ++++--- .../sdk/src/client/createClient.unit.spec.ts | 32 +++++++++++++++++ packages/sdk/src/client/findProvider.ts | 21 +++++++++++ .../sdk/src/client/findProvider.unit.spec.ts | 35 +++++++++++++++++++ packages/sdk/src/index.ts | 1 + packages/sdk/src/types/core.ts | 7 +++- 7 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 packages/sdk/src/client/findProvider.ts create mode 100644 packages/sdk/src/client/findProvider.unit.spec.ts diff --git a/.changeset/chain-aware-is-address.md b/.changeset/chain-aware-is-address.md index 5eedb8ce0..16e0ea406 100644 --- a/.changeset/chain-aware-is-address.md +++ b/.changeset/chain-aware-is-address.md @@ -3,3 +3,5 @@ --- `SDKProvider.isAddress` accepts an optional `chainId`. Without it, a provider answers as before. With it, a provider whose chains use different address formats accepts only that chain's format and refuses a chain it does not know. A provider must never forward the chain ID to a library function whose second parameter means something else. + +A provider can declare the `chainIds` it serves, so a chain-specific provider can sit beside the generic provider of the same chain type. `client.getProvider(type, chainId?)` returns the provider that lists the chain, otherwise the provider of that type that lists no chains; `getProvider(type)` answers as before. The new `findProvider(providers, chainType, chainId?)` applies the same rule to any provider list. `setProviders` replaces only a provider with the same type and the same `chainIds`. diff --git a/packages/sdk/src/client/createClient.ts b/packages/sdk/src/client/createClient.ts index 053fc90aa..9036d404f 100644 --- a/packages/sdk/src/client/createClient.ts +++ b/packages/sdk/src/client/createClient.ts @@ -8,8 +8,13 @@ import type { } from '../types/core.js' import { checkPackageUpdates } from '../utils/checkPackageUpdates.js' import { name, version } from '../version.js' +import { findProvider } from './findProvider.js' import { getClientStorage } from './getClientStorage.js' +// A provider replaces only one that serves the same chains. +const providerScope = (provider: SDKProvider): string => + `${provider.type}:${provider.chainIds?.join(',') ?? ''}` + export function createClient(options: SDKConfig): SDKClient { if (!options.integrator) { throw new Error( @@ -68,15 +73,15 @@ export function createClient(options: SDKConfig): SDKClient { get providers() { return _providers }, - getProvider(type: ChainType) { - return this.providers.find((provider) => provider.type === type) + getProvider(type: ChainType, chainId?: ChainId) { + return findProvider(this.providers, type, chainId) }, setProviders(newProviders: SDKProvider[]) { const providerMap = new Map( - this.providers.map((provider) => [provider.type, provider]) + this.providers.map((provider) => [providerScope(provider), provider]) ) for (const provider of newProviders) { - providerMap.set(provider.type, provider) + providerMap.set(providerScope(provider), provider) } _providers = Array.from(providerMap.values()) }, diff --git a/packages/sdk/src/client/createClient.unit.spec.ts b/packages/sdk/src/client/createClient.unit.spec.ts index 81e923dd2..80dd2c32c 100644 --- a/packages/sdk/src/client/createClient.unit.spec.ts +++ b/packages/sdk/src/client/createClient.unit.spec.ts @@ -197,6 +197,38 @@ describe('createClient', () => { expect(client.getProvider(ChainType.EVM)).toBe(evmProvider2) expect(client.getProvider(ChainType.SVM)).toBe(solanaProvider) }) + + it('keeps a chain-specific provider beside the generic one of its type', () => { + const bitcoin = UTXO() + const zcash = { ...UTXO(), chainIds: [ChainId.ZEC] } + const client = createClient({ integrator: 'test-app' }) + + client.setProviders([bitcoin, zcash]) + + expect(client.providers).toHaveLength(2) + expect(client.providers[0]).toBe(bitcoin) + expect(client.providers[1]).toBe(zcash) + expect(client.getProvider(ChainType.UTXO)).toBe(bitcoin) + expect(client.getProvider(ChainType.UTXO, ChainId.BTC)).toBe(bitcoin) + expect(client.getProvider(ChainType.UTXO, ChainId.ZEC)).toBe(zcash) + }) + + it('replaces only the provider that serves the same chains', () => { + const bitcoin1 = UTXO() + const bitcoin2 = UTXO() + const zcash = { ...UTXO(), chainIds: [ChainId.ZEC] } + const client = createClient({ + integrator: 'test-app', + providers: [bitcoin1, zcash], + }) + + client.setProviders([bitcoin2]) + + expect(client.providers).toHaveLength(2) + expect(client.providers[0]).toBe(bitcoin2) + expect(client.providers[1]).toBe(zcash) + expect(client.getProvider(ChainType.UTXO, ChainId.ZEC)).toBe(zcash) + }) }) describe('chain management', () => { diff --git a/packages/sdk/src/client/findProvider.ts b/packages/sdk/src/client/findProvider.ts new file mode 100644 index 000000000..606663e83 --- /dev/null +++ b/packages/sdk/src/client/findProvider.ts @@ -0,0 +1,21 @@ +import type { ChainId, ChainType } from '@lifi/types' +import type { SDKProvider } from '../types/core.js' + +/** + * The provider that serves a chain: the one that lists `chainId` in its + * `chainIds`, otherwise the provider of `chainType` that lists no chains. + */ +export const findProvider = >( + providers: readonly T[], + chainType: ChainType, + chainId?: ChainId +): T | undefined => + (chainId === undefined + ? undefined + : providers.find( + (provider) => + provider.type === chainType && provider.chainIds?.includes(chainId) + )) ?? + providers.find( + (provider) => provider.type === chainType && !provider.chainIds + ) diff --git a/packages/sdk/src/client/findProvider.unit.spec.ts b/packages/sdk/src/client/findProvider.unit.spec.ts new file mode 100644 index 000000000..0acfe2167 --- /dev/null +++ b/packages/sdk/src/client/findProvider.unit.spec.ts @@ -0,0 +1,35 @@ +import { ChainId, ChainType } from '@lifi/types' +import { describe, expect, it } from 'vitest' +import { findProvider } from './findProvider.js' + +const provider = (type: ChainType, chainIds?: ChainId[]) => ({ type, chainIds }) + +describe('findProvider', () => { + const evm = provider(ChainType.EVM) + const bitcoin = provider(ChainType.UTXO) + const zcash = provider(ChainType.UTXO, [ChainId.ZEC]) + const providers = [evm, bitcoin, zcash] + + it('returns the provider that lists the chain', () => { + expect(findProvider(providers, ChainType.UTXO, ChainId.ZEC)).toBe(zcash) + }) + + it('falls back to the provider of the type that lists no chains', () => { + expect(findProvider(providers, ChainType.UTXO, ChainId.BTC)).toBe(bitcoin) + expect(findProvider(providers, ChainType.UTXO)).toBe(bitcoin) + expect(findProvider(providers, ChainType.EVM, ChainId.ARB)).toBe(evm) + }) + + it('does not depend on the order of the list', () => { + expect(findProvider([zcash, bitcoin], ChainType.UTXO)).toBe(bitcoin) + expect(findProvider([zcash, bitcoin], ChainType.UTXO, ChainId.ZEC)).toBe( + zcash + ) + }) + + it('returns undefined when no provider serves the chain', () => { + expect(findProvider([zcash], ChainType.UTXO)).toBeUndefined() + expect(findProvider([zcash], ChainType.UTXO, ChainId.BTC)).toBeUndefined() + expect(findProvider(providers, ChainType.SVM, ChainId.SOL)).toBeUndefined() + }) +}) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b7f84d9a3..e8f90392d 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -23,6 +23,7 @@ export { actions } from './actions/index.js' export { patchContractCalls } from './actions/patchContractCalls.js' export { relayTransaction } from './actions/relayTransaction.js' export { createClient } from './client/createClient.js' +export { findProvider } from './client/findProvider.js' export { getActionMessage, getSubstatusMessage } from './core/actionMessages.js' export { BaseStepExecutionTask } from './core/BaseStepExecutionTask.js' export { BaseStepExecutor } from './core/BaseStepExecutor.js' diff --git a/packages/sdk/src/types/core.ts b/packages/sdk/src/types/core.ts index 1db6d0a8e..7b6860346 100644 --- a/packages/sdk/src/types/core.ts +++ b/packages/sdk/src/types/core.ts @@ -91,6 +91,11 @@ export type RPCUrls = Partial> export interface SDKProvider { readonly type: ChainType + /** + * The chains this provider serves. Omit it to serve every chain of `type` + * that no other provider lists. + */ + readonly chainIds?: readonly ChainId[] /** * Validates a wallet address. With `chainId`, a provider whose chains use * different address formats accepts only that chain's format and refuses a @@ -121,7 +126,7 @@ export interface SDKProvider { export interface SDKClient { config: SDKBaseConfig providers: SDKProvider[] - getProvider(type: ChainType): SDKProvider | undefined + getProvider(type: ChainType, chainId?: ChainId): SDKProvider | undefined setProviders(providers: SDKProvider[]): void setChains(chains: ExtendedChain[]): void getChains(): Promise From f03e2f8113f3531bd221d1d06d3452366a093dec Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 30 Sep 2026 14:10:47 +0200 Subject: [PATCH 16/24] feat(sdk-provider-zcash): add a destination-only Zcash provider --- .changeset/zcash-provider.md | 5 + README.md | 9 ++ packages/sdk-provider-zcash/package.json | 93 +++++++++++++++++++ .../sdk-provider-zcash/src/ZcashProvider.ts | 30 ++++++ .../src/ZcashProvider.unit.spec.ts | 63 +++++++++++++ packages/sdk-provider-zcash/src/index.ts | 2 + .../src/utils/zcashAddress.ts | 24 +++++ .../src/utils/zcashAddress.unit.spec.ts | 42 +++++++++ packages/sdk-provider-zcash/src/version.ts | 2 + packages/sdk-provider-zcash/tsconfig.json | 17 ++++ .../sdk-provider-zcash/tsconfig.spec.json | 22 +++++ packages/sdk-provider-zcash/tsdown.config.ts | 38 ++++++++ pnpm-lock.yaml | 28 ++++++ 13 files changed, 375 insertions(+) create mode 100644 .changeset/zcash-provider.md create mode 100644 packages/sdk-provider-zcash/package.json create mode 100644 packages/sdk-provider-zcash/src/ZcashProvider.ts create mode 100644 packages/sdk-provider-zcash/src/ZcashProvider.unit.spec.ts create mode 100644 packages/sdk-provider-zcash/src/index.ts create mode 100644 packages/sdk-provider-zcash/src/utils/zcashAddress.ts create mode 100644 packages/sdk-provider-zcash/src/utils/zcashAddress.unit.spec.ts create mode 100644 packages/sdk-provider-zcash/src/version.ts create mode 100644 packages/sdk-provider-zcash/tsconfig.json create mode 100644 packages/sdk-provider-zcash/tsconfig.spec.json create mode 100644 packages/sdk-provider-zcash/tsdown.config.ts diff --git a/.changeset/zcash-provider.md b/.changeset/zcash-provider.md new file mode 100644 index 000000000..c10c60463 --- /dev/null +++ b/.changeset/zcash-provider.md @@ -0,0 +1,5 @@ +--- +'@lifi/sdk-provider-zcash': minor +--- + +New package. `ZcashProvider()` validates receivers on ZEC, a destination-only chain: transparent `t1` and `t3` addresses with a valid checksum. It serves `ChainId.ZEC` beside `BitcoinProvider`, leaves balances unknown and cannot execute a step. diff --git a/README.md b/README.md index 1c3c1fc1b..184752fc2 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,13 @@ needs no ecosystem library. To discover and connect browser wallets such as Frei xBull, or Lobstr, we recommend [Stellar Wallets Kit](https://github.com/Creit-Tech/Stellar-Wallets-Kit). +**Zcash** +```bash +pnpm add @lifi/sdk-provider-zcash +``` + +ZEC is a destination-only chain: `ZcashProvider` validates transparent `t1` and `t3` receivers and needs no wallet. Register it beside `BitcoinProvider`; it serves ZEC only. + ## Architecture The LI.FI SDK uses a modular provider architecture: @@ -134,6 +141,7 @@ import { BitcoinProvider } from '@lifi/sdk-provider-bitcoin' import { SuiProvider } from '@lifi/sdk-provider-sui' import { TronProvider } from '@lifi/sdk-provider-tron' import { StellarProvider } from '@lifi/sdk-provider-stellar' +import { ZcashProvider } from '@lifi/sdk-provider-zcash' const client = createClient({ integrator: 'Your dApp/company name', @@ -144,6 +152,7 @@ const client = createClient({ SuiProvider({ /* options */ }), TronProvider({ /* options */ }), StellarProvider({ /* options */ }), + ZcashProvider(), ], }) ``` diff --git a/packages/sdk-provider-zcash/package.json b/packages/sdk-provider-zcash/package.json new file mode 100644 index 000000000..58a0cf5db --- /dev/null +++ b/packages/sdk-provider-zcash/package.json @@ -0,0 +1,93 @@ +{ + "name": "@lifi/sdk-provider-zcash", + "version": "0.0.0", + "description": "LI.FI Zcash SDK Provider for Any-to-Any Cross-Chain-Swap", + "homepage": "https://github.com/lifinance/sdk", + "bugs": { + "url": "https://github.com/lifinance/sdk/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/lifinance/sdk.git", + "directory": "packages/sdk-provider-zcash" + }, + "license": "Apache-2.0", + "author": "Eugene Chybisov ", + "type": "module", + "sideEffects": false, + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "typings": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js", + "default": "./dist/cjs/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "rm -rf dist && node ../../scripts/version.js && node ../../scripts/build.js && printf '{\"type\":\"commonjs\"}' > ./dist/cjs/package.json && printf '{\"type\":\"module\",\"sideEffects\":false}' > ./dist/esm/package.json", + "build:prerelease": "node ../../scripts/prerelease.js && cpy '../../README.md' .", + "build:postrelease": "node ../../scripts/postrelease.js && rm -rf *.md", + "build:version": "node ../../scripts/version.js", + "clean": "rm -rf dist", + "coverage": "vitest run --coverage", + "test": "vitest --run", + "test:cov": "pnpm test --coverage", + "test:unit": "pnpm test .unit.spec.ts --passWithNoTests", + "check:types": "tsc --noEmit && tsc -p tsconfig.spec.json --noEmit", + "check:types:specs": "tsc -p tsconfig.spec.json --noEmit", + "check:circular-deps": "madge --circular $(find ./src -name '*.ts')", + "check:circular-deps-graph": "madge --circular $(find ./src -name '*.ts') --image graph.svg", + "watch": "tsdown --watch" + }, + "dependencies": { + "@lifi/sdk": "workspace:*", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6" + }, + "devDependencies": { + "@vitest/coverage-v8": "^5.0.1", + "cpy-cli": "^7.0.0", + "madge": "^8.0.0", + "typescript": "^7.0.2", + "vitest": "^5.0.1" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "CHANGELOG.md", + "dist/**", + "!dist/**/*.tsbuildinfo", + "src/**/*.ts", + "!src/**/*.spec.ts", + "!src/**/*.test.ts", + "!src/**/*.mock.ts", + "!src/**/*.spec.ts", + "!src/**/*.handlers.ts", + "!src/**/*.tsbuildinfo", + "!**/__mocks__/**", + "!*.tmp", + "!*.env", + "!tsconfig.json" + ], + "keywords": [ + "swap", + "bridge", + "bridge-aggregation", + "cross-chain", + "cross-chain-applications", + "cross-chain-bridge", + "dapp", + "defi", + "zcash", + "zec", + "lifi", + "multi-chain", + "sdk", + "web3" + ] +} diff --git a/packages/sdk-provider-zcash/src/ZcashProvider.ts b/packages/sdk-provider-zcash/src/ZcashProvider.ts new file mode 100644 index 000000000..287b72198 --- /dev/null +++ b/packages/sdk-provider-zcash/src/ZcashProvider.ts @@ -0,0 +1,30 @@ +import { + ChainId, + ChainType, + LiFiErrorCode, + ProviderError, + type SDKProvider, +} from '@lifi/sdk' +import { isZcashAddress } from './utils/zcashAddress.js' + +export function ZcashProvider(): SDKProvider { + return { + get type() { + return ChainType.UTXO + }, + chainIds: [ChainId.ZEC], + isAddress: (address, chainId) => + (chainId === undefined || chainId === ChainId.ZEC) && + isZcashAddress(address), + resolveAddress: async () => undefined, + // No Zcash balance source yet, so every amount stays unknown. + getBalance: async (_client, _walletAddress, tokens) => + tokens.map((token) => ({ ...token })), + getStepExecutor: async () => { + throw new ProviderError( + LiFiErrorCode.ProviderUnavailable, + 'ZEC is a destination-only chain.' + ) + }, + } +} diff --git a/packages/sdk-provider-zcash/src/ZcashProvider.unit.spec.ts b/packages/sdk-provider-zcash/src/ZcashProvider.unit.spec.ts new file mode 100644 index 000000000..d97862c8f --- /dev/null +++ b/packages/sdk-provider-zcash/src/ZcashProvider.unit.spec.ts @@ -0,0 +1,63 @@ +import { ChainId, ChainType, type SDKClient, type Token } from '@lifi/sdk' +import { describe, expect, it } from 'vitest' +import { ZcashProvider } from './ZcashProvider.js' + +const t1 = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC' +const t3 = 't3LmX1cxWPPPqL4TZHx42HU3U5ghbFjRiif' +const bitcoin = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' +const client = {} as SDKClient + +describe('ZcashProvider', () => { + const provider = ZcashProvider() + + it('serves ZEC as a UTXO chain', () => { + expect(provider.type).toBe(ChainType.UTXO) + expect(provider.chainIds).toEqual([ChainId.ZEC]) + }) + + it('accepts a transparent Zcash address without a chain and for ZEC', () => { + for (const address of [t1, t3]) { + expect(provider.isAddress(address)).toBe(true) + expect(provider.isAddress(address, ChainId.ZEC)).toBe(true) + } + }) + + it('refuses a Zcash address for any other chain', () => { + expect(provider.isAddress(t1, ChainId.BTC)).toBe(false) + expect(provider.isAddress(t1, ChainId.ETH)).toBe(false) + }) + + it('refuses a Bitcoin address', () => { + expect(provider.isAddress(bitcoin)).toBe(false) + expect(provider.isAddress(bitcoin, ChainId.ZEC)).toBe(false) + }) + + it('resolves no names', async () => { + await expect( + provider.resolveAddress('alice.zec', client) + ).resolves.toBeUndefined() + }) + + it('leaves every balance unknown', async () => { + const token: Token = { + chainId: ChainId.ZEC, + address: 'zcash', + symbol: 'ZEC', + decimals: 8, + name: 'Zcash', + priceUSD: '0', + } + + const [balance] = await provider.getBalance(client, t1, [token]) + + expect(balance).toEqual(token) + expect(balance).not.toBe(token) + expect(balance).not.toHaveProperty('amount') + }) + + it('cannot execute a step', async () => { + await expect( + provider.getStepExecutor({ routeId: 'route' }) + ).rejects.toThrow('ZEC is a destination-only chain.') + }) +}) diff --git a/packages/sdk-provider-zcash/src/index.ts b/packages/sdk-provider-zcash/src/index.ts new file mode 100644 index 000000000..887d7857a --- /dev/null +++ b/packages/sdk-provider-zcash/src/index.ts @@ -0,0 +1,2 @@ +// biome-ignore lint/performance/noBarrelFile: module entrypoint +export { ZcashProvider } from './ZcashProvider.js' diff --git a/packages/sdk-provider-zcash/src/utils/zcashAddress.ts b/packages/sdk-provider-zcash/src/utils/zcashAddress.ts new file mode 100644 index 000000000..751e854cc --- /dev/null +++ b/packages/sdk-provider-zcash/src/utils/zcashAddress.ts @@ -0,0 +1,24 @@ +import { sha256 } from '@noble/hashes/sha2' +import { createBase58check } from '@scure/base' + +const base58check = createBase58check(sha256) + +/** + * Whether `address` is a mainnet transparent Zcash address (`t1` P2PKH or `t3` + * P2SH) with a valid checksum. The API handles transparent receivers only, so + * TEX, Sapling and unified addresses, and testnet forms, return `false`. + */ +export const isZcashAddress = (address: string): boolean => { + let payload: Uint8Array + try { + payload = base58check.decode(address) + } catch { + return false + } + // A 2-byte version prefix (0x1CB8 or 0x1CBD) and a 20-byte hash. + return ( + payload.length === 22 && + payload[0] === 0x1c && + (payload[1] === 0xb8 || payload[1] === 0xbd) + ) +} diff --git a/packages/sdk-provider-zcash/src/utils/zcashAddress.unit.spec.ts b/packages/sdk-provider-zcash/src/utils/zcashAddress.unit.spec.ts new file mode 100644 index 000000000..3f88ec269 --- /dev/null +++ b/packages/sdk-provider-zcash/src/utils/zcashAddress.unit.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { isZcashAddress } from './zcashAddress.js' + +// Sources: ZIP 320 (t1 and its TEX form), ZIP 214 (t3), zcash-test-vectors +// unified_address.json (u1, and the Sapling raw address of row 0 encoded as zs1). +const t1 = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC' +const t3 = 't3LmX1cxWPPPqL4TZHx42HU3U5ghbFjRiif' +const tex = 'tex1s2rt77ggv6q989lr49rkgzmh5slsksa9khdgte' +const sapling = + 'zs1mrhc9y7jdh5r9ece8u5khgvj9kg0zgkxzdduyv0whkg7lkcrkx5xqem3e48avjq9wn2rukydkwn' +const unified = + 'u1l8xunezsvhq8fgzfl7404m450nwnd76zshscn6nfys7vyz2ywyh4cc5daaq0c7q2su5lqfh23sp7fkf3kt27ve5948mzpfdvckzaect2jtte308mkwlycj2u0eac077wu70vqcetkxf' +// The t1 above with its last character changed: the checksum no longer matches. +const t1WithBadChecksum = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yD' +// Testnet forms of the same hash: version 0x1D25 and HRP `textest`. +const testnetTransparent = 'tmMcWbZU8t39htCR1fQRfRSHtkW4p3Ax5Se' +const testnetTex = 'textest1s2rt77ggv6q989lr49rkgzmh5slsksa90ej7wz' + +describe('isZcashAddress', () => { + it.each([ + ['p2pkh', t1], + ['p2sh', t3], + ])('accepts a %s address', (_, address) => { + expect(isZcashAddress(address)).toBe(true) + }) + + it.each([ + ['a TEX address', tex], + ['a Sapling address', sapling], + ['a unified address', unified], + ['a corrupted checksum', t1WithBadChecksum], + ['a testnet transparent address', testnetTransparent], + ['a testnet TEX address', testnetTex], + ['a Bitcoin segwit address', 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'], + ['a Bitcoin legacy address', '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'], + ['an EVM address', '0xB095274743941e953c746F9C228DA9c18Bb6ec29'], + ['an empty string', ''], + ['free text', 'not-an-address'], + ])('refuses %s', (_, address) => { + expect(isZcashAddress(address)).toBe(false) + }) +}) diff --git a/packages/sdk-provider-zcash/src/version.ts b/packages/sdk-provider-zcash/src/version.ts new file mode 100644 index 000000000..70152b5b8 --- /dev/null +++ b/packages/sdk-provider-zcash/src/version.ts @@ -0,0 +1,2 @@ +export const name = '@lifi/sdk-provider-zcash' +export const version = '0.0.0' diff --git a/packages/sdk-provider-zcash/tsconfig.json b/packages/sdk-provider-zcash/tsconfig.json new file mode 100644 index 000000000..1e2ed416e --- /dev/null +++ b/packages/sdk-provider-zcash/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", + "composite": true, + "sourceMap": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*", "./src/**/*.json"], + "exclude": [ + "tests", + "./src/**/*.spec.ts", + "./src/**/*.mock.ts", + "./src/**/*.handlers.ts" + ] +} diff --git a/packages/sdk-provider-zcash/tsconfig.spec.json b/packages/sdk-provider-zcash/tsconfig.spec.json new file mode 100644 index 000000000..8de1c4fd6 --- /dev/null +++ b/packages/sdk-provider-zcash/tsconfig.spec.json @@ -0,0 +1,22 @@ +{ + // Type-checks the files `tsconfig.json` excludes: `*.spec.ts`, `*.mock.ts` + // and `*.handlers.ts`. Vitest transpiles without type-checking, so without + // this config nothing in the repo ever type-checks a test file — which is how + // a wrong type assertion can sit in a green test asserting something other + // than what it claims. + // + // Everything else is inherited unchanged, so tests are held to the same + // `strict` / `isolatedDeclarations` / `noUnusedLocals` bar as `src`. Only two + // things are overridden: `exclude` (to stop dropping the test files) and + // `tsBuildInfoFile` (the inherited path is the one `check:types` writes — + // sharing it would make the two runs invalidate each other's cache). + // + // Run with `--noEmit` (see the `check:types:specs` script): a composite + // project may not set `noEmit` in its config, only on the command line. + "extends": "./tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.spec.tsbuildinfo" + }, + "include": ["./src/**/*", "./src/**/*.json"], + "exclude": ["tests"] +} diff --git a/packages/sdk-provider-zcash/tsdown.config.ts b/packages/sdk-provider-zcash/tsdown.config.ts new file mode 100644 index 000000000..20d8edde2 --- /dev/null +++ b/packages/sdk-provider-zcash/tsdown.config.ts @@ -0,0 +1,38 @@ +import { defineConfig } from 'tsdown' + +const entry = [ + 'src/**/*.ts', + '!src/**/*.spec.ts', + '!src/**/*.test.ts', + '!src/**/*.mock.ts', + '!src/**/*.handlers.ts', +] + +const shared = { + unbundle: true, + target: 'es2022' as const, + logLevel: 'warn' as const, + deps: { + skipNodeModulesBundle: true, + }, +} + +export default defineConfig([ + { + ...shared, + entry, + outDir: 'dist/esm', + format: 'esm', + sourcemap: true, + dts: { sourcemap: true }, + outExtensions: () => ({ js: '.js', dts: '.d.ts' }), + }, + { + ...shared, + entry, + outDir: 'dist/cjs', + format: 'cjs', + sourcemap: true, + outExtensions: () => ({ js: '.js' }), + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3495b0d1e..7b7ecbf9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -433,6 +433,34 @@ importers: specifier: ^5.0.3 version: 5.0.3(@types/node@26.6.3)(@vitest/coverage-v8@5.0.3)(msw@3.0.1(@types/node@26.6.3)(graphql@17.0.2)(supports-color@7.2.0)(typescript@7.0.2)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)(yaml@2.9.1)))(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)(yaml@2.9.1)) + packages/sdk-provider-zcash: + dependencies: + '@lifi/sdk': + specifier: workspace:* + version: link:../sdk + '@noble/hashes': + specifier: ^1.8.0 + version: 1.8.0 + '@scure/base': + specifier: ^1.2.6 + version: 1.2.6 + devDependencies: + '@vitest/coverage-v8': + specifier: ^5.0.1 + version: 5.0.1(vitest@5.0.1) + cpy-cli: + specifier: ^7.0.0 + version: 7.0.0 + madge: + specifier: ^8.0.0 + version: 8.0.0(supports-color@7.2.0)(typescript@7.0.2) + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vitest: + specifier: ^5.0.1 + version: 5.0.1(@types/node@26.6.2)(@vitest/coverage-v8@5.0.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.1)) + packages: '@0no-co/graphql.web@1.3.4': From a310c0b79f3238e6fe909bc89a258260e42438d2 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 30 Sep 2026 14:11:52 +0200 Subject: [PATCH 17/24] refactor(sdk-provider-bitcoin): leave Zcash to its own provider --- .changeset/bitcoin-known-chains-only.md | 5 +++ .changeset/zcash-destination-address.md | 5 --- packages/sdk-provider-bitcoin/package.json | 1 - .../src/BitcoinProvider.ts | 19 ++------ .../src/BitcoinProvider.unit.spec.ts | 45 +++++++++++-------- .../src/actions/getBitcoinBalance.ts | 3 +- packages/sdk-provider-bitcoin/src/types.ts | 3 +- .../src/utils/zcashAddress.ts | 24 ---------- .../src/utils/zcashAddress.unit.spec.ts | 42 ----------------- pnpm-lock.yaml | 3 -- 10 files changed, 39 insertions(+), 111 deletions(-) create mode 100644 .changeset/bitcoin-known-chains-only.md delete mode 100644 .changeset/zcash-destination-address.md delete mode 100644 packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts delete mode 100644 packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts diff --git a/.changeset/bitcoin-known-chains-only.md b/.changeset/bitcoin-known-chains-only.md new file mode 100644 index 000000000..b32bce9d5 --- /dev/null +++ b/.changeset/bitcoin-known-chains-only.md @@ -0,0 +1,5 @@ +--- +'@lifi/sdk-provider-bitcoin': minor +--- + +`BitcoinProvider.isAddress(address, chainId)` refuses every UTXO chain other than BTC, so a Bitcoin address never passes as the receiver of a chain whose format the provider does not know; ZEC receivers are validated by `@lifi/sdk-provider-zcash`. `getBitcoinBalance` leaves the amount of a non-BTC token unknown instead of reporting the Bitcoin balance for it. `isBitcoinProvider` no longer matches another UTXO provider, such as `ZcashProvider`. diff --git a/.changeset/zcash-destination-address.md b/.changeset/zcash-destination-address.md deleted file mode 100644 index 7bb48cbac..000000000 --- a/.changeset/zcash-destination-address.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@lifi/sdk-provider-bitcoin': minor ---- - -`BitcoinProvider.isAddress(address, ChainId.ZEC)` validates a Zcash receiver with its checksum. Transparent `t1` and `t3` addresses are accepted; TEX (`tex1`), Sapling (`zs1`) and unified (`u1`) addresses are refused until the API supports them. Any UTXO chain other than BTC and ZEC is refused, so a Bitcoin address no longer passes as the receiver of a chain whose format the provider does not know. `getBitcoinBalance` now leaves the amount of a non-BTC token unknown instead of reporting the Bitcoin balance for it. diff --git a/packages/sdk-provider-bitcoin/package.json b/packages/sdk-provider-bitcoin/package.json index 9f446f6db..59ee451dc 100644 --- a/packages/sdk-provider-bitcoin/package.json +++ b/packages/sdk-provider-bitcoin/package.json @@ -47,7 +47,6 @@ "@bigmi/core": "^0.9.2", "@bitcoinerlab/secp256k1": "^2.0.0", "@lifi/sdk": "workspace:*", - "@scure/base": "^1.2.6", "bech32": "^2.0.0", "bitcoinjs-lib": "^7.0.2" }, diff --git a/packages/sdk-provider-bitcoin/src/BitcoinProvider.ts b/packages/sdk-provider-bitcoin/src/BitcoinProvider.ts index 5630c40b6..a60f5ca6b 100644 --- a/packages/sdk-provider-bitcoin/src/BitcoinProvider.ts +++ b/packages/sdk-provider-bitcoin/src/BitcoinProvider.ts @@ -4,22 +4,11 @@ import { getBitcoinBalance } from './actions/getBitcoinBalance.js' import { resolveBitcoinAddress } from './actions/resolveBitcoinAddress.js' import { BitcoinStepExecutor } from './core/BitcoinStepExecutor.js' import type { BitcoinProviderOptions, BitcoinSDKProvider } from './types.js' -import { isZcashAddress } from './utils/zcashAddress.js' -// Each UTXO chain has its own address format. A chain this provider does not -// know is refused, so a Bitcoin address never passes as its receiver. The chain -// ID must not reach bigmi, which reads a second argument as a network. -const isAddress = (address: string, chainId?: ChainId): boolean => { - switch (chainId) { - case undefined: - case ChainId.BTC: - return isUTXOAddress(address) - case ChainId.ZEC: - return isZcashAddress(address) - default: - return false - } -} +// Each UTXO chain has its own address format, so any chain but BTC is refused. +// The chain ID must not reach bigmi, which reads a second argument as a network. +const isAddress = (address: string, chainId?: ChainId): boolean => + (chainId === undefined || chainId === ChainId.BTC) && isUTXOAddress(address) export function BitcoinProvider( options?: BitcoinProviderOptions diff --git a/packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts b/packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts index 6cf00326b..71d8cd911 100644 --- a/packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts +++ b/packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts @@ -1,6 +1,7 @@ -import { ChainId, ChainType } from '@lifi/sdk' +import { ChainId, ChainType, type SDKProvider } from '@lifi/sdk' import { describe, expect, it, vi } from 'vitest' import { BitcoinProvider } from './BitcoinProvider.js' +import { isBitcoinProvider } from './types.js' describe('BitcoinProvider', () => { it('should create provider with default options', () => { @@ -58,9 +59,6 @@ describe('BitcoinProvider', () => { const bitcoinSegwit = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' const bitcoinLegacy = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2' const zcashP2pkh = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC' - const zcashP2sh = 't3LmX1cxWPPPqL4TZHx42HU3U5ghbFjRiif' - const zcashUnified = - 'u1l8xunezsvhq8fgzfl7404m450nwnd76zshscn6nfys7vyz2ywyh4cc5daaq0c7q2su5lqfh23sp7fkf3kt27ve5948mzpfdvckzaect2jtte308mkwlycj2u0eac077wu70vqcetkxf' it('keeps today’s answer without a chain ID and for Bitcoin', () => { for (const address of [ @@ -77,23 +75,34 @@ describe('BitcoinProvider', () => { expect(provider.isAddress(zcashP2pkh)).toBe(false) }) - it('accepts only transparent Zcash addresses for ZEC', () => { - expect(provider.isAddress(zcashP2pkh, ChainId.ZEC)).toBe(true) - expect(provider.isAddress(zcashP2sh, ChainId.ZEC)).toBe(true) - expect(provider.isAddress(zcashUnified, ChainId.ZEC)).toBe(false) - }) - - it('refuses a Bitcoin address for ZEC and a Zcash address for BTC', () => { - expect(provider.isAddress(bitcoinSegwit, ChainId.ZEC)).toBe(false) - expect(provider.isAddress(bitcoinLegacy, ChainId.ZEC)).toBe(false) - expect(provider.isAddress(zcashP2pkh, ChainId.BTC)).toBe(false) - }) - - it('refuses every address for a UTXO chain it does not know', () => { - for (const chainId of [ChainId.LTC, ChainId.BCH, ChainId.DGE]) { + it('refuses every address for a UTXO chain other than BTC', () => { + for (const chainId of [ + ChainId.ZEC, + ChainId.LTC, + ChainId.BCH, + ChainId.DGE, + ]) { expect(provider.isAddress(bitcoinSegwit, chainId)).toBe(false) expect(provider.isAddress(zcashP2pkh, chainId)).toBe(false) } }) }) + + describe('isBitcoinProvider', () => { + it('matches the Bitcoin provider only', () => { + const otherUtxoProvider: SDKProvider = { + type: ChainType.UTXO, + chainIds: [ChainId.ZEC], + isAddress: () => false, + resolveAddress: async () => undefined, + getBalance: async () => [], + getStepExecutor: async () => { + throw new Error('Not used.') + }, + } + + expect(isBitcoinProvider(BitcoinProvider())).toBe(true) + expect(isBitcoinProvider(otherUtxoProvider)).toBe(false) + }) + }) }) diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts index 4e8c0344e..0a153775b 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts @@ -20,8 +20,7 @@ export const getBitcoinBalance = async ( console.warn('Requested tokens have to be on the same chain.') } } - // The client reads Bitcoin only. Another UTXO chain, such as ZEC, shares this - // provider but not its balances, so its amount stays unknown, not zero. + // The client reads Bitcoin only, so another chain's amount stays unknown, not zero. const isBitcoinToken = (token: Token): boolean => token.chainId === ChainId.BTC if (!tokens.some(isBitcoinToken)) { diff --git a/packages/sdk-provider-bitcoin/src/types.ts b/packages/sdk-provider-bitcoin/src/types.ts index d3c349b31..a21ba4c55 100644 --- a/packages/sdk-provider-bitcoin/src/types.ts +++ b/packages/sdk-provider-bitcoin/src/types.ts @@ -24,5 +24,6 @@ export interface BitcoinSDKProvider extends SDKProvider { export function isBitcoinProvider( provider: SDKProvider ): provider is BitcoinSDKProvider { - return provider.type === ChainType.UTXO + // Other UTXO providers, such as Zcash, share the type but take no options. + return provider.type === ChainType.UTXO && 'setOptions' in provider } diff --git a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts deleted file mode 100644 index a9bcc138e..000000000 --- a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createBase58check } from '@scure/base' -import { crypto } from 'bitcoinjs-lib' - -const base58check = createBase58check(crypto.sha256) - -/** - * Whether `address` is a mainnet transparent Zcash address (`t1` P2PKH or `t3` - * P2SH) with a valid checksum. The API handles transparent receivers only, so - * TEX, Sapling and unified addresses, and testnet forms, return `false`. - */ -export const isZcashAddress = (address: string): boolean => { - let payload: Uint8Array - try { - payload = base58check.decode(address) - } catch { - return false - } - // A 2-byte version prefix (0x1CB8 or 0x1CBD) and a 20-byte hash. - return ( - payload.length === 22 && - payload[0] === 0x1c && - (payload[1] === 0xb8 || payload[1] === 0xbd) - ) -} diff --git a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts b/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts deleted file mode 100644 index 3f88ec269..000000000 --- a/packages/sdk-provider-bitcoin/src/utils/zcashAddress.unit.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { isZcashAddress } from './zcashAddress.js' - -// Sources: ZIP 320 (t1 and its TEX form), ZIP 214 (t3), zcash-test-vectors -// unified_address.json (u1, and the Sapling raw address of row 0 encoded as zs1). -const t1 = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC' -const t3 = 't3LmX1cxWPPPqL4TZHx42HU3U5ghbFjRiif' -const tex = 'tex1s2rt77ggv6q989lr49rkgzmh5slsksa9khdgte' -const sapling = - 'zs1mrhc9y7jdh5r9ece8u5khgvj9kg0zgkxzdduyv0whkg7lkcrkx5xqem3e48avjq9wn2rukydkwn' -const unified = - 'u1l8xunezsvhq8fgzfl7404m450nwnd76zshscn6nfys7vyz2ywyh4cc5daaq0c7q2su5lqfh23sp7fkf3kt27ve5948mzpfdvckzaect2jtte308mkwlycj2u0eac077wu70vqcetkxf' -// The t1 above with its last character changed: the checksum no longer matches. -const t1WithBadChecksum = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yD' -// Testnet forms of the same hash: version 0x1D25 and HRP `textest`. -const testnetTransparent = 'tmMcWbZU8t39htCR1fQRfRSHtkW4p3Ax5Se' -const testnetTex = 'textest1s2rt77ggv6q989lr49rkgzmh5slsksa90ej7wz' - -describe('isZcashAddress', () => { - it.each([ - ['p2pkh', t1], - ['p2sh', t3], - ])('accepts a %s address', (_, address) => { - expect(isZcashAddress(address)).toBe(true) - }) - - it.each([ - ['a TEX address', tex], - ['a Sapling address', sapling], - ['a unified address', unified], - ['a corrupted checksum', t1WithBadChecksum], - ['a testnet transparent address', testnetTransparent], - ['a testnet TEX address', testnetTex], - ['a Bitcoin segwit address', 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'], - ['a Bitcoin legacy address', '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'], - ['an EVM address', '0xB095274743941e953c746F9C228DA9c18Bb6ec29'], - ['an empty string', ''], - ['free text', 'not-an-address'], - ])('refuses %s', (_, address) => { - expect(isZcashAddress(address)).toBe(false) - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b7ecbf9c..61680ab99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -252,9 +252,6 @@ importers: '@lifi/sdk': specifier: workspace:* version: link:../sdk - '@scure/base': - specifier: ^1.2.6 - version: 1.2.6 bech32: specifier: ^2.0.0 version: 2.0.0 From 1d3c2efde5ba91c18328e77ed4733e70d22625ef Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 30 Sep 2026 14:41:57 +0200 Subject: [PATCH 18/24] fix(sdk): scope a provider by its sorted chain list, and a generic one by its type --- packages/sdk/src/client/createClient.ts | 7 +++-- .../sdk/src/client/createClient.unit.spec.ts | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/client/createClient.ts b/packages/sdk/src/client/createClient.ts index 9036d404f..1727c313a 100644 --- a/packages/sdk/src/client/createClient.ts +++ b/packages/sdk/src/client/createClient.ts @@ -11,9 +11,12 @@ import { name, version } from '../version.js' import { findProvider } from './findProvider.js' import { getClientStorage } from './getClientStorage.js' -// A provider replaces only one that serves the same chains. +// A provider replaces only one that serves the same chains. A provider that +// lists no chains keeps the bare type, so even an empty list stays apart. const providerScope = (provider: SDKProvider): string => - `${provider.type}:${provider.chainIds?.join(',') ?? ''}` + provider.chainIds + ? `${provider.type}:${[...provider.chainIds].sort().join(',')}` + : provider.type export function createClient(options: SDKConfig): SDKClient { if (!options.integrator) { diff --git a/packages/sdk/src/client/createClient.unit.spec.ts b/packages/sdk/src/client/createClient.unit.spec.ts index 80dd2c32c..0b8bd1096 100644 --- a/packages/sdk/src/client/createClient.unit.spec.ts +++ b/packages/sdk/src/client/createClient.unit.spec.ts @@ -229,6 +229,36 @@ describe('createClient', () => { expect(client.providers[1]).toBe(zcash) expect(client.getProvider(ChainType.UTXO, ChainId.ZEC)).toBe(zcash) }) + + it('keeps the generic provider beside one that lists no chains', () => { + const bitcoin = UTXO() + const empty = { ...UTXO(), chainIds: [] } + const client = createClient({ + integrator: 'test-app', + providers: [bitcoin], + }) + + client.setProviders([empty]) + + expect(client.providers).toHaveLength(2) + expect(client.providers[0]).toBe(bitcoin) + expect(client.providers[1]).toBe(empty) + expect(client.getProvider(ChainType.UTXO)).toBe(bitcoin) + }) + + it('replaces a provider that lists the same chains in another order', () => { + const first = { ...UTXO(), chainIds: [ChainId.ZEC, ChainId.LTC] } + const second = { ...UTXO(), chainIds: [ChainId.LTC, ChainId.ZEC] } + const client = createClient({ + integrator: 'test-app', + providers: [first], + }) + + client.setProviders([second]) + + expect(client.providers).toHaveLength(1) + expect(client.providers[0]).toBe(second) + }) }) describe('chain management', () => { From 1d2c75de2247905c5e7771ec3b1c7913bb0b8fa6 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 30 Sep 2026 14:42:03 +0200 Subject: [PATCH 19/24] fix(sdk-provider-bitcoin): set no BTC amount when the balance request fails --- .../src/actions/getBitcoinBalance.ts | 8 ++++---- .../src/actions/getBitcoinBalance.unit.spec.ts | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts index 0a153775b..5b23b9fed 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts @@ -34,11 +34,11 @@ export const getBitcoinBalance = async ( const blockNumber = blockCount.status === 'fulfilled' ? BigInt(blockCount.value) : 0n - // RPC failed — leave amount undefined so callers can distinguish - // an unknown balance from a known zero. - const amount = balance.status === 'fulfilled' ? balance.value : undefined + // A failed request sets no amount, so callers can tell an unknown balance + // from a known zero. + const amount = balance.status === 'fulfilled' ? { amount: balance.value } : {} return tokens.map((token) => - isBitcoinToken(token) ? { ...token, amount, blockNumber } : { ...token } + isBitcoinToken(token) ? { ...token, ...amount, blockNumber } : { ...token } ) } diff --git a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts index b42d6f6fe..976323c8a 100644 --- a/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts +++ b/packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.unit.spec.ts @@ -63,4 +63,18 @@ describe('getBitcoinBalance', () => { } warn.mockRestore() }) + + it('adds no amount for BTC when the balance request fails', async () => { + mocks.getBitcoinPublicClient.mockResolvedValue({ + getBalance: vi.fn().mockRejectedValue(new Error('RPC down')), + getBlockCount: vi.fn().mockResolvedValue(900_000), + }) + + const [balance] = await getBitcoinBalance(client, walletAddress, [ + nativeToken(ChainId.BTC, 'BTC'), + ]) + + expect(balance).not.toHaveProperty('amount') + expect(balance.blockNumber).toBe(900_000n) + }) }) From 3718594a3d2caa9bd5a7d1b7880a888556b13cc4 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 30 Sep 2026 14:44:07 +0200 Subject: [PATCH 20/24] docs: count the Zcash provider package --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index c2abf1c55..3425c3add 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # CLAUDE.md ## Project -TypeScript monorepo (pnpm workspaces) with 7 packages under `packages/`. `@lifi/sdk` +TypeScript monorepo (pnpm workspaces) with 8 packages under `packages/`. `@lifi/sdk` is the hub; each provider depends on it via `workspace:*` as a **regular** dependency (not peer), which resolves to the exact pinned version in published tarballs. From 79626ca2d7a98ad3a9763183bf3ab4e756b3df87 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 30 Sep 2026 15:00:55 +0200 Subject: [PATCH 21/24] docs(changeset): tell custom provider authors to wrap a two-parameter isAddress --- .changeset/chain-aware-is-address.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/chain-aware-is-address.md b/.changeset/chain-aware-is-address.md index 16e0ea406..6f2846dac 100644 --- a/.changeset/chain-aware-is-address.md +++ b/.changeset/chain-aware-is-address.md @@ -2,6 +2,6 @@ '@lifi/sdk': minor --- -`SDKProvider.isAddress` accepts an optional `chainId`. Without it, a provider answers as before. With it, a provider whose chains use different address formats accepts only that chain's format and refuses a chain it does not know. A provider must never forward the chain ID to a library function whose second parameter means something else. +`SDKProvider.isAddress` accepts an optional `chainId`. Without it, a provider answers as before. With it, a provider whose chains use different address formats accepts only that chain's format and refuses a chain it does not know. A provider must never forward the chain ID to a library function whose second parameter means something else. A custom provider that assigns such a function directly as `isAddress`, for example viem's `isAddress` or bigmi's `isUTXOAddress`, no longer type-checks; wrap it in a one-argument function, as `EthereumProvider` and `BitcoinProvider` do. A provider can declare the `chainIds` it serves, so a chain-specific provider can sit beside the generic provider of the same chain type. `client.getProvider(type, chainId?)` returns the provider that lists the chain, otherwise the provider of that type that lists no chains; `getProvider(type)` answers as before. The new `findProvider(providers, chainType, chainId?)` applies the same rule to any provider list. `setProviders` replaces only a provider with the same type and the same `chainIds`. From 4f8a495207d21e1e2c601c6f8c4fa2ad54d2e0ec Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 30 Sep 2026 15:17:26 +0200 Subject: [PATCH 22/24] docs: register the Zcash provider after the Bitcoin provider --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 184752fc2..e9465ac2c 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ xBull, or Lobstr, we recommend pnpm add @lifi/sdk-provider-zcash ``` -ZEC is a destination-only chain: `ZcashProvider` validates transparent `t1` and `t3` receivers and needs no wallet. Register it beside `BitcoinProvider`; it serves ZEC only. +ZEC is a destination-only chain: `ZcashProvider` validates transparent `t1` and `t3` receivers and needs no wallet. Register it after `BitcoinProvider`; it serves ZEC only. ## Architecture From 1b774eec1b21c5b8bfab316a288406ce18179df7 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Mon, 5 Oct 2026 13:56:54 +0200 Subject: [PATCH 23/24] chore(sdk-provider-zcash): align the test tooling with main --- packages/sdk-provider-zcash/package.json | 4 ++-- pnpm-lock.yaml | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/sdk-provider-zcash/package.json b/packages/sdk-provider-zcash/package.json index 58a0cf5db..d08b56e8f 100644 --- a/packages/sdk-provider-zcash/package.json +++ b/packages/sdk-provider-zcash/package.json @@ -49,11 +49,11 @@ "@scure/base": "^1.2.6" }, "devDependencies": { - "@vitest/coverage-v8": "^5.0.1", + "@vitest/coverage-v8": "^5.0.3", "cpy-cli": "^7.0.0", "madge": "^8.0.0", "typescript": "^7.0.2", - "vitest": "^5.0.1" + "vitest": "^5.0.3" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61680ab99..d404c9983 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -443,8 +443,8 @@ importers: version: 1.2.6 devDependencies: '@vitest/coverage-v8': - specifier: ^5.0.1 - version: 5.0.1(vitest@5.0.1) + specifier: ^5.0.3 + version: 5.0.3(vitest@5.0.3) cpy-cli: specifier: ^7.0.0 version: 7.0.0 @@ -455,8 +455,8 @@ importers: specifier: ^7.0.2 version: 7.0.2 vitest: - specifier: ^5.0.1 - version: 5.0.1(@types/node@26.6.2)(@vitest/coverage-v8@5.0.1)(msw@2.15.0(@types/node@26.6.2)(typescript@7.0.2))(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.1)) + specifier: ^5.0.3 + version: 5.0.3(@types/node@26.6.3)(@vitest/coverage-v8@5.0.3)(msw@3.0.1(@types/node@26.6.3)(graphql@17.0.2)(supports-color@7.2.0)(typescript@7.0.2)(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)(yaml@2.9.1)))(vite@8.3.1(@types/node@26.6.3)(jiti@2.7.0)(yaml@2.9.1)) packages: From b7870f68c0a5d0cedaae6eb01cbd7efda81a16a9 Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Tue, 6 Oct 2026 15:12:44 +0200 Subject: [PATCH 24/24] feat(sdk-provider-zcash): accept unified addresses with an Orchard receiver --- .changeset/zcash-provider.md | 2 +- README.md | 2 +- .../src/ZcashProvider.unit.spec.ts | 7 +- .../src/utils/unifiedAddress.ts | 125 +++++++ .../src/utils/unifiedAddress.unit.spec.ts | 63 ++++ .../src/utils/unifiedAddress.vectors.mock.ts | 333 ++++++++++++++++++ .../src/utils/zcashAddress.ts | 17 +- .../src/utils/zcashAddress.unit.spec.ts | 16 +- 8 files changed, 549 insertions(+), 16 deletions(-) create mode 100644 packages/sdk-provider-zcash/src/utils/unifiedAddress.ts create mode 100644 packages/sdk-provider-zcash/src/utils/unifiedAddress.unit.spec.ts create mode 100644 packages/sdk-provider-zcash/src/utils/unifiedAddress.vectors.mock.ts diff --git a/.changeset/zcash-provider.md b/.changeset/zcash-provider.md index c10c60463..c751515e7 100644 --- a/.changeset/zcash-provider.md +++ b/.changeset/zcash-provider.md @@ -2,4 +2,4 @@ '@lifi/sdk-provider-zcash': minor --- -New package. `ZcashProvider()` validates receivers on ZEC, a destination-only chain: transparent `t1` and `t3` addresses with a valid checksum. It serves `ChainId.ZEC` beside `BitcoinProvider`, leaves balances unknown and cannot execute a step. +New package. `ZcashProvider()` validates receivers on ZEC, a destination-only chain: transparent `t1` and `t3` addresses with a valid checksum, and unified `u1` addresses that decode under ZIP 316 and carry an Orchard receiver, the ones the API pays to a shielded balance. Sapling, TEX and testnet addresses, and a unified address without an Orchard receiver, are refused. It serves `ChainId.ZEC` beside `BitcoinProvider`, leaves balances unknown and cannot execute a step. diff --git a/README.md b/README.md index e9465ac2c..da2b40abb 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ xBull, or Lobstr, we recommend pnpm add @lifi/sdk-provider-zcash ``` -ZEC is a destination-only chain: `ZcashProvider` validates transparent `t1` and `t3` receivers and needs no wallet. Register it after `BitcoinProvider`; it serves ZEC only. +ZEC is a destination-only chain: `ZcashProvider` validates transparent `t1` and `t3` receivers, and unified `u1` receivers that carry an Orchard receiver, and needs no wallet. Register it after `BitcoinProvider`; it serves ZEC only. ## Architecture diff --git a/packages/sdk-provider-zcash/src/ZcashProvider.unit.spec.ts b/packages/sdk-provider-zcash/src/ZcashProvider.unit.spec.ts index d97862c8f..b33294160 100644 --- a/packages/sdk-provider-zcash/src/ZcashProvider.unit.spec.ts +++ b/packages/sdk-provider-zcash/src/ZcashProvider.unit.spec.ts @@ -4,6 +4,8 @@ import { ZcashProvider } from './ZcashProvider.js' const t1 = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC' const t3 = 't3LmX1cxWPPPqL4TZHx42HU3U5ghbFjRiif' +const unified = + 'u1k9eh52jx5q4y8lw6x208lsep4t6yzwk7mdwz9e6239qywjkqzdcd3al3d64zwnqx296p3klxnash5w2e0elg39qrydxx0s0qz5m2gnt0' const bitcoin = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq' const client = {} as SDKClient @@ -15,8 +17,8 @@ describe('ZcashProvider', () => { expect(provider.chainIds).toEqual([ChainId.ZEC]) }) - it('accepts a transparent Zcash address without a chain and for ZEC', () => { - for (const address of [t1, t3]) { + it('accepts a transparent or Orchard unified address without a chain and for ZEC', () => { + for (const address of [t1, t3, unified]) { expect(provider.isAddress(address)).toBe(true) expect(provider.isAddress(address, ChainId.ZEC)).toBe(true) } @@ -25,6 +27,7 @@ describe('ZcashProvider', () => { it('refuses a Zcash address for any other chain', () => { expect(provider.isAddress(t1, ChainId.BTC)).toBe(false) expect(provider.isAddress(t1, ChainId.ETH)).toBe(false) + expect(provider.isAddress(unified, ChainId.BTC)).toBe(false) }) it('refuses a Bitcoin address', () => { diff --git a/packages/sdk-provider-zcash/src/utils/unifiedAddress.ts b/packages/sdk-provider-zcash/src/utils/unifiedAddress.ts new file mode 100644 index 000000000..384da68c0 --- /dev/null +++ b/packages/sdk-provider-zcash/src/utils/unifiedAddress.ts @@ -0,0 +1,125 @@ +import { blake2b } from '@noble/hashes/blake2b' +import { bech32m } from '@scure/base' + +const ORCHARD = 0x03 +/** Byte length of each receiver typecode the API pays to (ZIP 316). */ +const RECEIVER_LENGTHS: ReadonlyMap = new Map([ + [0x00, 20], // P2PKH + [0x01, 20], // P2SH + [0x02, 43], // Sapling + [ORCHARD, 43], +]) +// Bech32 caps decoding at 90 characters; a unified address runs to hundreds. +const BECH32M_LIMIT = 4096 +const MIN_JUMBLED_LENGTH = 48 +// The decoded payload ends with the HRP `u`, zero-padded to 16 bytes. +const PADDING = Uint8Array.from({ length: 16 }, (_, k) => (k === 0 ? 0x75 : 0)) +const BLAKE2B_MAX_LENGTH = 64 + +const textEncoder = new TextEncoder() + +/** 16-byte BLAKE2b personalization: `UA_F4Jumble_`, then `i` and `j` as LE u16. */ +const personalization = (tag: 'H' | 'G', i: number, j = 0): Uint8Array => { + const bytes = new Uint8Array(16) + bytes.set(textEncoder.encode(`UA_F4Jumble_${tag}`)) + bytes[13] = i + bytes[14] = j & 0xff + bytes[15] = j >> 8 + return bytes +} + +const hashH = (i: number, input: Uint8Array, length: number): Uint8Array => + blake2b(input, { dkLen: length, personalization: personalization('H', i) }) + +const hashG = (i: number, input: Uint8Array, length: number): Uint8Array => { + const output = new Uint8Array(length) + for (let j = 0; j * BLAKE2B_MAX_LENGTH < length; j++) { + const block = blake2b(input, { + dkLen: BLAKE2B_MAX_LENGTH, + personalization: personalization('G', i, j), + }) + const offset = j * BLAKE2B_MAX_LENGTH + output.set(block.subarray(0, length - offset), offset) + } + return output +} + +const xor = (a: Uint8Array, b: Uint8Array): Uint8Array => + a.map((byte, k) => byte ^ b[k]) + +/** The inverse of F4Jumble, the Feistel jumble ZIP 316 applies before Bech32m. */ +export const f4JumbleInverse = (jumbled: Uint8Array): Uint8Array => { + const leftLength = Math.min( + BLAKE2B_MAX_LENGTH, + Math.floor(jumbled.length / 2) + ) + const rightLength = jumbled.length - leftLength + const c = jumbled.subarray(0, leftLength) + const d = jumbled.subarray(leftLength) + const y = xor(c, hashH(1, d, leftLength)) + const x = xor(d, hashG(1, y, rightLength)) + const a = xor(y, hashH(0, x, leftLength)) + const b = xor(x, hashG(0, a, rightLength)) + const raw = new Uint8Array(jumbled.length) + raw.set(a) + raw.set(b, leftLength) + return raw +} + +/** + * The typecodes of a decoded receiver list, or `undefined` when a receiver is + * not one the API pays to, has another length, breaks the ascending order or + * overruns. Every accepted typecode and length fits one CompactSize byte, so a + * wider CompactSize can only encode a typecode the API refuses. + */ +const receiverTypecodes = (body: Uint8Array): number[] | undefined => { + const typecodes: number[] = [] + for (let offset = 0; offset < body.length; ) { + const typecode = body[offset] + const length = RECEIVER_LENGTHS.get(typecode) + const end = offset + 2 + (length ?? 0) + if ( + length === undefined || + body[offset + 1] !== length || + end > body.length || + typecode <= (typecodes.at(-1) ?? -1) + ) { + return undefined + } + typecodes.push(typecode) + offset = end + } + return typecodes +} + +/** + * Whether `address` is a mainnet unified address (`u1`) that the API pays to a + * shielded balance. It must decode — Bech32m checksum, F4Jumble, padding — + * into P2PKH, P2SH, Sapling and Orchard receivers only, each at its length, in + * ascending typecode order, with at most one transparent receiver, and one of + * them must be Orchard: without it a bridge pays the transparent receiver. + */ +export const isOrchardUnifiedAddress = (address: string): boolean => { + // The API takes the lowercase form only. + if (!address.startsWith('u1')) { + return false + } + const decoded = bech32m.decodeUnsafe(address, BECH32M_LIMIT) + if (decoded?.prefix !== 'u') { + return false + } + const jumbled = bech32m.fromWordsUnsafe(decoded.words) + if (!jumbled || jumbled.length < MIN_JUMBLED_LENGTH) { + return false + } + const raw = f4JumbleInverse(jumbled) + const paddingStart = raw.length - PADDING.length + if (PADDING.some((byte, k) => raw[paddingStart + k] !== byte)) { + return false + } + const typecodes = receiverTypecodes(raw.subarray(0, paddingStart)) ?? [] + return ( + typecodes.includes(ORCHARD) && + !(typecodes.includes(0x00) && typecodes.includes(0x01)) + ) +} diff --git a/packages/sdk-provider-zcash/src/utils/unifiedAddress.unit.spec.ts b/packages/sdk-provider-zcash/src/utils/unifiedAddress.unit.spec.ts new file mode 100644 index 000000000..55ceb5d2b --- /dev/null +++ b/packages/sdk-provider-zcash/src/utils/unifiedAddress.unit.spec.ts @@ -0,0 +1,63 @@ +import { bech32m } from '@scure/base' +import { describe, expect, it } from 'vitest' +import { f4JumbleInverse, isOrchardUnifiedAddress } from './unifiedAddress.js' +import { + f4JumbleVectors, + unifiedAddressVectors, +} from './unifiedAddress.vectors.mock.js' + +const hexToBytes = (hex: string): Uint8Array => + Uint8Array.from(hex.match(/../g) ?? [], (byte) => Number.parseInt(byte, 16)) + +// A Zashi address with one receiver, Orchard. +const orchardOnly = + 'u1k9eh52jx5q4y8lw6x208lsep4t6yzwk7mdwz9e6239qywjkqzdcd3al3d64zwnqx296p3klxnash5w2e0elg39qrydxx0s0qz5m2gnt0' + +describe('f4JumbleInverse', () => { + it.each(f4JumbleVectors)('restores %s', (raw, jumbled) => { + expect(f4JumbleInverse(hexToBytes(jumbled))).toEqual(hexToBytes(raw)) + }) +}) + +describe('isOrchardUnifiedAddress', () => { + it('accepts a unified address with an Orchard receiver only', () => { + expect(isOrchardUnifiedAddress(orchardOnly)).toBe(true) + }) + + it.each(unifiedAddressVectors)( + 'answers $payable for $address', + ({ address, payable }) => { + expect(isOrchardUnifiedAddress(address)).toBe(payable) + } + ) + + it('refuses a corrupted checksum', () => { + // The last character of the address is `0`; `q` keeps the Bech32 alphabet. + expect(isOrchardUnifiedAddress(`${orchardOnly.slice(0, -1)}q`)).toBe(false) + }) + + it('refuses the uppercase form, which the API does not take', () => { + expect(isOrchardUnifiedAddress(orchardOnly.toUpperCase())).toBe(false) + }) + + it('refuses a testnet unified address', () => { + const { words } = bech32m.decode(orchardOnly, 4096) + expect(isOrchardUnifiedAddress(bech32m.encode('utest', words, 4096))).toBe( + false + ) + }) + + it.each([ + ['a transparent address', 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC'], + [ + 'a Sapling address', + 'zs1mrhc9y7jdh5r9ece8u5khgvj9kg0zgkxzdduyv0whkg7lkcrkx5xqem3e48avjq9wn2rukydkwn', + ], + ['a TEX address', 'tex1s2rt77ggv6q989lr49rkgzmh5slsksa9khdgte'], + ['a truncated address', orchardOnly.slice(0, 40)], + ['a name', 'u1.eth'], + ['an empty string', ''], + ])('refuses %s', (_, address) => { + expect(isOrchardUnifiedAddress(address)).toBe(false) + }) +}) diff --git a/packages/sdk-provider-zcash/src/utils/unifiedAddress.vectors.mock.ts b/packages/sdk-provider-zcash/src/utils/unifiedAddress.vectors.mock.ts new file mode 100644 index 000000000..156639ccb --- /dev/null +++ b/packages/sdk-provider-zcash/src/utils/unifiedAddress.vectors.mock.ts @@ -0,0 +1,333 @@ +// From zcash/zcash-test-vectors: test-vectors/zcash/unified_address.json and f4jumble.json. + +/** Each unified address and whether the API pays it: an Orchard receiver and no unknown typecode. */ +export const unifiedAddressVectors: { address: string; payable: boolean }[] = [ + { + address: + 'u1l8xunezsvhq8fgzfl7404m450nwnd76zshscn6nfys7vyz2ywyh4cc5daaq0c7q2su5lqfh23sp7fkf3kt27ve5948mzpfdvckzaect2jtte308mkwlycj2u0eac077wu70vqcetkxf', + payable: false, + }, + { + address: + 'u1fl5mprj0t9p4jg92hjjy8q5myvwc60c9wv0xachauqpn3c3k4xwzlaueafq27dcg7tzzzaz5jl8tyj93wgs983y0jq0qfhzu6n4r8rakpv5f4gg2lrw4z6pyqqcrcqx04d38yunc6je', + payable: false, + }, + { + address: + 'u1qxqf8ctkxlsdh7xdcgkdtyw4mku7dxma8tsz45xd6ttgs322gdk7kazg3sdn52z7na3tzcrzf7lt3xrdtfp9d4pccderalchvvxk8hghduxrky5guzqlw65fmgp6x7aj4k8v5jkgwuw', + payable: false, + }, + { + address: + 'u1pg2aaph7jp8rpf6yhsza25722sg5fcn3vaca6ze27hqjw7jvvhhuxkpcg0ge9xh6drsgdkda8qjq5chpehkcpxf87rnjryjqwymdheptpvnljqqrjqzjwkc2ma6hcq666kgwfytxwac8eyex6ndgr6ezte66706e3vaqrd25dzvzkc69kw0jgywtd0cmq52q5lkw6uh7hyvzjse8ksx', + payable: true, + }, + { + address: + 'u19mzuf4l37ny393m59v4mxx4t3uyxkh7qpqjdfvlfk9f504cv9w4fpl7cql0kqvssz8jay8mgl8lnrtvg6yzh9pranjj963acc3h2z2qt7007du0lsmdf862dyy40c3wmt0kq35k5z836tfljgzsqtdsccchayfjpygqzkx24l77ga3ngfgskqddyepz8we7ny4ggmt7q48cgvgu57mz', + payable: true, + }, + { + address: + 'u1mtxw5nras5glkxz093282sv3n2h8qs7cpxcmmaxj96vtzjzl6rmdaxs4e9es7mxwmd0h3k5wz3ce4ll5g4jz2pn9su4pufq74pxhp4t235n6j7aed3hh8ss7pf3sekf7apsf6vtg84ue5zcq2k9q3xv5yth3q50fu4czdm8sn8q4de3m5k76g2vwwyjsf50hqfxgmwxqxu0rsy22ktw', + payable: true, + }, + { + address: + 'u1ay3aawlldjrmxqnjf5medr5ma6p3acnet464ht8lmwplq5cd3ugytcmlf96rrmtgwldc75x94qn4n8pgen36y8tywlq6yjk7lkf3fa8wzjrav8z2xpxqnrnmjxh8tmz6jhfh425t7f3vy6p4pd3zmqayq49efl2c4xydc0gszg660q9p', + payable: true, + }, + { + address: + 'u19a4vmx7ysmtavmnaz4d2dgl9pyshexw35rl5ezg5dkkxktg08p42lng7kf9hqtn2fhr63qzyhe8gtnvgtfl9yvne46x6zfzwgedx7c0chnrxty0k5r5qqph8k02zs8e3keul9vj8myju7rvqgjaysa9kt0fucxpzuky6kf0pjgy0a6hx', + payable: true, + }, + { + address: + 'u13p2teem3xlvy4kwlke24hng5el2z6mn4ftj8xarwn8fy7dqt0flgcfpaxe6sk5cwawwh4tynzu7z2uschaf8tfa3tp2xgt8g4kx5lahhglcjm26jnvw7am6ld33708g0kv35pq83eg6gj82a0aau80enrhywpgr4v4m4vve7tg8vd4hz', + payable: true, + }, + { + address: + 'u1snf9yr883aj2hm8pksp9aymnqdwzy42rpzuffevj35hhxeckays5pcpeq7vy2mtgzlcuc4mnh9443qnuyje0yx6h59angywka4v2ap6kchh2j96ezf9w0c0auyz3wwts2lx5gmk2sk9', + payable: true, + }, + { + address: + 'u1szwcx2zdxalyp7cfqwrptv95rnpyajejs6jmwacz4cgm2g3vzdxl5perhpg3nyhnuplvptdr4g63gupdfj5zal9v35s3e6adqsckv68hyrclan3gxaj6mz8aejzsnhqjyn32jcpnpra', + payable: true, + }, + { + address: + 'u1glq6lzrxc7n7r4c922qht20zmpxyl0asfuldrjcaddagfspxpc3040fdfwdf5crw4j6j6wkx4r038s0w24w7enpyfmmdfu9t9p2amxazgvasms8l03l3j5yhrrfqy6xzue5uggef4p8', + payable: true, + }, + { + address: + 'u1tqhg04ppjt6vlf2uvkygt07sqzgpclxdpn7j7ydkcr0e8ym68wn592z7uqudktrwn4u3q57flp8hw3d0wd9t0rm0e6m8eys27evfawh6zhha6eulzj86uz89swu7gtk0vcknd3dauhc96twhx20xxsp93dxahqlt7z5p04ldgy2y2lp0', + payable: true, + }, + { + address: + 'u1zm98xj3ncc79sx8jxhcscptxav0p4wam8mlkf4lp69rhramz7v6fsndwxcd4qtmzkefwcwn5rgd8uztvdrvfqv32jk3xx6wlt7gae9fhs7xh48d3kn9fe92xtcff8hu0zgegmgr95qtxayjylfdct96eg2f2r06drf6sj800mcsns3n0', + payable: true, + }, + { + address: + 'u1hfgf2s4pghqteculnmq2rcnvyesml74zqfp5yfhxhwewx62q75qhgmwreg5qht7c5vu3fxefunjrarrfhmcuw2z4ndx0qx7u74gkw2n7v0ypvd4mxgzlenvs7lkurdj09zuhz6pmtuzs4m42sx92axuuru4dmgu46a920x5kuye6gxvs', + payable: true, + }, + { + address: + 'u17cfcut587e3kszg8vud0z5a8lj9gyypyvtt5xn4hfc4p3kv4e0jfr2pzzxhywlkhsjldtmkvupwr7mkjvruz8gnxk7a64x777p4l3u7vpm6zsdsx88ef90x5q5sqx57fq8vtj5vk3hx', + payable: true, + }, + { + address: + 'u1z6qgxh0wyw0ptgwwgsr5uv05n3xm3z8yrdr06k7q6fj9ypyjcj2hxwfmktv4a7ejaqphcgkddhsvrs93skzl3frm8e48at6huayg7k67e3c50ykpdnhva2jfh5dfcvy6nvttqwgz5a7', + payable: true, + }, + { + address: + 'u1g6jcyfwqd9yx8pdg4yvf0nsr5j7k5gmx83shh8v0v3w256umheen026x66f4608w2vydyasphgp80j9avq9h56dx73gg2559l5lj707v4458a0ucyhfxcjcccfx9z9upmcf3c6hg9k8', + payable: true, + }, + { + address: + 'u1en8ysypun4gdkdnu8zqqg6k73ankr9ffwfzg08wtzg9z939w0wupewemfrc8a630e8gc4uqucym0l4v44fszy3et4veyypt3jsyp0whfpfsn2lw30kj8nepe6wvvasf00wklh85u9v8glqndupmamk9z2ja9sanf70pp4yxvkt3dmyzxa0kkhv2c9pxmkghrxqk0590azvya3nzrtevj449nu3laskrhf7c7nj9cyw7ty38mccg4znrr876guu6pzndx7ngwzhmlsn8d89saf5araaacrhr9958xr6z23mj4qtzzn98whdpu8u7n8fhf5d2vypljda62q73du44sf0e0kxmq3gvgkta0qqgq9w6r403gc5jz2any02etmwlttkv84hgh95czhdf2jugk3u36ke0kchcthg240', + payable: false, + }, + { + address: + 'u1a7gz63aey4tnj4klwauth00vnkmltwafwzk9nld2ys7yz3yjzjcdp47crc37zc4g9aq4athg9zh8r792e44kd6g2f4drhsl5ph4ja8pe4gcc9yjyf3rn7pej808hcy6xh0x6y8khmzljehjlwqq4h2czp35vu3l7aa7rpw5vcng9gswwlaqn5ptes592wejx7f49rxsvmzeqjekjtyfevehanvyksa8gtkpk75yrqnam26hzuxrtm6agaluy4hv0ha4sg6h22394m0x5th6r8uj7svzlklaja852vv9ud5gznu2sqyrsqveqjmfk9rcs59sprjj8nrt2nke862xlhvjq9y9zswen27eqj5slg52q2zch59uzwaeat8jw6z6092uu8yqqnnj7h0yguhypgd8y2wu9ftgg38ym3', + payable: false, + }, + { + address: + 'u1ln90fvpdtyjapnsqpa2xjsarmhu3k2qvdr6uc6upurnuvzh382jzmfyw40yu8avd2lj7arvq57n0qmryy0flp7tm0fw05h366587mzzwwrls85da6l2sr7tuazmv5s02avxaxrl4j7pau0u9xyp470y9hkca5m9g4735208w6957p82lxajzq4l2pqkam86y6jfx8cd8ecw2e05qnh0qq95dr09sgz9hqmflzac7hsxj47yvjd69ej06ewdg97wsu2x9wg3ahfh6s4nvk65elwcu5wl092ta38028p4lc2d6l7ea63s6uh4ek0ry9lg50acxuw2sdv02jh90tzh783d59gneu8ue3wqefjmtndyquwq9kkxaedhtqh2yyjew93ua38vp8uchug0q7kg7qvp4l65t9yqaz2w2p', + payable: false, + }, + { + address: + 'u1sem2gcey0emntrvxyjv8hyhq0w5fr4sxaj3cppgrfqgg6laydh8m78gy2cw2p54zzak3alnnsx4xjuhazpkrfcd90wl0c7ldj6y095hh5j6j2evry9vg5jqp4dyqpwqeryu7pes4sxyyyqwn6egs5daxk4473v9xpgzrwv5n0tvs93nlj4xpphq4vs2w8um9ph7zkte08t7fa509mnrt9apuhr22xq34mp2svjnq6rvfn0hg6lkehxtlj39vgjxjlkjfhx8rw2f02ckq8k5szcxsnhkgr2cqlmf2udl2gqdqr5t6', + payable: false, + }, + { + address: + 'u10j2s9sy4dmuakf57z58jc5t8yuswega82jpd2hk3q62l6fsphwyjxvmvfwy8skvvvea6dnkl8l9zpjf3m27qsav9y9nlj59hagmjf5xh0xxyqr8lymnmtjn6gzgrn04dr5s0k9k9wuxc2udzjh4llv47zm6jn6ff0j65s54h3m6p0n9ajswrqzpvy8eh4d5pvypyc6rp5m07uwmjp4sr0upca5hl7gr4pxg45m7vlnx5r7va4n6mfyr98twvjrhcyalwhddelnnjrkhcj0wcp5eyas2c2kcadrxyzw28vvv47q74', + payable: false, + }, + { + address: + 'u1mtnedjgkz5ln6zzs7nrcyt8mertjundexqdxx52n2x4ww3v52s0akf3qy6sqlze3nexcjsxtcajglxcdwg47dsrrva6g5t4nf8u3sjchhkmsqghelysrn0cl52c2m8uuv3nyfdv258jjqnvd4lgqtugc8aqvpmt05c49qv2yqlhxvnq9phdamm4xv89cc7tzvzgmwltxxdsvme44dgzt8prkcwcsma8cdr76m8n0xwj02tpr9086a237xakkdf8fumsj8u4r6qlf0d59x0mw83ar36vrcr94zsherapa0566vd22', + payable: false, + }, + { + address: + 'u1n9znrl4zyuvds24rcapzglzapqdlax4r8rgkvek0y0xlzfjfvn7zexelrafkchea24w030cr9jqsel7t8lvveaq7m7w4z0khmrlzc6748w9ldlccy02scd5xngtcv2yy4ctnyu9zn5m', + payable: true, + }, + { + address: + 'u19f2knszheph2dt8lrnwqeeq9krnw39pgz8syqv028ghtg7kjz6xvu23suv5hmdmj7e6fjuu6060y34fdw8ccjlp8gsqp0usyhrgw3reqfveet7hh2pqcjafysqqv2l3felj7sl7a7ym', + payable: true, + }, + { + address: + 'u160suxvjkgt22zcp7f9xw5f0axdu7rxdt5ktyexpn4cq70w4at2f74390mns7uksfenrdmcjjzqalyfky6tq05jv8mnamrkyxn9dcxe35z4x6m35cczmjcj55g0fc6a2thz03sjfywxa', + payable: true, + }, + { + address: + 'u1ddnjsdcpm36r6aq79n3s68shjweksnmwtdltrh046s8m6xcws9ygyawalxx8n6hg6vegk0wh8zjnafxgh6msppjsljvyt0ynece3lvm0', + payable: true, + }, + { + address: + 'u1nztelxna9h7w0vtpd2xjhxt4lpu8s9cmdl8n8vcr7actf2ny45nd07cy8cyuhuvw3axcp545y0ktq9cezuzx84jyhex8dk4tdvwhu4dl', + payable: true, + }, + { + address: + 'u1trxzh330wl8wkh92uwv508z0qfx270ruuar8fxeng7arry5d73q9ve6gfud36s9nc4qj3uvn082l9srrfayjhnf20mmunywtvqzgc90c', + payable: true, + }, + { + address: + 'u1xdrenc94696j8clxa2xnkdg8xd5t3y8s24urctyxu87vggv0u46qr4lkpnh7gqqdev9wwugt6xkv8c8du8ufhfl8nfjnzusf6cw20wpm85hlshmnmj2lkyhka9rua7qw7kr0xeajk7y2rlsuwl6z6l5l3wq3v6rrqt9e8zy7sc7pww45jznrj4xy6h9rp4kjy5xtl5upr30u4cyk58kv3t80k3p8w97k3e345h7avmjylxakx6sgyk5ss8th5kqay50ewav62eeep7tghzejaflsdstpwz55haex398jqpq27007me2', + payable: false, + }, + { + address: + 'u1y647tzm2ms4stj8skfswfljmvatmhwqzjzl2uq5v3a78ys2mls2g9thdap4yfmr9tw6y5h9gnehzhpddyl43enmhd6xv2udcttqmas35l62jt2yar33jwr5eulchzxg3d8upf2raqcx3jup8s3dep6an5n5xh9ngdjfp4hjv8fwfhh34kvglsug57zf0duypq6ugmysw0mnhdg5fz9sndputdc7pdssg6k3ks76wrrnuu5najqxj8xchp5xv5ahfh3f2szrfl5cm6mslq2f69ja9r54plen209xwpdpwsvm6zep4gwl', + payable: false, + }, + { + address: + 'u1m5jvynaxyrtk27mt23q0j4r8uf5dzzhlwf6qd4s7pfdclqnmgkaf82kqrch0p44kd97f9pmwnk6q3rnjnzvlwv2ll289ahzlee4zcnual03ntelg2q2wxlqc6ueav935j4j2rzv2gxcdh6lk67quzxnxt5ay9xh0qjc9575dptfs9luhhr0m9wms2taq2vnrryjdj3ht5cktwathcerl9kw25y89f3hffyr65rnfw0jk2ka7703m8wym0c04u6r0xgagpn7xzfaxttrwgftmztzln6y2qcdglk3u28dgrswywqne28g', + payable: false, + }, + { + address: + 'u1tqx832p4wsfe9pd67ggm3qsmfuvdhqvw2259y7uwug7y0lpeu87fmgpqh3zmamex3fzs0d4ct4hhsg2csj5z0q5f3f7n656ap8e4nlng9c4440rz9s7ekxanfw6g84f7vu82fumtmlz3vstl2a9ufa0970k4knsz2wpsjt2xycqeay76pt4fx3ak9y7mps2q6qe2n2h7wkakxr7xu6vd36zhhzgln7ttmrzc0f9ye3jmyu2pp8l8rect87lfxj2fgckcwz3svdx70a947fz04kgu7e907enzrk676zdkdmuyw2kyrclkmj62kmyy2rjetpus7knmxfuu7z0m63uwfhdynhuu3yrjqu5y089v8zwnh60mw5ngc0kszdjmc339fk9mjn396m5ekv7h7td7fa0u9097xph3y5vth9af4sw6ykxdms84wr544mxxqtmgj027d9e8rnlrazge0kwyydyhder3chwhmaqjk9skuxgxzternw4xx962qed', + payable: false, + }, + { + address: + 'u1adph5ua2pv8ghr7utshst0fm0ad7tj32y09t2nhxn2ccwm6hengck3w2vy34tvhqay7rlw8vcfh63f85lh7lz63l0c5vja49tu8vcxvx30re085n8jt5hcqh4g4ec77czl4c8nspqps2ac2g5kxhl4j5g6mz3vsvxrg74e8p9s8hhqu8u3gldhxvrxg2htykqc7ceh930f3edxsg49nctv2e36cne6qpkvxzfymh2el2eguw6kg7zvdu620rgk4cwyvt9hz7zpjk9wskjdpk6p3cpyx3yuf5lk46nx2fyqjca3vtz8d9df3tpmg74d90uv7pp09apfa5ep374clznmh2ne5suxtzk22cp7mvu9gtswpvx9wfst63s73yjwqu9cjenwntdsep0uqz2hgnh4xpq0rlllwgv8z70ke6z5zkwnjmlrzt6nsvhac4zz245rp3rkj9lmj8tvpfmd0zawy08dv3hqxxf8cr06x90amtgkh2ura0yyfwucu', + payable: false, + }, + { + address: + 'u12acx92vw49jek4lwwnjtzm0cssn2wxfneu7ryj4amd8kvnhahdrq0htsnrwhqvl92yg92yut5jvgygk0rqfs4lgthtycsewc4t57jyjn9p2g6ffxek9rdg48xe5kr37hxxh86zxh2ef0u2lu22n25xaf3a45as6mtxxlqe37r75mndzu9z2fe4h77m35c5mrzf4uqru3fjs39ednvw9ay8nf9r8g9jx8rgj50mj098exdyq803hmqsek3dwlnz4g5whc88mkvvjnfmjldjs9hm8rx89ctn5wxcc2e05rcz7m955zc7trfm07gr7ankf96jxwwfcqppmdefj8gc6508gep8ndrml34rdpk9tpvwzgdcv7lk2d70uh5jqacrpk6zsety33qcc554r3cls4ajktg03d9fye6exk8gnve562yadzsfmfh9d7v6ctl5ufm9ewpr6se25c47huk4fh2hakkwerkdd2yy3093snsgree5lt6smejfvse8v', + payable: false, + }, + { + address: + 'u1uehkuaq6rpfgt4ed5zpvhczg9apgpmyk5eq9qg23j8w7jxkhdnqzacte6gu8zgzfzgxy48ryzus3wnkhfxrxmlhs34xde3f34uxcnv3y6dsgj288vu56xs9f6ghvqsgkhuwtz4kkfxj8pa27v5p3ttlst340zvwx9nj6s0zw8p3wwk3zh37dwc7znqz52gj2fpaapzxzyagah0aeyxwa9fxxvyyj6w989v96ymsgf7s8s6ej9346p60fcjzzynvf9rmxevumdvt8l9mvhdfz4u5j4h7e0zjr2sde7fu7z9s02447qg6qzllm22egnx6ej6qczkkk2ygvpy08un9ggp853sddp6vskrlar6sygxec5f6c2t2eu9zmc728esy4sj9z853gxuplr6hw7lpcwzk20d85vuflnhlfv8nr3020r0v9z83ryudsyjv66rttxq2cscqlrdxakrmpjptzcf', + payable: false, + }, + { + address: + 'u1m76hh3wch9vwctg92h0jjt8zu6dry4zl97q9q94huutng5sxyhlzgfj64jqnvla2vqrqe0ndt67td2kejv6zlcw9zeurexxs67l7y67p7mww2j2uvfsp6uynct2apcr0m9xrmswtktmgs3x2glvndrqazy0gyrp30j328h4m5gkju9rl3pfrtjn9tm8v0rzr6t8gkklqfxgwk976dvv4kh7hl5utp9gjryu8wwu80h733ss5cjwpeewdgd3l8h46c0c7hxz4c6daws3vurq2fj9h0hpjnycup9tu8nfahvqjxewyhyuzynnjxa7jrvw2ekdytqs7sn02gqx4vxtkjzfrcy67lkmr6p5kalj0g8apazeyzqw3ywppy9482wj8k4tm06573nr3h78ecq9n260g7c0hm5jm3ffa4g2vk0edpdsnemksdegxgt9s7h8v8pjmcp23rnahmzf8pxdtdt', + payable: false, + }, + { + address: + 'u1c2tpmmdl49pdcfntc2e2gjaxmj2a0ackydlj9aeuqlet4erjdn2edwvtx6vd8nrkxjnvgckn4j3nx48p2gep5x23akrl2cv7u2un4vmjed9hav39taqgzyp602m3tpcv3uzdsjdyl8wxrjycx5aus8ypq2xja8yw0cf045n0zvwt3ajtgs2xyzjl6cq2245avkm26qjv72ta65h04etlp4ntdq87eu9efjx5v6gjsfvwrdt99m4lpu9j52t0h8yvpnzukuzdt89e3pg9cmderzh7tnahmw0rfyc37aqmd6dh24fnxmxagsj4mtz8jv3c3ch20xu4k6whwfsaf2sra4ktgdej9p6kqz05ae3vl3f93xsfx05xpaf884h56epcetx627jttgx2499vc0uzxl83hcdt92z4hy5la40ervrpha4kn3kxxwrngdj76u6mrfcmt4737czn08vd60k5gj', + payable: false, + }, + { + address: + 'u1dqavtnjvu42hlsjw6sc2mxajqlyt03zg8l4luykz9fnchunq74nqxhfp58h5n5xfpyqhheax8thta8lfkjgp8wqwsavc0g4mgu4du02c', + payable: true, + }, + { + address: + 'u1q8g29qhrktunc24lud3fgk007u7ya8q5g8vy9awadxtl7wu5vjllr4mmdfwk0zdh8zqxgl93sthzumeanzzkdqmqdft6ryhwtvqyqt3e', + payable: true, + }, + { + address: + 'u13j3q8q8f9hx2nx0w9l52dqksy4png7fgm0lqjh8ahn9enyvz5z9xnwzdcdjmpf756s2y88rnyr9px4f4k9w03sl6fr4vwsqcvg8ggfjx', + payable: true, + }, + { + address: + 'u1ukslldhknrzmvpdmn03u03edgfy976w3muurfs9asvh3n9uh9h6sgle6m7yjgf3wafxtvke08u735v4nd3kjqnyulw7cvxh6ke357knyjudgqtes6kcw7y28e6kewr03pjah5mh26na', + payable: true, + }, + { + address: + 'u1a0dnfvgdp4khm5yk79ltkkvp8jjmjykjy38cdue8ktl8askwenl4lzfyu0p7end0guyu6up57wylzns0tpr99wz5z8edh5u0m4yzuusysr3d2xczwkp82atq3vfw45u2yvtau852lnw', + payable: true, + }, + { + address: + 'u1a84vn0qes8q3jhk7zxs2whd2p922far8kztqdapergs5ej8rarn53v5ddnd6t7e3l5efhaefrhkptatnzq565nrpvf7kn2787gdvervmk08azp4qgehaew2zplkxkkyu36l3v7drg2v', + payable: true, + }, + { + address: + 'u187vrwl4ampyxd5m6aj38n4ndkmj8v6gs97hkt23aps3sn5k89a0gk2smluexgdprcrtm56ezc5c7tjwlrnnl79tjtrxmqd42c5mpyz7g', + payable: false, + }, + { + address: + 'u1xd83nhheggwe78x3lvcygdl8cmwz3gfxnr02sytkxvfpwdep9dzl7vte48zhkx39s705yqp20rw4l835fhg3ylkde44l7glt3cyps5wk', + payable: false, + }, + { + address: + 'u1w7x9ttwvk30grems6ae3rhgs6xytrrueaklyc5t509fpux7043fzla70jehhxyn4mg9d3ym095s3wghl9trvvdmu56yn74ajqy38ufjg', + payable: false, + }, + { + address: + 'u1smpx6drvevct3dyrer7esjlct99lf4nxdeltdetyxjdrmtqag7q7mkrd8rxlvj9e5vy0qy24fhvvvrj7agfdgxapefxe72xl8vuu9ds5yfq0p86r3y0jw4suurzjz5s6lzrxkfft4am', + payable: true, + }, + { + address: + 'u1ymxkv9nks7tuzjt265fg8vctdq5nxqw4l0q2xj2ya5dkt660rrzkg032v5duhgeqae6cnh9tzxry4dspv8yvtq5lem9gujysaz64034mavd8p0ejqhnvp2jg34nt24y2c2whclxxk94', + payable: true, + }, + { + address: + 'u14j8rtl62a70skh0nhzv7tasxsa69axm0vlac37ye3mcgfpjk6k9ury7hlmet0grhvhedtfj27xmsygp06pcm932f8sc33u5uwps57d89667kyhwmj8pucp5r8cel2lhuaxmx5ftm2nt', + payable: true, + }, + { + address: + 'u1xjkw3lwwf9crx8cz050gdwfejufzhcusc37ged99w8fyj7tyx3e7hgmauyuv538dak2sepq6wjv4tyyjnhcef02dr682y5dsuzuftsx83lrvfc6dxd0kk260m4p3c9ka96vf3z9u6axvsj47mfd6kszy39e5gma28yg88yp92kxjt8ah0x329j4gxjdfyn0n2wp3urwrxxz6z0ynx82', + payable: true, + }, + { + address: + 'u1p4c4u3uz2vtkedv78d4phjav86exankz0x9wmrmz8q4mxqaf43gwd0qt486jk5jvpvyccc6lyy2vaq3ht8ngnw4vusryxd9erhhl2uy5x6x4huyfdymwxj7dkyyeut8ld36kxwu3v5wjg7jwp9kr8ul7u3xdakfunvmwq0rkv6y4k0ngm2n24x763uurfmrr685welsefyys2xwp8ug', + payable: true, + }, + { + address: + 'u1ap7zakdnuefrgdglr334cw62hnqjkhr65t7tketyym0amkhdvyedpucuyxwu9z2te5vp0jf75jgsm36d7r09h6z3qe5rkgd8y28er6fz8z5rckspevxnx4y9wfk49njpcujh5gle7mfan90m9tt9a2gltyh8hx27cwt7h6u8ndmzhtk8qrq8hjytnakjqm0n658llh4z0277cyl2rcu', + payable: true, + }, + { + address: + 'u1udmzarqn6y9026whk083lm5vs8pv282egeln6xg0n2a3w4klkpn6208h68ntuus7gp54d937u4f724v2xgdx6qeu74j45vxfn822xty2yyx6u0ecakj8r9uu3r2jqafj64w7updkhtq', + payable: true, + }, + { + address: + 'u1fyvdgdehrx3gvjx5f2ez2lkcm0lcrfxg8hksdmg3g8zujfz8xk2kyhu4dafs99y96sq2t5c3d3zsxhhnlfmj6trmttg5awtwczz8g8xjr7u30hxc4nkyfyefyl4xt3dxdjevsnrkqdg', + payable: true, + }, + { + address: + 'u1kfzux4hf9favh8jmssqa2h04k87advldqz5ze7a8t4un3nkegklhz3ewzk6lmqg0uy7matdway9vn2q8q9rxp0fjwuewpcjtwrwavxjdsfxdvsk5nkx4q35atp0tfepfdsapqkk4en5', + payable: true, + }, + { + address: + 'u1hrwrtyl3m8m2c6vkhu8wng43j5yvwweg37n2qstsqwc9dfw4vhs69m09064522758p44pfz42gu6hydjxua0wt0ge907sgrxkc9mft4gyfjevkhsyl4d8lnzgyd90arhx4t6v20zlfz', + payable: false, + }, + { + address: + 'u1rf4n5f682jspygln8r5pjwh6fmta7xz6n9x868f5wgc9prxsqkrh8jkpmn7wfnag56ml7czw68dv96299ft6s98p05u4jvdx3elyr83jqnzr603vw8yarptpg5pj73zlea0sksuje3r', + payable: false, + }, + { + address: + 'u1l6exm3zmfsr74sqvlwgc0zf7mydwf6z5r79amka84kfwzwef3wxs0yupl2lwhws85vdmqet3rtz795gpnm4h0jjfv4hanwqta0ezlxqe4p578a4aq09s93xhhtf3xhtrlh575qrsf5g', + payable: false, + }, +] + +/** `[raw, jumbled]` hex pairs up to 193 bytes; the longer rows add no other branch. */ +export const f4JumbleVectors: [string, string][] = [ + [ + '5d7a8f739a2d9e945b0ce152a8049e294c4d6e66b164939daffa2ef6ee6921481cdd86b3cc4318d9614fc820905d042b', + '0304d029141b995da5387c125970673504d6c764d91ea6c082123770c7139ccd88ee27368cd0c0921a0444c8e5858d22', + ], + [ + 'b1ef9ca3f24988c7b3534201cfb1cd8dbf69b8250c18ef41294ca97993db546c1fe01f7e9c8e36d6a5e29d4e30a73594bf5098421c69378af1e40f64e125946f', + '5271fa3321f3adbcfb075196883d542b438ec6339176537daf859841fe6a56222bff76d1662b5509a9e1079e446eeedd2e683c31aae3ee1851d7954328526be1', + ], + [ + '62c2fa7b2fecbcb64b6968912a6381ce3dc166d56a1d62f5a8d7551db5fd9313e8c7203d996af7d477083756d59af80d06a745f44ab023752cb5b406ed8985e18130ab33362697b0e4e4c763ccb8f676495c222f7fba1e31defa3d5a57efc2e1e9b01a035587d5fb1a38e01d94903d3c3e0ad3360c1d3710acd20b183e31d49f', + '498cf1b1ba6f4577effe64151d67469adc30acc325e326207e7d78487085b4162669f82f02f9774c0cc26ae6e1a76f1e266c6a9a8a2f4ffe8d2d676b1ed71cc47195a3f19208998f7d8cdfc0b74d2a96364d733a62b4273c77d9828aa1fa061588a7c4c88dd3d3dde02239557acfaad35c55854f4541e1a1b3bc8c17076e7316', + ], + [ + '25c9a138f49b1a537edcf04be34a9851a7af9db6990ed83dd64af3597c04323ea51b0052ad8084a8b9da948d320dadd64f5431e61ddf658d24ae67c22c8d1309131fc00fe7f235734276d38d47f1e191e00c7a1d48af046827591e9733a97fa6b679f3dc601d008285edcbdae69ce8fc1be4aac00ff2711ebd931de518856878f7', + '7508a3a146714f229db91b543e240633ed57853f6451c9db6d64c6e86af1b88b28704f608582c53c51ce7d5b8548827a971d2b98d41b7f6258655902440cd66ee11e84dbfac7d2a43696fd0468810a3d9637c3fa58e7d2d341ef250fa09b9fb71a78a41d389370138a55ea58fcde779d714a04e0d30e61dc2d8be0da61cd684509', + ], + [ + '3476f21a482ec9378365c8f7393c94e2885315eb4671098b79535e790fe53e29fef2b3766697ac32b4f473f468a008e72389fc03880d780cb07fcfaabe3f1a84b27db59a4a153d882d2b2103596555ed9494c6ac893c49723833ec8926c1039586a7afcf4a0d9c731e985d99589c8bb838e8aaf745533ed9e8ae3a1cd074a51a20da8aba18d1dbebbc862ded42435e92476930d069896cff30eb414f727b89e001afa2fb8dc3436d75a4a6f26572504b192232ecb9f0c02411e52596bc5e9045', + '5139912fe8b95492c12731995a0f4478dbeb81ec36653a21bc80d673f3c6a0feef70b6c566f9d34bb726c098648382d105afb19b2b8486b73cbd47a17a0d2d1fd593b14bb9826c5d114b850c6f0cf3083a6f61e38e42713a37ef7997ebd2b376c8a410d797b3932e5a6e39e726b2894ce79604b4ae3c00acaea3be2c1dfe697fa644755102cf9ad78794d0594585494fe38ab56fa6ef3271a68a33481015adf3944c115311421a7dc3ce73ef2abf47e18a6aca7f9dd25a85ce8dbd6f1ad89c8d', + ], + [ + '7e745939ffedbd12863ce71a02af117d417adb3d15cc54dcb1fce467500c6b8fb86b12b56da9c382857deecc40a98d5f2935395ee4762dd21afdbb5d47fa9a6dd984d567db2857b927b7fae2db587105415d4642789d38f50b8dbcc129cab3d17d19f3355bcf73cecb8cb8a5da01307152f13936a270572670dc82d39026c6cb4cd4b0f7f5aa2a4f5a5341ec5dd715406f2fdd2afa733f5f641c8c21862a1bafce2609d9eecfa158cfb5cd79f88008e315dc7d8388e76c1782fd2795d18a763624', + '1a52585e652da6ea46994954905cb79f55fca58171a4d7f773a57d23ed9ddec0c745ef0f4588fa7b2b68d69cdd25e5eb0e08c20523a3957171f1730ab0636faee75da2dc9e89562f0653d4e9422179286ae8305f01371f47ab16eed692c3895ce2fd655e4b19651c35d83c81894f687055b581114440646508e39a49b0d5a99004560af7367cc2738344d4e797a995ed66df72228e3d3746674337104700144c73b6db27d238c9e1770662feb0957d5028b5086f3839aacf275022dd7e7e983b6d', + ], +] diff --git a/packages/sdk-provider-zcash/src/utils/zcashAddress.ts b/packages/sdk-provider-zcash/src/utils/zcashAddress.ts index 751e854cc..509bc996e 100644 --- a/packages/sdk-provider-zcash/src/utils/zcashAddress.ts +++ b/packages/sdk-provider-zcash/src/utils/zcashAddress.ts @@ -1,14 +1,10 @@ import { sha256 } from '@noble/hashes/sha2' import { createBase58check } from '@scure/base' +import { isOrchardUnifiedAddress } from './unifiedAddress.js' const base58check = createBase58check(sha256) -/** - * Whether `address` is a mainnet transparent Zcash address (`t1` P2PKH or `t3` - * P2SH) with a valid checksum. The API handles transparent receivers only, so - * TEX, Sapling and unified addresses, and testnet forms, return `false`. - */ -export const isZcashAddress = (address: string): boolean => { +const isTransparentZcashAddress = (address: string): boolean => { let payload: Uint8Array try { payload = base58check.decode(address) @@ -22,3 +18,12 @@ export const isZcashAddress = (address: string): boolean => { (payload[1] === 0xb8 || payload[1] === 0xbd) ) } + +/** + * Whether `address` is a mainnet Zcash address the API pays to: a transparent + * `t1` (P2PKH) or `t3` (P2SH) address with a valid checksum, or a unified `u1` + * address with an Orchard receiver. Sapling, TEX and testnet addresses, and a + * unified address without an Orchard receiver, return `false`. + */ +export const isZcashAddress = (address: string): boolean => + isTransparentZcashAddress(address) || isOrchardUnifiedAddress(address) diff --git a/packages/sdk-provider-zcash/src/utils/zcashAddress.unit.spec.ts b/packages/sdk-provider-zcash/src/utils/zcashAddress.unit.spec.ts index 3f88ec269..ac3d97cfa 100644 --- a/packages/sdk-provider-zcash/src/utils/zcashAddress.unit.spec.ts +++ b/packages/sdk-provider-zcash/src/utils/zcashAddress.unit.spec.ts @@ -2,14 +2,17 @@ import { describe, expect, it } from 'vitest' import { isZcashAddress } from './zcashAddress.js' // Sources: ZIP 320 (t1 and its TEX form), ZIP 214 (t3), zcash-test-vectors -// unified_address.json (u1, and the Sapling raw address of row 0 encoded as zs1). +// unified_address.json (row 0: transparent and Sapling receivers, and that +// Sapling raw address encoded as zs1), and a Zashi address with an Orchard receiver. const t1 = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC' const t3 = 't3LmX1cxWPPPqL4TZHx42HU3U5ghbFjRiif' const tex = 'tex1s2rt77ggv6q989lr49rkgzmh5slsksa9khdgte' const sapling = 'zs1mrhc9y7jdh5r9ece8u5khgvj9kg0zgkxzdduyv0whkg7lkcrkx5xqem3e48avjq9wn2rukydkwn' -const unified = +const unifiedWithoutOrchard = 'u1l8xunezsvhq8fgzfl7404m450nwnd76zshscn6nfys7vyz2ywyh4cc5daaq0c7q2su5lqfh23sp7fkf3kt27ve5948mzpfdvckzaect2jtte308mkwlycj2u0eac077wu70vqcetkxf' +const unifiedWithOrchard = + 'u1k9eh52jx5q4y8lw6x208lsep4t6yzwk7mdwz9e6239qywjkqzdcd3al3d64zwnqx296p3klxnash5w2e0elg39qrydxx0s0qz5m2gnt0' // The t1 above with its last character changed: the checksum no longer matches. const t1WithBadChecksum = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yD' // Testnet forms of the same hash: version 0x1D25 and HRP `textest`. @@ -18,16 +21,17 @@ const testnetTex = 'textest1s2rt77ggv6q989lr49rkgzmh5slsksa90ej7wz' describe('isZcashAddress', () => { it.each([ - ['p2pkh', t1], - ['p2sh', t3], - ])('accepts a %s address', (_, address) => { + ['a p2pkh', t1], + ['a p2sh', t3], + ['an Orchard unified', unifiedWithOrchard], + ])('accepts %s address', (_, address) => { expect(isZcashAddress(address)).toBe(true) }) it.each([ ['a TEX address', tex], ['a Sapling address', sapling], - ['a unified address', unified], + ['a unified address without an Orchard receiver', unifiedWithoutOrchard], ['a corrupted checksum', t1WithBadChecksum], ['a testnet transparent address', testnetTransparent], ['a testnet TEX address', testnetTex],