|
| 1 | +# Widget Execution Flow |
| 2 | + |
| 3 | +This document explains how transaction execution works in the LI.FI widget. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +The execution system handles swap and bridge transactions through a coordinated flow involving: |
| 8 | +- **LI.FI SDK** - handles blockchain interactions |
| 9 | +- **Zustand Store** - manages persistent state |
| 10 | +- **React Query** - coordinates async operations with the UI |
| 11 | +- **Event Emitter** - notifies integrators of execution progress |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## Execution Flow |
| 16 | + |
| 17 | +### 1. Route Selection → Transaction Page |
| 18 | + |
| 19 | +When a user selects a route (swap/bridge), they navigate to `TransactionPage`, which displays the route details via the `Checkout` component. |
| 20 | + |
| 21 | +### 2. Starting Execution |
| 22 | + |
| 23 | +The flow begins when user clicks "Start Swapping/Bridging": |
| 24 | + |
| 25 | +```typescript |
| 26 | +// packages/widget/src/pages/TransactionPage/TransactionPage.tsx |
| 27 | + |
| 28 | +const handleExecuteRoute = () => { |
| 29 | + tokenValueBottomSheetRef.current?.close() |
| 30 | + executeRoute() // Triggers execution |
| 31 | + setFieldValue('fromAmount', '') |
| 32 | + // ... |
| 33 | +} |
| 34 | +``` |
| 35 | + |
| 36 | +Before execution starts, the widget may show confirmation dialogs for: |
| 37 | +- **High value loss** - if the output value is significantly less than input |
| 38 | +- **Low address activity** - if sending to an address with no transaction history |
| 39 | + |
| 40 | +### 3. Core Execution Hook: `useRouteExecution` |
| 41 | + |
| 42 | +The `executeRoute` function comes from the `useRouteExecution` hook which wraps the `@lifi/sdk`: |
| 43 | + |
| 44 | +```typescript |
| 45 | +// packages/widget/src/hooks/useRouteExecution.ts |
| 46 | + |
| 47 | +const executeRouteMutation = useMutation({ |
| 48 | + mutationFn: () => { |
| 49 | + if (!account.isConnected) { |
| 50 | + throw new Error('Account is not connected.') |
| 51 | + } |
| 52 | + if (!routeExecution?.route) { |
| 53 | + throw new Error('Execution route not found.') |
| 54 | + } |
| 55 | + |
| 56 | + return executeRoute(sdkClient, routeExecution.route, { |
| 57 | + updateRouteHook, // Called on every status update |
| 58 | + acceptExchangeRateUpdateHook, // For rate change confirmations |
| 59 | + infiniteApproval: false, |
| 60 | + executeInBackground, |
| 61 | + ...sdkClient.config?.executionOptions, |
| 62 | + }) |
| 63 | + }, |
| 64 | + onMutate: () => { |
| 65 | + emitter.emit(WidgetEvent.RouteExecutionStarted, routeExecution.route) |
| 66 | + }, |
| 67 | +}) |
| 68 | +``` |
| 69 | + |
| 70 | +--- |
| 71 | + |
| 72 | +## Execution Status Flow |
| 73 | + |
| 74 | +Routes transition through these statuses during execution: |
| 75 | + |
| 76 | +```typescript |
| 77 | +// packages/widget/src/stores/routes/types.ts |
| 78 | + |
| 79 | +export enum RouteExecutionStatus { |
| 80 | + Idle = 1 << 0, // Not started |
| 81 | + Pending = 1 << 1, // In progress |
| 82 | + Done = 1 << 2, // Completed successfully |
| 83 | + Failed = 1 << 3, // Failed |
| 84 | + Partial = 1 << 4, // Partially completed |
| 85 | + Refunded = 1 << 5, // Refunded |
| 86 | +} |
| 87 | +``` |
| 88 | + |
| 89 | +### Status Diagram |
| 90 | + |
| 91 | +``` |
| 92 | +┌──────┐ Start ┌─────────┐ |
| 93 | +│ Idle │ ─────────────► │ Pending │ |
| 94 | +└──────┘ └────┬────┘ |
| 95 | + │ |
| 96 | + ┌──────────────┼──────────────┐ |
| 97 | + │ │ │ |
| 98 | + ▼ ▼ ▼ |
| 99 | + ┌────────┐ ┌──────────┐ ┌────────┐ |
| 100 | + │ Done │ │ Partial │ │ Failed │ |
| 101 | + └────────┘ └──────────┘ └───┬────┘ |
| 102 | + │ |
| 103 | + │ Retry |
| 104 | + ▼ |
| 105 | + ┌─────────┐ |
| 106 | + │ Pending │ |
| 107 | + └─────────┘ |
| 108 | +``` |
| 109 | + |
| 110 | +--- |
| 111 | + |
| 112 | +## Real-time Updates |
| 113 | + |
| 114 | +### The `updateRouteHook` Callback |
| 115 | + |
| 116 | +The SDK calls `updateRouteHook` whenever the route state changes: |
| 117 | + |
| 118 | +```typescript |
| 119 | +// packages/widget/src/hooks/useRouteExecution.ts |
| 120 | + |
| 121 | +const updateRouteHook = (updatedRoute: Route) => { |
| 122 | + const routeExecution = routeExecutionStoreContext.getState().routes[updatedRoute.id] |
| 123 | + if (!routeExecution) return |
| 124 | + |
| 125 | + const clonedUpdatedRoute = structuredClone(updatedRoute) |
| 126 | + updateRoute(clonedUpdatedRoute) |
| 127 | + |
| 128 | + // Detect execution changes |
| 129 | + const execution = getUpdatedExecution(routeExecution.route, clonedUpdatedRoute) |
| 130 | + |
| 131 | + if (execution) { |
| 132 | + emitter.emit(WidgetEvent.RouteExecutionUpdated, { |
| 133 | + route: clonedUpdatedRoute, |
| 134 | + execution, |
| 135 | + }) |
| 136 | + } |
| 137 | + |
| 138 | + // Check completion status |
| 139 | + const executionCompleted = isRouteDone(clonedUpdatedRoute) |
| 140 | + const executionFailed = isRouteFailed(clonedUpdatedRoute) |
| 141 | + |
| 142 | + if (executionCompleted) { |
| 143 | + emitter.emit(WidgetEvent.RouteExecutionCompleted, clonedUpdatedRoute) |
| 144 | + } |
| 145 | + |
| 146 | + if (executionFailed && execution) { |
| 147 | + emitter.emit(WidgetEvent.RouteExecutionFailed, { |
| 148 | + route: clonedUpdatedRoute, |
| 149 | + execution, |
| 150 | + }) |
| 151 | + } |
| 152 | + |
| 153 | + // Invalidate token balance queries on completion |
| 154 | + if (executionCompleted || executionFailed) { |
| 155 | + queryClient.invalidateQueries({ queryKey: ['token-balances', ...] }) |
| 156 | + } |
| 157 | +} |
| 158 | +``` |
| 159 | + |
| 160 | +### Route Status Helpers |
| 161 | + |
| 162 | +```typescript |
| 163 | +// packages/widget/src/stores/routes/utils.ts |
| 164 | + |
| 165 | +export const isRouteDone = (route: RouteExtended) => { |
| 166 | + return route.steps.every((step) => step.execution?.status === 'DONE') |
| 167 | +} |
| 168 | + |
| 169 | +export const isRoutePartiallyDone = (route: RouteExtended) => { |
| 170 | + return route.steps.some((step) => step.execution?.substatus === 'PARTIAL') |
| 171 | +} |
| 172 | + |
| 173 | +export const isRouteRefunded = (route: RouteExtended) => { |
| 174 | + return route.steps.some((step) => step.execution?.substatus === 'REFUNDED') |
| 175 | +} |
| 176 | + |
| 177 | +export const isRouteFailed = (route: RouteExtended) => { |
| 178 | + return route.steps.some((step) => step.execution?.status === 'FAILED') |
| 179 | +} |
| 180 | + |
| 181 | +export const isRouteActive = (route?: RouteExtended) => { |
| 182 | + if (!route) return false |
| 183 | + const isDone = isRouteDone(route) |
| 184 | + const isFailed = isRouteFailed(route) |
| 185 | + const alreadyStarted = route.steps.some((step) => step.execution) |
| 186 | + return !isDone && !isFailed && alreadyStarted |
| 187 | +} |
| 188 | +``` |
| 189 | + |
| 190 | +--- |
| 191 | + |
| 192 | +## State Persistence |
| 193 | + |
| 194 | +### Zustand Store |
| 195 | + |
| 196 | +Routes are stored in a persisted Zustand store so executions survive page reloads: |
| 197 | + |
| 198 | +```typescript |
| 199 | +// packages/widget/src/stores/routes/createRouteExecutionStore.ts |
| 200 | + |
| 201 | +export const createRouteExecutionStore = ({ namePrefix }: PersistStoreProps) => |
| 202 | + create<RouteExecutionState>()( |
| 203 | + persist( |
| 204 | + (set, get) => ({ |
| 205 | + routes: {}, |
| 206 | + |
| 207 | + setExecutableRoute: (route: Route, observableRouteIds?: string[]) => { |
| 208 | + // Stores new route with Idle status |
| 209 | + // Cleans up previous idle and done routes |
| 210 | + }, |
| 211 | + |
| 212 | + updateRoute: (route: RouteExtended) => { |
| 213 | + // Updates route and automatically sets status based on step execution: |
| 214 | + // - FAILED if any step failed |
| 215 | + // - DONE if all steps done (with Partial/Refunded flags if applicable) |
| 216 | + // - PENDING if any step has execution in progress |
| 217 | + }, |
| 218 | + |
| 219 | + deleteRoute: (routeId: string) => { /* ... */ }, |
| 220 | + deleteRoutes: (type: 'completed' | 'active') => { /* ... */ }, |
| 221 | + }), |
| 222 | + { |
| 223 | + name: `${namePrefix || 'li.fi'}-widget-routes`, |
| 224 | + version: 2, |
| 225 | + // Auto-cleanup: removes failed transactions after 1 day |
| 226 | + } |
| 227 | + ) |
| 228 | + ) |
| 229 | +``` |
| 230 | + |
| 231 | +--- |
| 232 | + |
| 233 | +## Auto-Resume on Page Reload |
| 234 | + |
| 235 | +If a user refreshes the page mid-execution, the route automatically resumes: |
| 236 | + |
| 237 | +```typescript |
| 238 | +// packages/widget/src/hooks/useRouteExecution.ts |
| 239 | + |
| 240 | +useEffect(() => { |
| 241 | + const route = routeExecutionStoreContext.getState().routes[routeId]?.route |
| 242 | + |
| 243 | + // Auto-resume active routes after mount |
| 244 | + if (isRouteActive(route) && account.isConnected && !resumedAfterMount.current) { |
| 245 | + resumedAfterMount.current = true |
| 246 | + _resumeRoute() |
| 247 | + } |
| 248 | + |
| 249 | + // Move execution to background on unmount |
| 250 | + return () => { |
| 251 | + const route = routeExecutionStoreContext.getState().routes[routeId]?.route |
| 252 | + if (route && isRouteActive(route)) { |
| 253 | + updateRouteExecution(route, { executeInBackground: true }) |
| 254 | + } |
| 255 | + } |
| 256 | +}, [account.isConnected, routeExecutionStoreContext, routeId]) |
| 257 | +``` |
| 258 | + |
| 259 | +--- |
| 260 | + |
| 261 | +## Widget Events |
| 262 | + |
| 263 | +The widget emits events throughout the execution lifecycle that integrators can listen to: |
| 264 | + |
| 265 | +| Event | Payload | When | |
| 266 | +|-------|---------|------| |
| 267 | +| `RouteExecutionStarted` | `Route` | Execution begins | |
| 268 | +| `RouteExecutionUpdated` | `{ route: Route, execution: Execution }` | Each execution status change | |
| 269 | +| `RouteExecutionCompleted` | `Route` | All steps completed successfully | |
| 270 | +| `RouteExecutionFailed` | `{ route: Route, execution: Execution }` | Any step fails | |
| 271 | +| `RouteHighValueLoss` | `{ fromAmountUSD, toAmountUSD, gasCostUSD, feeCostUSD, valueLoss }` | User confirms high value loss | |
| 272 | + |
| 273 | +### Listening to Events |
| 274 | + |
| 275 | +```typescript |
| 276 | +import { useWidgetEvents, WidgetEvent } from '@lifi/widget' |
| 277 | + |
| 278 | +const widgetEvents = useWidgetEvents() |
| 279 | + |
| 280 | +useEffect(() => { |
| 281 | + const onRouteExecutionCompleted = (route) => { |
| 282 | + console.log('Route completed!', route) |
| 283 | + } |
| 284 | + |
| 285 | + widgetEvents.on(WidgetEvent.RouteExecutionCompleted, onRouteExecutionCompleted) |
| 286 | + |
| 287 | + return () => { |
| 288 | + widgetEvents.off(WidgetEvent.RouteExecutionCompleted, onRouteExecutionCompleted) |
| 289 | + } |
| 290 | +}, [widgetEvents]) |
| 291 | +``` |
| 292 | + |
| 293 | +--- |
| 294 | + |
| 295 | +## UI Components |
| 296 | + |
| 297 | +### `StepExecution` |
| 298 | + |
| 299 | +Displays the current execution status with a progress indicator and transaction links: |
| 300 | + |
| 301 | +```typescript |
| 302 | +// packages/widget/src/components/Checkout/StepExecution.tsx |
| 303 | + |
| 304 | +export const StepExecution: React.FC<{ step: LiFiStepExtended }> = ({ step }) => { |
| 305 | + const { title } = useExecutionMessage(step) |
| 306 | + const { getTransactionLink } = useExplorer() |
| 307 | + |
| 308 | + if (!step.execution) return null |
| 309 | + |
| 310 | + // Renders: |
| 311 | + // - CircularProgress indicator |
| 312 | + // - Status message (e.g., "Waiting for signature", "Swap pending") |
| 313 | + // - Transaction links when available |
| 314 | +} |
| 315 | +``` |
| 316 | + |
| 317 | +### Execution Messages |
| 318 | + |
| 319 | +Status messages are mapped based on transaction type and execution status: |
| 320 | + |
| 321 | +```typescript |
| 322 | +// packages/widget/src/hooks/useExecutionMessage.ts |
| 323 | + |
| 324 | +const processStatusMessages = { |
| 325 | + TOKEN_ALLOWANCE: { |
| 326 | + STARTED: 'Approving token allowance...', |
| 327 | + ACTION_REQUIRED: 'Please approve {tokenSymbol} in your wallet', |
| 328 | + PENDING: 'Waiting for approval confirmation...', |
| 329 | + DONE: '{tokenSymbol} approved', |
| 330 | + }, |
| 331 | + SWAP: { |
| 332 | + STARTED: 'Preparing swap...', |
| 333 | + ACTION_REQUIRED: 'Please confirm the swap in your wallet', |
| 334 | + PENDING: 'Swap pending...', |
| 335 | + DONE: 'Swap completed', |
| 336 | + }, |
| 337 | + CROSS_CHAIN: { |
| 338 | + STARTED: 'Preparing bridge transaction...', |
| 339 | + ACTION_REQUIRED: 'Please confirm the bridge in your wallet', |
| 340 | + PENDING: 'Bridge transaction pending...', |
| 341 | + DONE: 'Bridge completed', |
| 342 | + }, |
| 343 | + // ... |
| 344 | +} |
| 345 | +``` |
| 346 | + |
| 347 | +--- |
| 348 | + |
| 349 | +## Architecture Summary |
| 350 | + |
| 351 | +``` |
| 352 | +┌─────────────────────────────────────────────────────────────────┐ |
| 353 | +│ TransactionPage │ |
| 354 | +│ ┌───────────────────────────────────────────────────────────┐ │ |
| 355 | +│ │ Checkout │ │ |
| 356 | +│ │ ┌─────────────────┐ ┌────────────────────────────────┐ │ │ |
| 357 | +│ │ │ StepExecution │ │ Token Details & Route Info │ │ │ |
| 358 | +│ │ │ (status, tx) │ │ │ │ │ |
| 359 | +│ │ └─────────────────┘ └────────────────────────────────┘ │ │ |
| 360 | +│ └───────────────────────────────────────────────────────────┘ │ |
| 361 | +└─────────────────────────────────────────────────────────────────┘ |
| 362 | + │ |
| 363 | + │ executeRoute() |
| 364 | + ▼ |
| 365 | +┌─────────────────────────────────────────────────────────────────┐ |
| 366 | +│ useRouteExecution │ |
| 367 | +│ - Wraps @lifi/sdk executeRoute/resumeRoute │ |
| 368 | +│ - Manages mutations via React Query │ |
| 369 | +│ - Emits widget events │ |
| 370 | +│ - Handles auto-resume on mount │ |
| 371 | +└─────────────────────────────────────────────────────────────────┘ |
| 372 | + │ |
| 373 | + │ updateRouteHook() |
| 374 | + ▼ |
| 375 | +┌─────────────────────────────────────────────────────────────────┐ |
| 376 | +│ RouteExecutionStore (Zustand) │ |
| 377 | +│ - Persists routes to localStorage │ |
| 378 | +│ - Tracks execution status │ |
| 379 | +│ - Auto-cleans old failed routes │ |
| 380 | +└─────────────────────────────────────────────────────────────────┘ |
| 381 | + │ |
| 382 | + ▼ |
| 383 | +┌─────────────────────────────────────────────────────────────────┐ |
| 384 | +│ @lifi/sdk │ |
| 385 | +│ - Handles wallet interactions │ |
| 386 | +│ - Manages transaction signing │ |
| 387 | +│ - Tracks cross-chain status │ |
| 388 | +└─────────────────────────────────────────────────────────────────┘ |
| 389 | +``` |
0 commit comments