Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
81fdafc
feat(widget-provider): check an address against a destination chain
chybisov Sep 23, 2026
51057dd
feat(chains): keep destination-only chains out of the source side
chybisov Sep 23, 2026
1b73425
feat(send-to-wallet): validate a receiver against the destination chain
chybisov Sep 23, 2026
a078385
fix(send-to-wallet): require and clear receivers per destination chain
chybisov Sep 23, 2026
7e447ae
fix(routes): quote only with a receiver valid on the destination chain
chybisov Sep 23, 2026
7275b76
fix(send-to-wallet): offer only receivers valid on the destination chain
chybisov Sep 23, 2026
48dd12a
fix(bitcoin): never fill or price a Bitcoin wallet as a ZEC receiver
chybisov Sep 23, 2026
e2c0c6a
fix(checkout): accept only receivers valid on the destination chain
chybisov Sep 23, 2026
27ff6b7
chore: add changeset for Zcash as a destination-only chain
chybisov Sep 23, 2026
735e9f0
fix(send-to-wallet): judge only a signer that belongs to the source c…
chybisov Sep 23, 2026
384bc14
refactor(chains): deny destination-only chains as a source in the res…
chybisov Sep 23, 2026
55c4d4a
fix(chains): hide the reverse button when the destination cannot be a…
chybisov Sep 23, 2026
b8bb4e6
refactor(send-to-wallet): drop the unused requiredToChainType
chybisov Sep 23, 2026
a80e973
chore: list every package with a new SDK floor in the changeset
chybisov Sep 23, 2026
0a43e31
fix(widget): resolve the chains config once per integrator change
chybisov Sep 23, 2026
1144a7f
refactor(widget-provider): drop hasProviderFor, which the signer chec…
chybisov Sep 23, 2026
247e8b2
refactor(checkout): drop the destination-only branch, which checkout …
chybisov Sep 23, 2026
0ae0703
fix(chains): hide the reverse button for any destination the config d…
chybisov Sep 23, 2026
5733038
style: keep comments to one-line reasons
chybisov Sep 23, 2026
3498db3
fix(widget): never price a Bitcoin account as the ZEC receiver in gas…
chybisov Sep 23, 2026
eaa4178
fix(chains): keep the reverse button while no destination is chosen
chybisov Sep 23, 2026
04f799f
refactor(widget): drop a destination-only fromChain once in the resol…
chybisov Sep 23, 2026
c272b88
refactor(send-to-wallet): drop the unused chainType validation argument
chybisov Sep 23, 2026
d5b7af7
refactor: trim a redundant guard, an unused export and narrating comm…
chybisov Sep 23, 2026
9756c00
fix(chains): hide the cards-layout swap button when the destination c…
chybisov Sep 23, 2026
be97a26
fix(send-to-wallet): show the Zcash address message in every locale
chybisov Sep 23, 2026
d1257e0
feat(widget-provider): ask the provider that serves a chain and add a…
chybisov Sep 30, 2026
332dad2
feat(widget-provider-zcash): add an opt-in Zcash widget provider
chybisov Sep 30, 2026
882842a
feat(widget): hide a destination-only chain that no provider serves
chybisov Sep 30, 2026
bf853af
feat(widget): keep the chain of an address found without a chain
chybisov Sep 30, 2026
71ef2f3
chore(playground): offer ZEC through the Zcash widget provider
chybisov Sep 30, 2026
081e3c4
fix(widget): keep the served chains config stable across renders
chybisov Sep 30, 2026
24df93a
fix(widget): drop a preset destination-only toChain that no provider …
chybisov Sep 30, 2026
96b5b08
refactor(widget): keep the detected chain of a resolved name as well
chybisov Sep 30, 2026
cead823
ci: run the playground e2e checks for the Zcash widget provider
chybisov Sep 30, 2026
6190aac
fix(widget): drop an unserved destination-only toChain from the URL i…
chybisov Sep 30, 2026
663f27e
refactor(widget): take a receiver's chain from the provider that dete…
chybisov Sep 30, 2026
ac9c1d6
docs(changeset): note the reverse control for a chain excluded as a s…
chybisov Sep 30, 2026
5110021
refactor(widget): drop an unserved toChain preset once, in the served…
chybisov Sep 30, 2026
12a67f6
refactor(widget): detect a receiver with one lookup
chybisov Sep 30, 2026
e5ee396
docs(changeset): explain the Zcash provider under external wallet man…
chybisov Sep 30, 2026
f0c226c
docs(changeset): upgrade the provider packages together with the widget
chybisov Sep 30, 2026
63dc8ba
chore(deps): pin the SDK preview with the Zcash provider
chybisov Oct 5, 2026
b143235
perf(widget): check each send-to-wallet row once
chybisov Oct 5, 2026
847a8cb
fix(widget): show the detected chain for a configured or linked Zcash…
chybisov Oct 5, 2026
1a028dd
feat(widget): accept unified Zcash receivers with an Orchard receiver
chybisov Oct 6, 2026
71341b2
chore(deps): pin the SDK preview with unified Zcash addresses
chybisov Oct 6, 2026
b967575
test(widget): name the malformed unified fixture for what it is
chybisov Oct 6, 2026
226c95e
fix(widget): quote, read balances and reset receivers correctly for ZEC
chybisov Oct 7, 2026
d4ddb62
chore(widget-provider-zcash): release the first version as 4.0.0
chybisov Oct 7, 2026
3acc207
chore(deps): use the released SDK with Zcash support
chybisov Oct 7, 2026
75824f2
ci: reset the bundle size budgets on this branch and measure Zcash
chybisov Oct 7, 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/token-pick-receiver-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lifi/widget': patch
---

