Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/checkout-provider-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@lifi/widget-provider": minor
"@lifi/widget": minor
"@lifi/wallet-management": patch
---

Add the checkout session core to `@lifi/widget-provider` (contexts, session client, on-ramp session registry) and expose shared widget primitives via `@lifi/widget/shared`.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ next-env.d.ts

.claude/worktrees/
.omc/
.cursor/
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ QueryClient → Settings → WidgetConfig → I18n → Theme → SDK → Wallet
- Library packages use `tsdown` with `unbundle: true` mode. The widget package needs `neverBundle: [/\.json$/]` for i18n JSON files.
- **PR template** at `.github/pull_request_template.md` — always use it when creating PRs via `gh pr create`.
- `packages/widget-embedded/README.md` — main integration guide for widget-light (not a typical package readme).
- **Minimal comments** — default to no comments. Add one short line only when the *why* is non-obvious (hidden constraint, subtle invariant, SDK quirk, workaround). Never narrate what the code does, never reference the task/PR/issue, never leave multi-paragraph docstrings on internal functions.

## Release

Expand Down
4 changes: 4 additions & 0 deletions knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
"@walletconnect/ethereum-provider",
"porto"
]
},
"packages/widget": {
"ignore": ["src/config/version.ts"],
"ignoreDependencies": ["@transak/ui-js-sdk", "@meshconnect/web-link-sdk"]
}
}
}
50 changes: 47 additions & 3 deletions packages/wallet-management/src/components/WalletMenuContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
List,
Typography,
} from '@mui/material'
import { useMemo, useReducer, useRef } from 'react'
import { useEffect, useMemo, useReducer, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useAccount } from '../hooks/useAccount.js'
import type { CombinedWallet } from '../hooks/useCombinedWallets.js'
Expand Down Expand Up @@ -83,7 +83,19 @@ export const WalletMenuContent: React.FC<WalletMenuContentProps> = ({
.filter(Boolean)
}, [accounts])

const [state, dispatch] = useReducer(reducer, { view: 'wallet-list' })
const openedWithWalletId = Boolean(walletChainArgs?.walletId)

const [state, dispatch] = useReducer(reducer, undefined, (): State => {
const walletId = walletChainArgs?.walletId
if (!walletId) {
return { view: 'wallet-list' }
}
const wallet = installedWallets.find((w) => w.id === walletId)
if (!wallet) {
return { view: 'wallet-list' }
}
return { view: 'multi-ecosystem', selectedWalletId: walletId }
})

