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
6 changes: 6 additions & 0 deletions .changeset/checkout-onramp-packages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@lifi/widget-provider-transak": minor
"@lifi/widget-provider-mesh": minor
---

Add the Transak and Mesh on-ramp integration packages (host components and balance/session hooks) for the checkout flow.
54 changes: 54 additions & 0 deletions packages/widget-provider-mesh/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"name": "@lifi/widget-provider-mesh",
"version": "4.0.0-beta.17",
"description": "LI.FI Widget on-ramp provider for Mesh (CEX transfers). Plug into LifiWidgetCheckout via the onRampProviders prop.",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.d.ts",
"sideEffects": false,
"scripts": {
"watch": "tsdown --watch",
"build": "pnpm clean && tsdown",
"build:prerelease": "node ../../scripts/prerelease.js && cpy '../widget-checkout/README.md' .",
"build:postrelease": "node ../../scripts/postrelease.js && rm -rf README.md",
"release:build": "pnpm build",
"clean": "rm -rf dist",
"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"
},
"author": "Eugene Chybisov <eugene@li.finance>",
"homepage": "https://github.com/lifinance/widget",
"repository": {
"type": "git",
"url": "https://github.com/lifinance/widget.git",
"directory": "packages/widget-provider-mesh"
},
"bugs": {
"url": "https://github.com/lifinance/widget/issues"
},
"license": "Apache-2.0",
"keywords": [
"widget",
"lifi-widget",
"onramp",
"mesh",
"cex",
"checkout",
"lifi"
],
"dependencies": {
"@meshconnect/web-link-sdk": "^3.9.1"
},
"peerDependencies": {
"@lifi/widget-provider": "workspace:*",
"react": ">=18"
},
"devDependencies": {
"@lifi/widget-provider": "workspace:*",
"cpy-cli": "^7.0.0",
"madge": "^8.0.0",
"react": "^19.2.5",
"typescript": "^6.0.2"
}
}
336 changes: 336 additions & 0 deletions packages/widget-provider-mesh/src/MeshHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
'use client'
import {
type CexSessionRequest,
type CexSessionResponse,
type OnRampError,
type OnRampFailure,
type OnRampHostWidgetConfig,
type OnRampOpenArgs,
type OnRampSession,
postCheckoutSession,
useCheckoutConfig,
useCheckoutUserId,
useRegisterOnRampSession,
} from '@lifi/widget-provider/checkout'
import type {
LinkEventType,
LinkPayload,
TransferFinishedPayload,
} from '@meshconnect/web-link-sdk'
import { createLink } from '@meshconnect/web-link-sdk'
import { type FC, useCallback, useMemo, useRef, useState } from 'react'

export interface MeshHostProps {
widgetConfig: OnRampHostWidgetConfig
}