A token pick that moves the destination chain, for example from "All networks", now resets a receiver the new chain cannot take, as the chain select already does.
18 changes: 18 additions & 0 deletions .changeset/zcash-destination-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@lifi/widget': minor
'@lifi/widget-provider': minor
'@lifi/widget-checkout': patch
'@lifi/wallet-management': patch
'@lifi/widget-provider-bitcoin': patch
'@lifi/widget-provider-ethereum': patch
'@lifi/widget-provider-solana': patch
'@lifi/widget-provider-stellar': patch
'@lifi/widget-provider-sui': patch
'@lifi/widget-provider-tron': patch
---

ZEC is a destination-only chain. It never appears as a source chain or source token, and a `fromChain` of ZEC from the widget config or the URL is ignored. A ZEC receiver must be a transparent Zcash address (`t1…` or `t3…`) or a unified address (`u1…`) with an Orchard receiver; Sapling, TEX and other unified addresses get a message that names the accepted formats. The address inputs take up to 512 characters, so a unified address fits.

Receivers are now checked against the destination chain, not only its chain type, so a Bitcoin address no longer passes as a ZEC receiver. A connected Bitcoin wallet is never filled in, offered, seeded or priced as a ZEC receiver, and a BTC → ZEC route asks for a receiver. Bookmarks of other ecosystems still save as before, and a Zcash bookmark shows the Zcash icon. The reverse and swap controls stay hidden while the destination chain is not allowed as a source by `chains.from`, so they never select a source chain the config excludes.

`@lifi/widget-provider` exports `useAddressForChain`, which returns `isAddressForChain(address, chain)`, and the `ChainRef` and `IsAddressForChain` types. Every package that depends on the SDK requires the release whose `isAddress` accepts a chain ID, so a consumer keeps a single `@lifi/sdk`. The widget now passes the chain ID to each provider's `isAddress`, so upgrade the `@lifi/widget-provider-*` packages together with `@lifi/widget`: an older `@lifi/widget-provider-bitcoin` refuses every Bitcoin receiver and hides Bitcoin balances. `name` and `version` exported from `@lifi/widget` stay the widget's own now that `@lifi/sdk` exports the same names.
7 changes: 7 additions & 0 deletions .changeset/zcash-provider-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@lifi/widget-provider-zcash': minor
'@lifi/widget-provider': minor
'@lifi/widget': minor
---