const handleMultiEcosystem = (id: string) => {
dispatch({ type: 'SHOW_MULTI_ECOSYSTEM', id })
Expand All @@ -94,7 +106,18 @@ export const WalletMenuContent: React.FC<WalletMenuContentProps> = ({
}

const handleBack = () => {
dispatch({ type: 'SHOW_WALLET_LIST' })
if (openedWithWalletId) {
if (state.view === 'connecting') {
dispatch({
type: 'SHOW_MULTI_ECOSYSTEM',
id: state.selectedWalletId!,
})
} else {
onClose()
}
} else {
dispatch({ type: 'SHOW_WALLET_LIST' })
}
}

const handleError = (id: string, error: any) => {
Expand All @@ -119,6 +142,10 @@ export const WalletMenuContent: React.FC<WalletMenuContentProps> = ({
const targetChainType =
walletChainArgs.chain?.chainType ?? walletChainArgs.chainType

if (!targetChainType) {
return installedWallets
}

return installedWallets
.map((wallet) => {
const filteredConnectors = wallet.connectors.filter(
Expand All @@ -131,6 +158,23 @@ export const WalletMenuContent: React.FC<WalletMenuContentProps> = ({
.filter(Boolean) as typeof installedWallets
}, [installedWallets, walletChainArgs])

const filteredWalletsRef = useRef(filteredWallets)
filteredWalletsRef.current = filteredWallets

useEffect(() => {
const walletId = walletChainArgs?.walletId
if (!walletId) {
dispatch({ type: 'SHOW_WALLET_LIST' })
return
}
const wallet = filteredWalletsRef.current.find((w) => w.id === walletId)
if (!wallet) {
dispatch({ type: 'SHOW_WALLET_LIST' })
return
}
dispatch({ type: 'SHOW_MULTI_ECOSYSTEM', id: walletId })
}, [walletChainArgs?.walletId])

const isMultiEcosystem = state.view === 'multi-ecosystem'
const isConnecting = state.view === 'connecting'

Expand Down
16 changes: 16 additions & 0 deletions packages/wallet-management/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import type {} from '@mui/material/themeCssVarsAugmentation'

export { BitcoinListItemButton } from './components/BitcoinListItemButton.js'
export { CardListItemButton } from './components/CardListItemButton.js'
export { EthereumListItemButton } from './components/EthereumListItemButton.js'
export { SolanaListItemButton } from './components/SolanaListItemButton.js'
export { SuiListItemButton } from './components/SuiListItemButton.js'
export * from './hooks/useAccount.js'
export * from './hooks/useAccountDisconnect.js'
export {
type CombinedWallet,
useCombinedWallets,
} from './hooks/useCombinedWallets.js'
export {
useWalletManagementEvents,
type WalletManagementEventEmitter,
Expand All @@ -14,5 +23,12 @@ export * from './providers/WalletManagementProviders.js'
export type { WalletMenuOpenArgs } from './providers/WalletMenuProvider/types.js'
export * from './providers/WalletMenuProvider/WalletMenuContext.js'
export * from './types/events.js'
export { WalletTagType } from './types/walletTagType.js'
export * from './utils/getConnectorIcon.js'
export { getConnectorId } from './utils/getConnectorId.js'
export { getSortedByTags } from './utils/getSortedByTags.js'
export * from './utils/getWalletPriority.js'
export {
getConnectorTagType,
getWalletTagType,
} from './utils/walletTags.js'
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ChainType, ExtendedChain } from '@lifi/sdk'
export interface WalletMenuOpenArgs {
chain?: ExtendedChain
chainType?: ChainType
walletId?: string
}

export interface WalletMenuContext {
Expand Down
11 changes: 9 additions & 2 deletions packages/widget-provider/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.d.ts",
"exports": {
".": "./src/index.ts",
"./checkout": "./src/checkout/index.ts"
},
"sideEffects": false,
"scripts": {
"watch": "tsdown --watch",
"build": "pnpm clean && tsdown",
"build:prerelease": "node ../../scripts/prerelease.js && cpy '../../README.md' .",
"build:postrelease": "node ../../scripts/postrelease.js && rm -rf README.md",
"clean": "rm -rf dist",
"test": "vitest run",
"check:types": "tsc --noEmit",
"check:circular-deps": "madge --circular $(find ./src -name '*.ts' -o -name '*.tsx')",
"check:circular-deps-graph": "madge --circular $(find ./src -name '*.ts' -o -name '*.tsx') --image graph.svg"
Expand All @@ -37,12 +42,14 @@
"lifi"
],
"dependencies": {
"@lifi/sdk": "^4.0.0"
"@lifi/sdk": "^4.0.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"cpy-cli": "^7.0.0",
"madge": "^8.0.0",
"react": "^19.2.7",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"vitest": "^4.1.8"
}
}
34 changes: 34 additions & 0 deletions packages/widget-provider/src/checkout/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Body for `POST /v1/checkout/onramp/session`. */
export interface OnrampSessionRequest {
walletAddress: string
tokenAddress: string
chainId: number
integrator: string
amount?: string
fiatCurrency?: 'USD' | 'EUR' | 'GBP'
fiatAmount?: string
}

export interface OnrampSessionResponse {
widgetUrl: string
}

/** Body for `POST /v1/checkout/cex/session` (Mesh CEX funding). */
export interface CexSessionRequest {
walletAddress: string
tokenAddress: string
chainId: number
userId?: string
integrator?: string
amount?: string
}

export interface CexSessionResponse {
linkToken: string
}

/** Common non-2xx payload shape returned by checkout session APIs. */
export interface CheckoutSessionApiError {
error?: string
code?: string
}
19 changes: 19 additions & 0 deletions packages/widget-provider/src/checkout/contexts/CheckoutContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
'use client'
import { type Context, createContext, useContext } from 'react'
import type { CheckoutContextValue } from '../types.js'

export const CheckoutContext: Context<CheckoutContextValue | null> =
createContext<CheckoutContextValue | null>(null)

/**
* Reads the runtime checkout config (integrator, callbacks, session API
* base URL) on-ramp host components depend on. Throws when called outside
* the widget's `CheckoutProvider`.
*/
export function useCheckoutConfig(): CheckoutContextValue {
const ctx = useContext(CheckoutContext)
if (!ctx) {
throw new Error('useCheckoutConfig must be used within CheckoutProvider')
}
return ctx
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import type { OnRampSession } from '../types.js'
import { createOnRampSessionsStore } from './OnRampSessionsContext.js'

const noop = () => {}

function makeSession(): OnRampSession {
return {
open: noop,
close: noop,
cancel: noop,
isOpen: false,
isLoading: false,
error: null,
failure: null,
depositTxHash: null,
acknowledgeDepositTxHash: noop,
mountTargetId: null,
}
}

describe('createOnRampSessionsStore', () => {
it('registers a session under an id', () => {
const store = createOnRampSessionsStore()
const session = makeSession()
store.getState().register('transak', session)
expect(store.getState().sessions.transak).toBe(session)
})

it('is a no-op (same state reference) when re-registering the same session', () => {
const store = createOnRampSessionsStore()
const session = makeSession()
store.getState().register('transak', session)
const before = store.getState().sessions
store.getState().register('transak', session)
expect(store.getState().sessions).toBe(before)
})

it('replaces the slot when registering a different session for the same id', () => {
const store = createOnRampSessionsStore()
const first = makeSession()
const second = makeSession()
store.getState().register('transak', first)
store.getState().register('transak', second)
expect(store.getState().sessions.transak).toBe(second)
})

it('does not touch other slots when registering', () => {
const store = createOnRampSessionsStore()
const transak = makeSession()
const mesh = makeSession()
store.getState().register('transak', transak)
store.getState().register('mesh', mesh)
expect(store.getState().sessions.transak).toBe(transak)
expect(store.getState().sessions.mesh).toBe(mesh)
})

it('removes a registered session on unregister', () => {
const store = createOnRampSessionsStore()
store.getState().register('transak', makeSession())
store.getState().unregister('transak')
expect(store.getState().sessions.transak).toBeUndefined()
})

it('is a no-op (same state reference) when unregistering an unknown id', () => {
const store = createOnRampSessionsStore()
store.getState().register('transak', makeSession())
const before = store.getState().sessions
store.getState().unregister('mesh')
expect(store.getState().sessions).toBe(before)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
'use client'
import { type Context, createContext, useContext, useEffect } from 'react'
import { useStore } from 'zustand'
import { createStore, type StoreApi } from 'zustand/vanilla'
import type { OnRampSession } from '../types.js'

interface OnRampSessionsState {
sessions: Record<string, OnRampSession>
register: (id: string, session: OnRampSession) => void
unregister: (id: string) => void
}

export type OnRampSessionsStore = StoreApi<OnRampSessionsState>

/**
* Creates a per-instance store that holds the registered `OnRampSession`s.
* The widget owns one store per `<LifiWidgetCheckout>` mount; Hosts register
* into it and consumers subscribe to specific session slots via selectors.
*/
export function createOnRampSessionsStore(): OnRampSessionsStore {
return createStore<OnRampSessionsState>((set) => ({
sessions: {},
register: (id, session) =>
set((s) =>
s.sessions[id] === session
? s
: { sessions: { ...s.sessions, [id]: session } }
),
unregister: (id) =>
set((s) => {
if (!(id in s.sessions)) {
return s
}
const { [id]: _, ...rest } = s.sessions
return { sessions: rest }
}),
}))
}

export const OnRampSessionsContext: Context<OnRampSessionsStore | null> =
createContext<OnRampSessionsStore | null>(null)

function useOnRampSessionsStore(): OnRampSessionsStore {
const store = useContext(OnRampSessionsContext)
if (!store) {
throw new Error(
'OnRampSessionsContext.Provider is missing — wrap descendants with <OnRampProviderRegistry>'
)
}
return store
}

/**
* Host hook: registers `session` under `id` for the lifetime of the mount.
* Re-runs when the `session` reference changes so consumers always see the
* latest state. CONTRACT: `session` must be memoized by the caller (see
* `OnRampSession`) — an unmemoized object re-registers every render.
*/
export function useRegisterOnRampSession(
id: string,
session: OnRampSession
): void {
const store = useOnRampSessionsStore()
useEffect(() => {
store.getState().register(id, session)
return () => store.getState().unregister(id)
}, [id, session, store])
}

/**
* Consumer hook: subscribes to a single session slot. Re-renders only when
* `sessions[id]` changes — other providers' updates do not trigger re-renders.
*/
export function useOnRampSession(id: string): OnRampSession | null {
const store = useOnRampSessionsStore()
return useStore(store, (s) => s.sessions[id] ?? null)
}
Loading