/**
* Logic-only host: holds Mesh session state, runs the Mesh link SDK (which
* provides its own overlay UI), and registers the session into the
* widget's `OnRampSessionsContext`. `mountTargetId` is `null` because
* Mesh manages its own modal — no hosted `<div>` from the widget is
* required.
*/
export const MeshHost: FC<MeshHostProps> = ({ widgetConfig }) => {
const checkoutUserId = useCheckoutUserId()
const { integrator, onError, onSuccess, apiUrl } = useCheckoutConfig()
const [isOpen, setIsOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<OnRampError | null>(null)
const [failure, setFailure] = useState<OnRampFailure | null>(null)
const [depositTxHash, setDepositTxHash] = useState<string | null>(null)
// Track in-Mesh errors as they stream through onEvent so we can classify
// the terminal close from onExit. Refs (not state) because Mesh's callbacks
// capture the value at link-creation time.
const lastEventErrorRef = useRef<{
kind: 'connection' | 'withdrawal'
message: string
} | null>(null)
const transferSucceededRef = useRef(false)

const lastOpenArgsRef = useRef<OnRampOpenArgs | null>(null)

const close = useCallback(() => {
setIsOpen(false)
setIsLoading(false)
setError(null)
}, [])

const acknowledgeDepositTxHash = useCallback(() => {
setDepositTxHash(null)
}, [])

const openDepositFlow = useCallback(
async (args: OnRampOpenArgs) => {
lastOpenArgsRef.current = args
setError(null)
setFailure(null)
setDepositTxHash(null)
lastEventErrorRef.current = null
transferSucceededRef.current = false
setIsLoading(true)

const apiKey = widgetConfig.apiKey?.trim()
if (!apiUrl) {
setError({ code: 'MISSING_API_URL' })
onError?.({
code: 'MISSING_API_URL',
message: 'CEX deposit is not configured: set sdkConfig.apiUrl.',
provider: 'mesh',
})
setIsLoading(false)
return
}
if (!apiKey) {
setError({ code: 'MISSING_API_KEY' })
onError?.({
code: 'MISSING_API_KEY',
message:
'CEX deposit is not configured: set widget apiKey to call checkout sessions.',
provider: 'mesh',
})
setIsLoading(false)
return
}

const body: CexSessionRequest = {
walletAddress: args.depositAddress,
tokenAddress: args.fromTokenAddress,
chainId: args.fromChainId,
userId: checkoutUserId,
integrator,
amount: args.amount,
}

try {
const res = await postCheckoutSession<
CexSessionRequest,
CexSessionResponse
>({
baseUrl: apiUrl,
integrator,
endpointPath: '/v1/checkout/cex/session',
apiKey,
body,
})
if (!res.ok) {
const errObj = res.apiError
if (errObj?.error) {
setError({ message: errObj.error })
onError?.({
code: errObj.code ?? 'CEX_SESSION_FAILED',
message: errObj.error,
provider: 'mesh',
})
} else {
setError({
code: 'SESSION_HTTP',
params: { status: res.status },
})
onError?.({
code: 'CEX_SESSION_FAILED',
message: `Could not start Mesh session (${res.status}). Try again later.`,
provider: 'mesh',
})
}
setIsLoading(false)
return
}

if (!res.data.linkToken) {
setError({ code: 'INVALID_RESPONSE' })
onError?.({
code: 'ONRAMP_INVALID_RESPONSE',
message: 'Invalid response from onramp server (missing linkToken).',
provider: 'mesh',
})
setIsLoading(false)
return
}

const link = createLink({
onIntegrationConnected: (_payload: LinkPayload) => {
// Exchange account linked — no action needed for transfer flow
},
onTransferFinished: (payload: TransferFinishedPayload) => {
transferSucceededRef.current = true
// Mesh's `txId` is an internal identifier that won't resolve via
// LI.FI's status endpoint — only forward a real on-chain hash.
const onChainHash = payload.txHash?.trim()
if (onChainHash) {
setDepositTxHash(onChainHash)
}
if (onSuccess) {
onSuccess({
provider: 'mesh',
transactionHash: onChainHash ?? payload.txId,
amount: String(payload.amount),
token: payload.symbol,
chainId: args.fromChainId,
})
}
// Leave the modal-state alone here; onExit fires next and is the
// single terminal cleanup point.
},
onEvent: (event: LinkEventType) => {
switch (event.type) {
case 'transferExecutionError':
lastEventErrorRef.current = {
kind: 'withdrawal',
message: event.payload.errorMessage,
}
break
case 'integrationConnectionError':
lastEventErrorRef.current = {
kind: 'connection',
message: event.payload.errorMessage,
}
break
default:
break
}
},
onExit: (exitError?: string) => {
// Reset transient open/loading flags but keep failure/depositTxHash
// so the consumer can render the post-Mesh state.
setIsOpen(false)
setIsLoading(false)

if (transferSucceededRef.current) {
return
}

const tracked = lastEventErrorRef.current
if (tracked) {
setFailure({
kind: tracked.kind,
message: tracked.message || undefined,
reportCode:
tracked.kind === 'withdrawal'
? 'MESH_WITHDRAWAL_FAILED'
: 'MESH_CONNECTION_FAILED',
retry: () => {
setFailure(null)
if (lastOpenArgsRef.current) {
void openDepositFlow(lastOpenArgsRef.current)
}
},
})
onError?.({
code:
tracked.kind === 'withdrawal'
? 'MESH_WITHDRAWAL_FAILED'
: 'MESH_CONNECTION_FAILED',
message: tracked.message,
provider: 'mesh',
})
return
}

if (exitError) {
setFailure({
kind: 'connection',
message: exitError,
reportCode: 'MESH_EXIT_ERROR',
retry: () => {
setFailure(null)
if (lastOpenArgsRef.current) {
void openDepositFlow(lastOpenArgsRef.current)
}
},
})
onError?.({
code: 'MESH_EXIT_ERROR',
message: exitError,
provider: 'mesh',
})
return
}

// No success, no tracked error, no exit error: the user dismissed
// the modal before depositing. Surface a `cancelled` failure (not
// an error) so the checkout returns to amount entry rather than
// sitting on the watching screen. No `onError` — cancelling isn't
// a failure to report.
setFailure({
kind: 'cancelled',
retry: () => {
setFailure(null)
if (lastOpenArgsRef.current) {
void openDepositFlow(lastOpenArgsRef.current)
}
},
})
},
})

setIsLoading(false)
setIsOpen(true)
link.openLink(res.data.linkToken)
} catch (e) {
const message =
e instanceof Error
? e.message
: 'Network error starting Mesh session.'
setError(
e instanceof Error
? { message: e.message }
: { code: 'NETWORK_ERROR' }
)
onError?.({ code: 'ONRAMP_NETWORK_ERROR', message, provider: 'mesh' })
setIsLoading(false)
}
},
[
apiUrl,
checkoutUserId,
integrator,
onError,
onSuccess,
widgetConfig.apiKey,
]
)

const cancel = useCallback(() => {
setIsOpen(false)
setIsLoading(false)
setFailure({
kind: 'cancelled',
retry: () => {
setFailure(null)
if (lastOpenArgsRef.current) {
void openDepositFlow(lastOpenArgsRef.current)
}
},
})
}, [openDepositFlow])

const session = useMemo<OnRampSession>(
() => ({
open: openDepositFlow,
close,
cancel,
isOpen,
isLoading,
error,
failure,
depositTxHash,
acknowledgeDepositTxHash,
mountTargetId: null,
}),
[
acknowledgeDepositTxHash,
cancel,
close,
depositTxHash,
error,
failure,
isLoading,
isOpen,
openDepositFlow,
]
)

useRegisterOnRampSession('mesh', session)
return null
}
Loading