Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
08293aa
feat(bitcoin): decode Zcash addresses and accept the transparent kinds
chybisov Sep 23, 2026
f2fc211
feat(sdk): validate an address against a destination chain
chybisov Sep 23, 2026
071bde4
fix(bitcoin): leave balances of other UTXO chains unknown
chybisov Sep 23, 2026
a75a5b3
chore: add changesets for the Zcash address check
chybisov Sep 23, 2026
03b3773
test(sdk): pin isAddress with a chain ID to the answer without one
chybisov Sep 23, 2026
cca4db9
test(bitcoin): read a Bitcoin balance, not Polygon tokens, in the int…
chybisov Sep 23, 2026
99e8620
fix(bitcoin): give the Bitcoin balance to BTC tokens only in a mixed …
chybisov Sep 23, 2026
c7c61f2
docs(sdk): state what isAddress answers without a chain ID
chybisov Sep 23, 2026
11cfe3d
fix(bitcoin): decode Base58Check with named imports that work in both…
chybisov Sep 23, 2026
3feaf80
refactor(bitcoin): check only the transparent Zcash formats a route a…
chybisov Sep 23, 2026
8ef92d3
fix(bitcoin): keep the Bitcoin block number off non-BTC tokens when t…
chybisov Sep 23, 2026
bcbb00e
docs(sdk): describe isAddress by behaviour, not history
chybisov Sep 23, 2026
8827b31
refactor(bitcoin): reuse bitcoinjs-lib's sha256 for Base58Check
chybisov Sep 23, 2026
0b7e3b8
chore: drop the changeset for an invisible Ethereum provider change
chybisov Sep 23, 2026
e7c6c20
feat(sdk): let a provider serve listed chains beside the generic prov…
chybisov Sep 30, 2026
f03e2f8
feat(sdk-provider-zcash): add a destination-only Zcash provider
chybisov Sep 30, 2026
a310c0b
refactor(sdk-provider-bitcoin): leave Zcash to its own provider
chybisov Sep 30, 2026
1d3c2ef
fix(sdk): scope a provider by its sorted chain list, and a generic on…
chybisov Sep 30, 2026
1d2c75d
fix(sdk-provider-bitcoin): set no BTC amount when the balance request…
chybisov Sep 30, 2026
3718594
docs: count the Zcash provider package
chybisov Sep 30, 2026
79626ca
docs(changeset): tell custom provider authors to wrap a two-parameter…
chybisov Sep 30, 2026
4f8a495
docs: register the Zcash provider after the Bitcoin provider
chybisov Sep 30, 2026
1b774ee
chore(sdk-provider-zcash): align the test tooling with main
chybisov Oct 5, 2026
b7870f6
feat(sdk-provider-zcash): accept unified addresses with an Orchard re…
chybisov Oct 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bitcoin-known-chains-only.md
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 7 additions & 0 deletions .changeset/chain-aware-is-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@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. 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`.
5 changes: 5 additions & 0 deletions .changeset/zcash-provider.md
Original file line number Diff line number Diff line change
@@ -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, 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.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 unified `u1` receivers that carry an Orchard receiver, and needs no wallet. Register it after `BitcoinProvider`; it serves ZEC only.

## Architecture

The LI.FI SDK uses a modular provider architecture:
Expand Down Expand Up @@ -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',
Expand All @@ -144,6 +152,7 @@ const client = createClient({
SuiProvider({ /* options */ }),
TronProvider({ /* options */ }),
StellarProvider({ /* options */ }),
ZcashProvider(),
],
})
```
Expand Down
9 changes: 7 additions & 2 deletions packages/sdk-provider-bitcoin/src/BitcoinProvider.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
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'

// 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
): BitcoinSDKProvider {
Expand All @@ -13,7 +18,7 @@ export function BitcoinProvider(
get type() {
return ChainType.UTXO
},
isAddress: isUTXOAddress,
isAddress,
resolveAddress: resolveBitcoinAddress,
getBalance: getBitcoinBalance,
async getStepExecutor(
Expand Down
55 changes: 54 additions & 1 deletion packages/sdk-provider-bitcoin/src/BitcoinProvider.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { 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', () => {
Expand Down Expand Up @@ -52,4 +53,56 @@ describe('BitcoinProvider', () => {
expect(executor).toBeDefined()
expect(mockGetWalletClient).toHaveBeenCalledOnce()
})

describe('isAddress', () => {
const provider = BitcoinProvider()
const bitcoinSegwit = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'
const bitcoinLegacy = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'
const zcashP2pkh = 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC'

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('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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down
26 changes: 12 additions & 14 deletions packages/sdk-provider-bitcoin/src/actions/getBitcoinBalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export const getBitcoinBalance = async (
console.warn('Requested tokens have to be on the same chain.')
}
}
// 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)) {
return tokens.map((token) => ({ ...token }))
}
const bigmiClient = await getBitcoinPublicClient(client, ChainId.BTC)
const [balance, blockCount] = await Promise.allSettled([
bigmiClient.getBalance({ address: walletAddress }),
Expand All @@ -28,19 +34,11 @@ export const getBitcoinBalance = async (

const blockNumber =
blockCount.status === 'fulfilled' ? BigInt(blockCount.value) : 0n
// 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 } : {}

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,
}))
}

return tokens.map((token) => ({
...token,
amount: balance.value,
blockNumber,
}))
return tokens.map((token) =>
isBitcoinToken(token) ? { ...token, ...amount, blockNumber } : { ...token }
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
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()
})

it('gives the Bitcoin balance to BTC tokens only in a mixed list', async () => {
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')],
]) {
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 })
}
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)
})
})
3 changes: 2 additions & 1 deletion packages/sdk-provider-bitcoin/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
7 changes: 6 additions & 1 deletion packages/sdk-provider-ethereum/src/EthereumProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -21,7 +26,7 @@ export function EthereumProvider(
get options() {
return _options
},
isAddress,
isAddress: isEthereumAddress,
isTokenAddress: isEthereumTokenAddress,
resolveAddress: resolveEthereumAddress,
getBalance: getEthereumBalance,
Expand Down
22 changes: 21 additions & 1 deletion packages/sdk-provider-ethereum/src/EthereumProvider.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
)
}
}
})
})
})
14 changes: 13 additions & 1 deletion packages/sdk-provider-solana/src/SolanaProvider.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -66,4 +66,16 @@ 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)
for (const address of [usdcMint, 'laptop']) {
expect(provider.isAddress(address, ChainId.SOL)).toBe(
provider.isAddress(address)
)
}
expect(provider.isAddress('laptop', ChainId.SOL)).toBe(false)
})
})
14 changes: 13 additions & 1 deletion packages/sdk-provider-stellar/src/StellarProvider.unit.spec.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -87,4 +87,16 @@ 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)
for (const address of [g, 'laptop']) {
expect(provider.isAddress(address, ChainId.XLM)).toBe(
provider.isAddress(address)
)
}
expect(provider.isAddress('laptop', ChainId.XLM)).toBe(false)
})
})
Loading
Loading