New package `@lifi/widget-provider-zcash`. Add `ZcashProvider()` to `providers` to offer ZEC as a destination; without it the widget shows no ZEC, because no ZEC receiver could be validated. With external wallet management the widget ignores `providers`, so render the component that `ZcashProvider()` returns around the widget, next to the other ecosystem providers. `@lifi/widget-provider` exports `ZcashContext` and `useZcashContext`, asks the provider that serves a chain for receiver checks, and recognises a Zcash address without a chain, so a Zcash bookmark keeps the Zcash icon.
1 change: 1 addition & 0 deletions .github/workflows/e2e-playground-dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ on:
- 'packages/widget-provider-stellar/**'
- 'packages/widget-provider-sui/**'
- 'packages/widget-provider-tron/**'
- 'packages/widget-provider-zcash/**'
# Dev smoke test code and config
- 'e2e/tests/dev/**'
- 'e2e/tests/components/**'
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/e2e-playground.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ on:
- 'packages/widget-provider-stellar/**'
- 'packages/widget-provider-sui/**'
- 'packages/widget-provider-tron/**'
- 'packages/widget-provider-zcash/**'
# E2E test code (widget-light and widget-embedded are not playground dependencies)
- 'e2e/tests/playground/**'
- 'e2e/tests/components/**'
Expand Down
22 changes: 15 additions & 7 deletions .size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"path": "packages/widget/dist/esm/index.js",
"import": "{ LiFiWidget }",
"ignore": ["react", "react-dom", "@tanstack/react-query", "*.json"],
"limit": "321 kB"
"limit": "322 kB"
},
{
"name": "@lifi/widget · English translations (always loaded)",
Expand Down Expand Up @@ -77,14 +77,14 @@
"path": "packages/widget-provider-solana/dist/esm/index.js",
"import": "{ SolanaProvider }",
"ignore": ["react", "bs58", "@lifi/sdk", "@lifi/widget-provider"],
"limit": "36 kB"
"limit": "39 kB"
},
{
"name": "Providers · bitcoin",
"path": "packages/widget-provider-bitcoin/dist/esm/index.js",
"import": "{ BitcoinProvider }",
"ignore": ["react", "@bigmi/react", "@lifi/sdk", "@lifi/widget-provider"],
"limit": "106 kB"
"limit": "107 kB"
},
{
"name": "Providers · sui",
Expand All @@ -108,14 +108,21 @@
"@lifi/sdk",
"@lifi/widget-provider"
],
"limit": "876 kB"
"limit": "877 kB"
},
{
"name": "Providers · stellar",
"path": "packages/widget-provider-stellar/dist/esm/index.js",
"import": "{ StellarProvider }",
"ignore": ["react", "@lifi/sdk", "@lifi/widget-provider"],
"limit": "212 kB"
"limit": "213 kB"
},
{
"name": "Providers · zcash",
"path": "packages/widget-provider-zcash/dist/esm/index.js",
"import": "{ ZcashProvider }",
"ignore": ["react", "@lifi/sdk", "@lifi/widget-provider"],
"limit": "8 kB"
},
{
"name": "Full integration · LiFiWidget + all providers + their peers (incl. on-demand code)",
Expand All @@ -126,9 +133,10 @@
"packages/widget-provider-bitcoin/dist/esm/index.js": "{ BitcoinProvider }",
"packages/widget-provider-sui/dist/esm/index.js": "{ SuiProvider }",
"packages/widget-provider-tron/dist/esm/index.js": "{ TronProvider }",
"packages/widget-provider-stellar/dist/esm/index.js": "{ StellarProvider }"
"packages/widget-provider-stellar/dist/esm/index.js": "{ StellarProvider }",
"packages/widget-provider-zcash/dist/esm/index.js": "{ ZcashProvider }"
},
"ignore": ["react", "react-dom", "@tanstack/react-query"],
"limit": "2278 kB"
"limit": "2292 kB"
}
]
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,23 @@ interface MockAccount {

let mockAccounts: MockAccount[] = []
let mockFundingSource: string | null = 'wallet'
let mockDestinationChain: { id: number; chainType: string } = {
id: 1,
chainType: 'EVM',
}
// EVM chains take 0x addresses; ZEC takes t1 addresses only.
const mockIsAddressForChain = (
address: string,
chain: { id: number; chainType: string }
) => {
if (chain.chainType === 'EVM') {
return address.startsWith('0x')
}
if (chain.id === 20000000000005) {
return address.startsWith('t1')
}
return address.startsWith('bc1')
}
let mockResolved: {
isUserSettable: boolean
isUserSet: boolean
Expand All @@ -23,7 +40,10 @@ vi.mock('@lifi/wallet-management', () => ({
}))
vi.mock('@lifi/widget/shared', () => ({
useWidgetConfig: () => ({ toChain: 1 }),
useChain: () => ({ chain: { chainType: 'EVM' } }),
useChain: () => ({ chain: mockDestinationChain }),
}))
vi.mock('@lifi/widget-provider', () => ({
useAddressForChain: () => ({ isAddressForChain: mockIsAddressForChain }),
}))
vi.mock('../stores/useCheckoutFlowStore.js', () => ({
useCheckoutFlowStore: (
Expand All @@ -41,6 +61,7 @@ describe('useDefaultWalletRecipient', () => {
setUserRecipient.mockReset()
mockAccounts = [{ isConnected: true, address: '0xabc', chainType: 'EVM' }]
mockFundingSource = 'wallet'
mockDestinationChain = { id: 1, chainType: 'EVM' }
mockResolved = { isUserSettable: true, isUserSet: false, setUserRecipient }
})

Expand Down Expand Up @@ -75,4 +96,17 @@ describe('useDefaultWalletRecipient', () => {
renderHook(() => useDefaultWalletRecipient())
expect(setUserRecipient).not.toHaveBeenCalled()
})

it('does not seed a Bitcoin wallet for a ZEC destination', () => {
mockDestinationChain = { id: 20000000000005, chainType: 'UTXO' }
mockAccounts = [
{
isConnected: true,
address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
chainType: 'UTXO',
},
]
renderHook(() => useDefaultWalletRecipient())
expect(setUserRecipient).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client'
import { useAccount } from '@lifi/wallet-management'
import { useChain, useWidgetConfig } from '@lifi/widget/shared'
import { useAddressForChain } from '@lifi/widget-provider'
import { useEffect, useRef } from 'react'
import { useCheckoutFlowStore } from '../stores/useCheckoutFlowStore.js'
import { useResolvedCheckoutRecipient } from './useResolvedCheckoutRecipient.js'
Expand All @@ -18,6 +19,7 @@ export function useDefaultWalletRecipient(): void {
const { isUserSettable, isUserSet, setUserRecipient } =
useResolvedCheckoutRecipient()
const { accounts } = useAccount()
const { isAddressForChain } = useAddressForChain()
const seededRef = useRef(false)

useEffect(() => {
Expand All @@ -32,7 +34,9 @@ export function useDefaultWalletRecipient(): void {
}
const match = accounts.find(
(a) =>
a.isConnected && a.address && a.chainType === destinationChain.chainType
a.isConnected &&
a.address &&
isAddressForChain(a.address, destinationChain)
)
if (match?.address) {
seededRef.current = true
Expand All @@ -48,5 +52,6 @@ export function useDefaultWalletRecipient(): void {
destinationChain,
accounts,
setUserRecipient,
isAddressForChain,
])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @vitest-environment happy-dom
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'

let mockSavedRecipient: { address: string; chainType: string } | undefined

vi.mock('@lifi/widget/shared', () => ({
useWidgetConfig: () => ({ toChain: 20000000000005 }),
useChain: () => ({ chain: { id: 20000000000005, chainType: 'UTXO' } }),
}))
vi.mock('@lifi/widget-provider/checkout', () => ({
useCheckoutConfig: () => ({
integrator: 'test',
allowUserDestinationAddress: true,
}),
}))
vi.mock('@lifi/widget-provider', () => ({
useAddressForChain: () => ({
isAddressForChain: (address: string) => address.startsWith('t1'),
}),
}))
vi.mock('../stores/useCheckoutRecipientStore.js', () => ({
useCheckoutRecipientStore: (selector: (state: unknown) => unknown) =>
selector({
recipients: { test: mockSavedRecipient },
setRecipient: vi.fn(),
clearRecipient: vi.fn(),
}),
}))

import { useResolvedCheckoutRecipient } from './useResolvedCheckoutRecipient.js'

describe('useResolvedCheckoutRecipient', () => {
beforeEach(() => {
mockSavedRecipient = undefined
})

it('drops a saved Bitcoin recipient for a ZEC destination', () => {
mockSavedRecipient = {
address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
chainType: 'UTXO',
}
const { result } = renderHook(() => useResolvedCheckoutRecipient())
expect(result.current.recipient).toBeNull()
expect(result.current.isUserSet).toBe(false)
})

it('keeps a saved Zcash recipient for a ZEC destination', () => {
mockSavedRecipient = {
address: 't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC',
chainType: 'UTXO',
}
const { result } = renderHook(() => useResolvedCheckoutRecipient())
expect(result.current.recipient?.address).toBe(
't1VmmGiyjVNeCjxDZzg7vZmd99WyzVby9yC'
)
})
})
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ChainType } from '@lifi/sdk'
import { useChain, useWidgetConfig } from '@lifi/widget/shared'
import { useAddressForChain } from '@lifi/widget-provider'
import { useCheckoutConfig } from '@lifi/widget-provider/checkout'
import { useCallback, useMemo } from 'react'
import {
Expand All @@ -21,24 +22,24 @@ export interface ResolvedCheckoutRecipient {
export function useResolvedCheckoutRecipient(): ResolvedCheckoutRecipient {
const { toAddress, toChain } = useWidgetConfig()
const { chain: destinationChain } = useChain(toChain)
const { isAddressForChain } = useAddressForChain()
const { integrator, allowUserDestinationAddress } = useCheckoutConfig()
const userRecipient = useCheckoutRecipientStore(
(s) => s.recipients[integrator] ?? null
)

// Drop a persisted recipient that no longer matches the destination ecosystem.
const validUserRecipient = useMemo(() => {
if (!userRecipient) {
return null
}
if (
destinationChain &&
userRecipient.chainType !== destinationChain.chainType
!isAddressForChain(userRecipient.address, destinationChain)
) {
return null
}
return userRecipient
}, [userRecipient, destinationChain])
}, [userRecipient, destinationChain, isAddressForChain])
const setRecipient = useCheckoutRecipientStore((s) => s.setRecipient)
const clearRecipient = useCheckoutRecipientStore((s) => s.clearRecipient)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
useHeader,
useWidgetConfig,
} from '@lifi/widget/shared'
import { useAddressForChain } from '@lifi/widget-provider'
import WalletOutlinedIcon from '@mui/icons-material/AccountBalanceWalletOutlined'
import {
Avatar,
Expand Down Expand Up @@ -44,6 +45,7 @@ export const SetDestinationAddressPage: React.FC = (): JSX.Element => {
const { setFieldValue } = useFieldActions()
const { openWalletMenu } = useWalletMenu()
const { accounts } = useAccount()
const { isAddressForChain } = useAddressForChain()

const [value, setValue] = useState('')
const [error, setError] = useState<string | null>(null)
Expand All @@ -66,26 +68,16 @@ export const SetDestinationAddressPage: React.FC = (): JSX.Element => {
setError(null)
const result = await validateAddress({
value: value.trim(),
chainType: destinationChain?.chainType,
chain: destinationChain,
})
if (!result.isValid) {
setError(result.error)
return
}
if (destinationChain && result.chainType !== destinationChain.chainType) {
setError(
t('error.title.walletAddressInvalid', {
context: 'chain',
chainName: destinationChain.name,
})
)
return
}
commitRecipient(result.address, result.chainType)
}, [value, destinationChain, validateAddress, commitRecipient, t])
}, [value, destinationChain, validateAddress, commitRecipient])

// Adopt the first connected account matching the destination ecosystem after connect.
// Adopt the first connected account matching the destination chain after connect.
const awaitingConnectRef = useRef(false)
const handleConnectWallet = useCallback(() => {
awaitingConnectRef.current = true
Expand All @@ -98,28 +90,31 @@ export const SetDestinationAddressPage: React.FC = (): JSX.Element => {
}
const match = accounts.find(
(a) =>
a.isConnected && a.address && a.chainType === destinationChain.chainType
a.isConnected &&
a.address &&
isAddressForChain(a.address, destinationChain)
)
if (match?.address) {
awaitingConnectRef.current = false
commitRecipient(match.address, destinationChain.chainType)
}
}, [accounts, destinationChain, commitRecipient])
}, [accounts, destinationChain, commitRecipient, isAddressForChain])

// Connected wallets in the destination ecosystem, offered as one-tap recipients.
// Connected wallets that can receive on the destination chain, offered as one-tap recipients.
const connectedAccounts = useMemo(() => {
const byAddress = new Map<string, (typeof accounts)[number]>()
for (const account of accounts) {
if (
account.isConnected &&
account.address &&
account.chainType === destinationChain?.chainType
destinationChain &&
isAddressForChain(account.address, destinationChain)
) {
byAddress.set(account.address.toLowerCase(), account)
}
}
return [...byAddress.values()]
}, [accounts, destinationChain])
}, [accounts, destinationChain, isAddressForChain])

return (
<PageContainer bottomGutters>
Expand Down
1 change: 1 addition & 0 deletions packages/widget-playground/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@lifi/widget-provider-sui": "workspace:*",
"@lifi/widget-provider-transak": "workspace:*",
"@lifi/widget-provider-tron": "workspace:*",
"@lifi/widget-provider-zcash": "workspace:*",
"@metamask/connect-evm": "^2.1.1",
"@mui/icons-material": "^9.4.0",
"@mui/material": "^9.4.0",
Expand Down
Loading
Loading