From 8ecbdde35c80f7c363f1511fa8463155437b9612 Mon Sep 17 00:00:00 2001 From: Philipp Trentmann Date: Tue, 23 Jun 2026 10:07:26 +0200 Subject: [PATCH] refactor(LIVE-29443): extract swap transaction status common logic --- .changeset/mobile-swap-status-parity.md | 5 + libs/ledger-live-common/.unimportedrc.json | 16 +- libs/ledger-live-common/jest.polyfills.js | 20 + libs/ledger-live-common/package.json | 88 ++++ .../api}/fetchSwapStatus.test.ts | 4 +- .../api}/fetchSwapStatus.ts | 4 +- .../display/display.test.ts | 344 +++++++++++++ .../swapTransactionStatus/display/index.ts | 460 ++++++++++++++++++ .../history}/fromSwapOperation.test.ts | 2 +- .../history}/fromSwapOperation.ts | 4 +- ...seSwapTransactionStatusController.test.tsx | 177 +++++++ .../useSwapTransactionStatusController.ts | 228 +++++++++ .../exchange/swapTransactionStatus/index.ts | 3 + .../params}/parseParams.test.ts | 0 .../params}/parseParams.ts | 6 +- .../status}/statusController.test.ts | 2 +- .../status}/statusController.ts | 2 +- .../status}/statusValues.ts | 0 .../transactionStatus.ts | 6 + .../types.ts | 0 .../src/exchange/transactionStatus/index.ts | 6 - .../getTransactionStatus.test.ts | 12 +- .../transactionStatus/getTransactionStatus.ts | 4 +- .../src/wallet-api/react.test.ts | 10 +- 24 files changed, 1358 insertions(+), 45 deletions(-) create mode 100644 .changeset/mobile-swap-status-parity.md rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/api}/fetchSwapStatus.test.ts (94%) rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/api}/fetchSwapStatus.ts (59%) create mode 100644 libs/ledger-live-common/src/exchange/swapTransactionStatus/display/display.test.ts create mode 100644 libs/ledger-live-common/src/exchange/swapTransactionStatus/display/index.ts rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/history}/fromSwapOperation.test.ts (94%) rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/history}/fromSwapOperation.ts (63%) create mode 100644 libs/ledger-live-common/src/exchange/swapTransactionStatus/hooks/useSwapTransactionStatusController.test.tsx create mode 100644 libs/ledger-live-common/src/exchange/swapTransactionStatus/hooks/useSwapTransactionStatusController.ts create mode 100644 libs/ledger-live-common/src/exchange/swapTransactionStatus/index.ts rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/params}/parseParams.test.ts (100%) rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/params}/parseParams.ts (91%) rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/status}/statusController.test.ts (99%) rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/status}/statusController.ts (98%) rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus/status}/statusValues.ts (100%) create mode 100644 libs/ledger-live-common/src/exchange/swapTransactionStatus/transactionStatus.ts rename libs/ledger-live-common/src/exchange/{transactionStatus => swapTransactionStatus}/types.ts (100%) delete mode 100644 libs/ledger-live-common/src/exchange/transactionStatus/index.ts diff --git a/.changeset/mobile-swap-status-parity.md b/.changeset/mobile-swap-status-parity.md new file mode 100644 index 000000000000..52f29f87fa4c --- /dev/null +++ b/.changeset/mobile-swap-status-parity.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/live-common": minor +--- + +Extract shared swap transaction status logic for LIVE-29443 while preserving the existing transaction status import paths. diff --git a/libs/ledger-live-common/.unimportedrc.json b/libs/ledger-live-common/.unimportedrc.json index b9b54750ffc3..a7fafd7eb916 100644 --- a/libs/ledger-live-common/.unimportedrc.json +++ b/libs/ledger-live-common/.unimportedrc.json @@ -93,6 +93,8 @@ "src/exchange/swap/updateAccountSwapStatus.ts", "src/exchange/swap/utils/index.ts", "src/exchange/swap/webApp/index.ts", + "src/exchange/swapTransactionStatus/index.ts", + "src/exchange/swapTransactionStatus/transactionStatus.ts", "src/explorer.ts", "src/explorers.ts", "src/families/algorand/logic.ts", @@ -364,12 +366,7 @@ "src/domain/computeEarnUiVersion.ts", "src/domain/earnUiVersion.ts" ], - "extensions": [ - ".ts", - ".js", - ".jsx", - ".tsx" - ], + "extensions": [".ts", ".js", ".jsx", ".tsx"], "ignorePatterns": [ "**/node_modules/**", "**/__tests__/**", @@ -916,12 +913,7 @@ "@react-native/virtualized-lists", "@solana/buffer-layout", "@solana/codecs-numbers", - [ - "@solana/codecs-numbers", - [ - "node_modules/@solana/web3.js/lib/index.cjs.js" - ] - ], + ["@solana/codecs-numbers", ["node_modules/@solana/web3.js/lib/index.cjs.js"]], "abort-controller/dist/abort-controller", "anser", "base64-js", diff --git a/libs/ledger-live-common/jest.polyfills.js b/libs/ledger-live-common/jest.polyfills.js index 30671b1245fc..008fabbd2a7b 100644 --- a/libs/ledger-live-common/jest.polyfills.js +++ b/libs/ledger-live-common/jest.polyfills.js @@ -65,6 +65,26 @@ Object.defineProperties(global, { clearInterval: { value: timer => clearInterval(unwrapTimer(timer)) }, }); +// React DOM's scheduler otherwise opens a MessagePort in jsdom-focused tests. +Object.defineProperties(global, { + setImmediate: { + value: + typeof global.setImmediate === "function" + ? global.setImmediate + : (callback, ...args) => global.setTimeout(() => callback(...args), 0), + writable: true, + configurable: true, + }, + clearImmediate: { + value: + typeof global.clearImmediate === "function" + ? global.clearImmediate + : timer => global.clearTimeout(timer), + writable: true, + configurable: true, + }, +}); + // Polyfill for 'self' (needed for tronweb in Node.js environment) if (typeof global.self === "undefined") { global.self = global; diff --git a/libs/ledger-live-common/package.json b/libs/ledger-live-common/package.json index 541509c431bc..731f404094c4 100644 --- a/libs/ledger-live-common/package.json +++ b/libs/ledger-live-common/package.json @@ -19,6 +19,39 @@ "*.json": [ "*.json" ], + "exchange/transactionStatus": [ + "lib-es/exchange/swapTransactionStatus/transactionStatus" + ], + "exchange/transactionStatus/index": [ + "lib-es/exchange/swapTransactionStatus/transactionStatus" + ], + "exchange/transactionStatus/fetchSwapStatus": [ + "lib-es/exchange/swapTransactionStatus/api/fetchSwapStatus" + ], + "exchange/transactionStatus/fromSwapOperation": [ + "lib-es/exchange/swapTransactionStatus/history/fromSwapOperation" + ], + "exchange/transactionStatus/parseParams": [ + "lib-es/exchange/swapTransactionStatus/params/parseParams" + ], + "exchange/transactionStatus/statusController": [ + "lib-es/exchange/swapTransactionStatus/status/statusController" + ], + "exchange/transactionStatus/statusValues": [ + "lib-es/exchange/swapTransactionStatus/status/statusValues" + ], + "exchange/transactionStatus/types": [ + "lib-es/exchange/swapTransactionStatus/types" + ], + "exchange/swapTransactionStatus": [ + "lib-es/exchange/swapTransactionStatus/index" + ], + "exchange/swapTransactionStatus/index": [ + "lib-es/exchange/swapTransactionStatus/index" + ], + "exchange/swapTransactionStatus/transactionStatus": [ + "lib-es/exchange/swapTransactionStatus/transactionStatus" + ], "*": [ "lib-es/*" ], @@ -47,6 +80,61 @@ ], "default": "./lib-es/*.js" }, + "./exchange/transactionStatus": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/transactionStatus.ts", + "types": "./lib-es/exchange/swapTransactionStatus/transactionStatus.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/transactionStatus.js" + }, + "./exchange/transactionStatus/index": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/transactionStatus.ts", + "types": "./lib-es/exchange/swapTransactionStatus/transactionStatus.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/transactionStatus.js" + }, + "./exchange/transactionStatus/fetchSwapStatus": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/api/fetchSwapStatus.ts", + "types": "./lib-es/exchange/swapTransactionStatus/api/fetchSwapStatus.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/api/fetchSwapStatus.js" + }, + "./exchange/transactionStatus/fromSwapOperation": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/history/fromSwapOperation.ts", + "types": "./lib-es/exchange/swapTransactionStatus/history/fromSwapOperation.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/history/fromSwapOperation.js" + }, + "./exchange/transactionStatus/parseParams": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/params/parseParams.ts", + "types": "./lib-es/exchange/swapTransactionStatus/params/parseParams.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/params/parseParams.js" + }, + "./exchange/transactionStatus/statusController": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/status/statusController.ts", + "types": "./lib-es/exchange/swapTransactionStatus/status/statusController.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/status/statusController.js" + }, + "./exchange/transactionStatus/statusValues": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/status/statusValues.ts", + "types": "./lib-es/exchange/swapTransactionStatus/status/statusValues.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/status/statusValues.js" + }, + "./exchange/transactionStatus/types": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/types.ts", + "types": "./lib-es/exchange/swapTransactionStatus/types.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/types.js" + }, + "./exchange/swapTransactionStatus": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/index.ts", + "types": "./lib-es/exchange/swapTransactionStatus/index.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/index.js" + }, + "./exchange/swapTransactionStatus/index": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/index.ts", + "types": "./lib-es/exchange/swapTransactionStatus/index.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/index.js" + }, + "./exchange/swapTransactionStatus/transactionStatus": { + "@ledgerhq/source": "./src/exchange/swapTransactionStatus/transactionStatus.ts", + "types": "./lib-es/exchange/swapTransactionStatus/transactionStatus.d.ts", + "default": "./lib-es/exchange/swapTransactionStatus/transactionStatus.js" + }, ".": { "default": "./lib-es/index.js" }, diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/fetchSwapStatus.test.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/api/fetchSwapStatus.test.ts similarity index 94% rename from libs/ledger-live-common/src/exchange/transactionStatus/fetchSwapStatus.test.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/api/fetchSwapStatus.test.ts index 9a73a9e2e797..88c3cdbae6d8 100644 --- a/libs/ledger-live-common/src/exchange/transactionStatus/fetchSwapStatus.test.ts +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/api/fetchSwapStatus.test.ts @@ -1,7 +1,7 @@ -import { getMultipleStatus } from "../swap/getStatus"; +import { getMultipleStatus } from "../../swap/getStatus"; import { fetchTransactionSwapStatus } from "./fetchSwapStatus"; -jest.mock("../swap/getStatus", () => ({ +jest.mock("../../swap/getStatus", () => ({ getMultipleStatus: jest.fn(), })); diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/fetchSwapStatus.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/api/fetchSwapStatus.ts similarity index 59% rename from libs/ledger-live-common/src/exchange/transactionStatus/fetchSwapStatus.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/api/fetchSwapStatus.ts index f3cc99db4678..a47d806b0605 100644 --- a/libs/ledger-live-common/src/exchange/transactionStatus/fetchSwapStatus.ts +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/api/fetchSwapStatus.ts @@ -1,5 +1,5 @@ -import { getMultipleStatus } from "../swap/getStatus"; -import type { SwapStatus, SwapStatusRequest } from "../swap/types"; +import { getMultipleStatus } from "../../swap/getStatus"; +import type { SwapStatus, SwapStatusRequest } from "../../swap/types"; export async function fetchTransactionSwapStatus( request: SwapStatusRequest, diff --git a/libs/ledger-live-common/src/exchange/swapTransactionStatus/display/display.test.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/display/display.test.ts new file mode 100644 index 000000000000..edc708898bfe --- /dev/null +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/display/display.test.ts @@ -0,0 +1,344 @@ +import { genAccount, genTokenAccount } from "@ledgerhq/ledger-wallet-framework/mocks/account"; +import { getCryptoCurrencyById } from "../../../currencies/index"; +import type { TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import { + formatSwapTransactionStatusAmount, + formatSwapTransactionStatusCreatedAt, + formatSwapTransactionStatusFeesAmount, + getSwapTransactionStatusDisplayStatus, + getSwapTransactionStatusDetailsViewModel, + getSwapTransactionStatusExplorerUrl, + getSwapTransactionStatusLabelKey, + getSwapTransactionStatusReceiveDisplayStatus, + getSwapTransactionStatusSectionItems, + getSwapTransactionStatusTitleKey, + getSwapTransactionStatusVisualTokens, + resolveSwapTransactionStatusAccountLike, + truncateSwapTransactionStatusIdentifier, +} from "./index"; + +jest.mock("../../../explorers", () => ({ + getDefaultExplorerView: jest.fn(currency => ({ currencyId: currency.id })), + getTransactionExplorer: jest.fn((_explorerView, operationHash) => { + return `https://explorer.test/tx/${operationHash}`; + }), +})); + +const bitcoin = getCryptoCurrencyById("bitcoin"); +const ethereum = getCryptoCurrencyById("ethereum"); +const polygon = getCryptoCurrencyById("polygon"); +const ton = getCryptoCurrencyById("ton"); +const usdcPolygon: TokenCurrency = { + type: "TokenCurrency", + id: "polygon/erc20/usd_coin", + parentCurrencyId: polygon.id, + tokenType: "erc20", + contractAddress: "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + ticker: "USDC", + name: "USD Coin", + units: [ + { + name: "USD Coin", + code: "USDC", + magnitude: 6, + }, + ], +}; + +function normalizeSpaces(value: string | undefined): string | undefined { + return value?.replace(/\u00a0/g, " "); +} + +describe("resolveSwapTransactionStatusAccountLike", () => { + it("should resolve a parent account directly", () => { + const account = genAccount("bitcoin-account", { currency: bitcoin }); + + expect(resolveSwapTransactionStatusAccountLike([account], account.id)).toEqual({ account }); + }); + + it("should resolve token accounts with their parent account", () => { + const parentAccount = genAccount("polygon-account", { currency: polygon }); + const tokenAccount = genTokenAccount(0, parentAccount, usdcPolygon); + + expect( + resolveSwapTransactionStatusAccountLike([parentAccount, tokenAccount], tokenAccount.id), + ).toEqual({ + account: tokenAccount, + parentAccount, + }); + }); + + it("should return undefined when the account cannot be resolved", () => { + expect(resolveSwapTransactionStatusAccountLike([], "missing-account")).toBeUndefined(); + expect(resolveSwapTransactionStatusAccountLike([], undefined)).toBeUndefined(); + }); +}); + +describe("amount formatting", () => { + it("should format raw atomic amounts with at most eight displayed decimals", () => { + expect( + normalizeSpaces(formatSwapTransactionStatusAmount(ethereum, "123456789123456789", "en-US")), + ).toBe("0.12345678 ETH"); + }); + + it("should format fees from the resolved main account currency", () => { + const account = genAccount("bitcoin-account", { currency: bitcoin }); + + expect( + normalizeSpaces(formatSwapTransactionStatusFeesAmount({ account }, "123456789", "en-US")), + ).toBe("1.23456789 BTC"); + }); + + it("should return undefined when amount inputs are missing", () => { + expect(formatSwapTransactionStatusAmount(undefined, "123", "en-US")).toBeUndefined(); + expect(formatSwapTransactionStatusAmount(bitcoin, undefined, "en-US")).toBeUndefined(); + expect(formatSwapTransactionStatusFeesAmount(undefined, "123", "en-US")).toBeUndefined(); + }); +}); + +describe("date and identifier formatting", () => { + it("should format creation dates with the requested locale", () => { + const createdAt = new Date(2024, 0, 2, 15, 4).getTime(); + + expect(formatSwapTransactionStatusCreatedAt(createdAt, "en-US")).toBe( + new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(createdAt)), + ); + }); + + it("should truncate long identifiers without changing short identifiers", () => { + expect(truncateSwapTransactionStatusIdentifier("1234567890abcdef")).toBe("12345678…abcdef"); + expect(truncateSwapTransactionStatusIdentifier("swap-1")).toBe("swap-1"); + }); +}); + +describe("getSwapTransactionStatusDetailsViewModel", () => { + it("should resolve provider display metadata and truncate the swap id", () => { + expect( + getSwapTransactionStatusDetailsViewModel({ + provider: "changelly", + providerData: { + displayName: "Changelly", + mainUrl: "https://changelly.test", + needsKYC: false, + supportUrl: "https://changelly.test/support", + termsOfUseUrl: "https://changelly.test/terms", + type: "CEX", + useInExchangeApp: true, + }, + swapId: "1234567890abcdef", + }), + ).toEqual({ + providerName: "Changelly", + providerMainUrl: "https://changelly.test", + shouldShowProvider: true, + truncatedSwapId: "12345678…abcdef", + }); + }); + + it("should hide the provider row when provider metadata is missing", () => { + expect( + getSwapTransactionStatusDetailsViewModel({ + provider: undefined, + providerData: undefined, + swapId: "swap-1", + }), + ).toEqual({ + providerName: undefined, + providerMainUrl: undefined, + shouldShowProvider: false, + truncatedSwapId: "swap-1", + }); + }); +}); + +describe("getSwapTransactionStatusExplorerUrl", () => { + it.each([ + ["lifi", "https://scan.li.fi/tx/hash-1"], + ["thorswap", "https://runescan.io/tx/hash-1"], + ["nearintents", "https://track.swapkit.dev/tx/hash-1"], + ])("should return the provider operation explorer URL for %s", (provider, expectedUrl) => { + expect( + getSwapTransactionStatusExplorerUrl({ + provider, + swapId: "swap-1", + operationHash: "hash-1", + fromCurrency: bitcoin, + }), + ).toBe(expectedUrl); + }); + + it.each(["swapsxyz", "moonpay_trade"])( + "should return the Swaps.xyz scan URL for %s without requiring an operation hash", + provider => { + expect( + getSwapTransactionStatusExplorerUrl({ + provider, + swapId: "swap-1", + operationHash: undefined, + fromCurrency: undefined, + }), + ).toBe("https://scan.swaps.xyz/transactions/swap-1"); + }, + ); + + it("should return the OKX explorer URL for the parent currency of a token swap", () => { + expect( + getSwapTransactionStatusExplorerUrl({ + provider: "okx", + swapId: "swap-1", + operationHash: "hash-1", + fromCurrency: usdcPolygon, + }), + ).toBe("https://web3.okx.com/fi/explorer/polygon/tx/hash-1"); + }); + + it("should return undefined for OKX when the currency is missing", () => { + expect( + getSwapTransactionStatusExplorerUrl({ + provider: "okx", + swapId: "swap-1", + operationHash: "hash-1", + fromCurrency: undefined, + }), + ).toBeUndefined(); + }); + + it("should fall back to the currency transaction explorer for unknown providers", () => { + expect( + getSwapTransactionStatusExplorerUrl({ + provider: "custom-provider", + swapId: "swap-1", + operationHash: "hash-1", + fromCurrency: bitcoin, + }), + ).toBe("https://explorer.test/tx/hash-1"); + }); + + it("should use the family transaction explorer override for unknown TON providers", () => { + expect( + getSwapTransactionStatusExplorerUrl({ + provider: "changelly_v2", + swapId: "swap-1", + operationHash: "ton-hash-1", + fromCurrency: ton, + getTransactionExplorer: (_explorerView, operation) => + `https://tonviewer.com/transaction/by-msg-hash/${operation.hash}`, + }), + ).toBe("https://tonviewer.com/transaction/by-msg-hash/ton-hash-1"); + }); + + it("should not build provider hash URLs when the operation hash is missing", () => { + expect( + getSwapTransactionStatusExplorerUrl({ + provider: "lifi", + swapId: "swap-1", + operationHash: undefined, + fromCurrency: undefined, + }), + ).toBeUndefined(); + }); + + it("should return undefined when provider data is missing", () => { + expect( + getSwapTransactionStatusExplorerUrl({ + provider: undefined, + swapId: "swap-1", + operationHash: "hash-1", + fromCurrency: bitcoin, + }), + ).toBeUndefined(); + }); +}); + +describe("status display helpers", () => { + it("should normalize receive unknown to finished when the swap or send leg is finished", () => { + expect(getSwapTransactionStatusReceiveDisplayStatus("unknown", "finished", "pending")).toBe( + "finished", + ); + expect(getSwapTransactionStatusReceiveDisplayStatus("unknown", "pending", "finished")).toBe( + "finished", + ); + }); + + it("should keep receive unknown when the swap and send leg are not finished", () => { + expect(getSwapTransactionStatusReceiveDisplayStatus("unknown", "pending", "pending")).toBe( + "unknown", + ); + }); + + it.each([ + ["finished", "success"], + ["expired", "error"], + ["refunded", "error"], + ["unknown", "unknown"], + ["pending", "pending"], + ] as const)("should map %s to %s display status", (status, displayStatus) => { + expect(getSwapTransactionStatusDisplayStatus(status)).toBe(displayStatus); + }); + + it("should build status title keys from the given translation prefix", () => { + expect( + getSwapTransactionStatusTitleKey("send", "finished", "swap2.modals.transactionStatus"), + ).toBe("swap2.modals.transactionStatus.sections.status.sendCompleted"); + expect( + getSwapTransactionStatusTitleKey( + "receive", + "pending", + "transfer.swap2.modals.transactionStatus", + ), + ).toBe("transfer.swap2.modals.transactionStatus.sections.status.receivePending"); + }); + + it("should build status label keys and use cancelled for refunded receive statuses", () => { + expect( + getSwapTransactionStatusLabelKey("send", "refunded", "swap2.modals.transactionStatus"), + ).toBe("swap2.modals.transactionStatus.statusLabels.refunded"); + expect( + getSwapTransactionStatusLabelKey( + "receive", + "refunded", + "transfer.swap2.modals.transactionStatus", + ), + ).toBe("transfer.swap2.modals.transactionStatus.statusLabels.cancelled"); + }); + + it("should build status section items from statuses, tickers, and translation prefix", () => { + expect( + getSwapTransactionStatusSectionItems({ + sendStatus: "finished", + receiveStatus: "refunded", + sendTicker: "BTC", + receiveTicker: "ETH", + translationPrefix: "swap2.modals.transactionStatus", + }), + ).toEqual({ + send: { + displayStatus: "success", + titleKey: "swap2.modals.transactionStatus.sections.status.sendCompleted", + titleValues: { ticker: "BTC" }, + labelKey: "swap2.modals.transactionStatus.statusLabels.finished", + }, + receive: { + displayStatus: "error", + titleKey: "swap2.modals.transactionStatus.sections.status.receivePending", + titleValues: { ticker: "ETH" }, + labelKey: "swap2.modals.transactionStatus.statusLabels.cancelled", + }, + }); + }); + + it.each([ + ["success", { icon: "success", tone: "success" }], + ["error", { icon: "error", tone: "error" }], + ["pending", { icon: "pending", tone: "muted" }], + ["unknown", { icon: "pending", tone: "muted" }], + ] as const)("should build visual tokens for %s display statuses", (status, visualTokens) => { + expect(getSwapTransactionStatusVisualTokens(status)).toEqual(visualTokens); + }); +}); diff --git a/libs/ledger-live-common/src/exchange/swapTransactionStatus/display/index.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/display/index.ts new file mode 100644 index 000000000000..7fb5051bbf65 --- /dev/null +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/display/index.ts @@ -0,0 +1,460 @@ +import BigNumber from "bignumber.js"; +import { useEffect, useMemo, useState } from "react"; +import { getAccountCurrency, getMainAccount } from "../../../account/index"; +import { formatCurrencyUnit, getCryptoCurrencyById } from "../../../currencies/index"; +import { getSwapProvider, type AdditionalProviderConfig } from "../../providers/swap"; +import { getProviderName } from "../../swap/utils/index"; +import type { SwapTransactionStatusControllerViewModel } from "../hooks/useSwapTransactionStatusController"; +import type { SwapTransactionStatusParams } from "../types"; +import { + getDefaultExplorerView, + getTransactionExplorer as getDefaultTransactionExplorer, +} from "../../../explorers"; +import type { + CryptoCurrency, + CryptoOrTokenCurrency, + ExplorerView, +} from "@ledgerhq/types-cryptoassets"; +import type { Account, AccountLike, Operation } from "@ledgerhq/types-live"; +import type { TransactionStatusValue } from "@ledgerhq/wallet-api-exchange-module"; + +const MAX_DISPLAY_DECIMALS = 8; +const SWAPS_XYZ_SCAN_URL = "https://scan.swaps.xyz/transactions"; + +export type ResolvedSwapTransactionStatusAccountLike = { + account: AccountLike; + parentAccount?: Account; +}; + +type ProviderExplorerParams = Readonly<{ + operationHash: string; + swapId: string; + currencyId?: string; +}>; + +type ProviderExplorer = Readonly<{ + requiresOperationHash: boolean; + buildUrl: (params: ProviderExplorerParams) => string | undefined; +}>; + +export type SwapTransactionStatusTransactionExplorerBuilder = ( + explorerView: ExplorerView | null | undefined, + operation: Operation, +) => string | null | undefined; + +export type SwapTransactionStatusDisplayStatus = "success" | "pending" | "error" | "unknown"; + +export type SwapTransactionStatusDirection = "send" | "receive"; + +export type SwapTransactionStatusVisualTone = "success" | "error" | "muted"; + +export type SwapTransactionStatusVisualIcon = "success" | "error" | "pending"; + +export type SwapTransactionStatusDisplayViewModel = { + sendCurrency?: CryptoOrTokenCurrency; + receiveCurrency?: CryptoOrTokenCurrency; + receiveAccountCurrency?: CryptoCurrency; + createdAt?: number; + locale: string; + sendStatus: TransactionStatusValue; + receiveStatus: TransactionStatusValue; + sentAmount?: string; + receivedAmount?: string; + feesAmount?: string; + receiveAccountName?: string; + provider?: string; + providerData?: AdditionalProviderConfig; + swapId: string; + explorerUrl?: string; + isStatusSectionLoading: boolean; + isFooterLoading: boolean; +}; + +export type UseSwapTransactionStatusDisplayViewModelParams = Readonly<{ + params: SwapTransactionStatusParams; + transactionStatus: SwapTransactionStatusControllerViewModel; + accounts: AccountLike[]; + locale: string; + useReceiveAccountName: (account: Account | undefined) => string | undefined; + useTransactionExplorerBuilder?: ( + currency: CryptoCurrency | undefined, + ) => SwapTransactionStatusTransactionExplorerBuilder | undefined; +}>; + +export type SwapTransactionStatusSectionItem = { + displayStatus: SwapTransactionStatusDisplayStatus; + titleKey: string; + titleValues: { ticker: string }; + labelKey: string; +}; + +export type SwapTransactionStatusDetailsViewModel = { + providerName?: string; + providerMainUrl?: string; + shouldShowProvider: boolean; + truncatedSwapId: string; +}; + +const PROVIDER_EXPLORERS: Record = { + lifi: { + requiresOperationHash: true, + buildUrl: ({ operationHash }) => `https://scan.li.fi/tx/${operationHash}`, + }, + thorswap: { + requiresOperationHash: true, + buildUrl: ({ operationHash }) => `https://runescan.io/tx/${operationHash}`, + }, + nearintents: { + requiresOperationHash: true, + buildUrl: ({ operationHash }) => `https://track.swapkit.dev/tx/${operationHash}`, + }, + swapsxyz: { + requiresOperationHash: false, + buildUrl: ({ swapId }) => `${SWAPS_XYZ_SCAN_URL}/${swapId}`, + }, + moonpay_trade: { + requiresOperationHash: false, + buildUrl: ({ swapId }) => `${SWAPS_XYZ_SCAN_URL}/${swapId}`, + }, + okx: { + requiresOperationHash: true, + buildUrl: ({ operationHash, currencyId }) => { + if (!currencyId) return undefined; + return `https://web3.okx.com/fi/explorer/${currencyId}/tx/${operationHash}`; + }, + }, +}; + +export function resolveSwapTransactionStatusAccountLike( + accounts: AccountLike[], + accountId: string | undefined, +): ResolvedSwapTransactionStatusAccountLike | undefined { + if (!accountId) return undefined; + const account = accounts.find(a => a.id === accountId); + if (!account) return undefined; + if (account.type !== "TokenAccount") return { account }; + const parentAccount = accounts.find( + (candidate): candidate is Account => + candidate.type === "Account" && candidate.id === account.parentId, + ); + return { account, parentAccount }; +} + +export function formatSwapTransactionStatusAmount( + currency: { units: { code: string; magnitude: number; name: string }[] } | undefined, + rawAtomic: string | undefined, + locale: string, +): string | undefined { + if (!currency || !rawAtomic) return undefined; + const unit = currency.units[0]; + return formatCurrencyUnit(unit, limitDisplayDecimals(rawAtomic, unit.magnitude), { + showCode: true, + locale, + disableRounding: true, + }); +} + +export function formatSwapTransactionStatusFeesAmount( + resolved: ResolvedSwapTransactionStatusAccountLike | undefined, + rawAtomic: string | undefined, + locale: string, +): string | undefined { + if (!resolved || !rawAtomic) return undefined; + const mainAccount = getMainAccount(resolved.account, resolved.parentAccount); + return formatSwapTransactionStatusAmount(mainAccount.currency, rawAtomic, locale); +} + +export function getSwapTransactionStatusExplorerUrl({ + provider, + swapId, + operationHash, + fromCurrency, + getTransactionExplorer, +}: { + provider: string | undefined; + swapId: string; + operationHash: string | undefined; + fromCurrency: CryptoOrTokenCurrency | undefined; + getTransactionExplorer?: SwapTransactionStatusTransactionExplorerBuilder; +}): string | undefined { + if (!provider) return undefined; + const mainCurrency = + fromCurrency?.type === "TokenCurrency" + ? getCryptoCurrencyById(fromCurrency.parentCurrencyId) + : fromCurrency; + const providerExplorer = PROVIDER_EXPLORERS[provider]; + + if (providerExplorer) { + if (providerExplorer.requiresOperationHash && !operationHash) return undefined; + return providerExplorer.buildUrl({ + operationHash: operationHash ?? "", + swapId, + currencyId: mainCurrency?.id, + }); + } + + if (!mainCurrency || !operationHash) return undefined; + return getCurrencyTransactionExplorerUrl(mainCurrency, operationHash, getTransactionExplorer); +} + +export function formatSwapTransactionStatusCreatedAt(timestamp: number, locale: string): string { + return new Intl.DateTimeFormat(locale, { + month: "long", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }).format(new Date(timestamp)); +} + +export function truncateSwapTransactionStatusIdentifier(value: string): string { + if (value.length <= 14) return value; + return `${value.slice(0, 8)}…${value.slice(-6)}`; +} + +export function getSwapTransactionStatusDetailsViewModel({ + provider, + providerData, + swapId, +}: Readonly<{ + provider?: string; + providerData?: AdditionalProviderConfig; + swapId: string; +}>): SwapTransactionStatusDetailsViewModel { + const providerName = provider ? getProviderName(provider) : undefined; + + return { + providerName, + providerMainUrl: providerData?.mainUrl, + shouldShowProvider: Boolean(provider && providerName), + truncatedSwapId: truncateSwapTransactionStatusIdentifier(swapId), + }; +} + +export function getSwapTransactionStatusReceiveDisplayStatus( + receiveStatus: TransactionStatusValue, + currentStatus: TransactionStatusValue, + sendStatus: TransactionStatusValue, +): TransactionStatusValue { + if (receiveStatus === "unknown" && (currentStatus === "finished" || sendStatus === "finished")) { + return "finished"; + } + return receiveStatus; +} + +export function getSwapTransactionStatusTitleKey( + direction: SwapTransactionStatusDirection, + currentStatus: TransactionStatusValue, + translationPrefix: string, +): string { + if (direction === "send" && currentStatus === "finished") { + return `${translationPrefix}.sections.status.sendCompleted`; + } + if (direction === "send") { + return `${translationPrefix}.sections.status.sendPending`; + } + if (currentStatus === "finished") { + return `${translationPrefix}.sections.status.receiveCompleted`; + } + return `${translationPrefix}.sections.status.receivePending`; +} + +export function getSwapTransactionStatusLabelKey( + direction: SwapTransactionStatusDirection, + currentStatus: TransactionStatusValue, + translationPrefix: string, +): string { + if (direction === "receive" && currentStatus === "refunded") { + return `${translationPrefix}.statusLabels.cancelled`; + } + return `${translationPrefix}.statusLabels.${currentStatus}`; +} + +export function getSwapTransactionStatusDisplayStatus( + currentStatus: TransactionStatusValue, +): SwapTransactionStatusDisplayStatus { + switch (currentStatus) { + case "finished": + return "success"; + case "expired": + case "refunded": + return "error"; + case "unknown": + return "unknown"; + default: + return "pending"; + } +} + +export function getSwapTransactionStatusVisualTokens(status: SwapTransactionStatusDisplayStatus): { + icon: SwapTransactionStatusVisualIcon; + tone: SwapTransactionStatusVisualTone; +} { + if (status === "success") { + return { icon: "success", tone: "success" }; + } + if (status === "error") { + return { icon: "error", tone: "error" }; + } + return { icon: "pending", tone: "muted" }; +} + +export function getSwapTransactionStatusSectionItems({ + sendStatus, + receiveStatus, + sendTicker, + receiveTicker, + translationPrefix, +}: Readonly<{ + sendStatus: TransactionStatusValue; + receiveStatus: TransactionStatusValue; + sendTicker?: string; + receiveTicker?: string; + translationPrefix: string; +}>): { + send: SwapTransactionStatusSectionItem; + receive: SwapTransactionStatusSectionItem; +} { + return { + send: { + displayStatus: getSwapTransactionStatusDisplayStatus(sendStatus), + titleKey: getSwapTransactionStatusTitleKey("send", sendStatus, translationPrefix), + titleValues: { ticker: sendTicker ?? "" }, + labelKey: getSwapTransactionStatusLabelKey("send", sendStatus, translationPrefix), + }, + receive: { + displayStatus: getSwapTransactionStatusDisplayStatus(receiveStatus), + titleKey: getSwapTransactionStatusTitleKey("receive", receiveStatus, translationPrefix), + titleValues: { ticker: receiveTicker ?? "" }, + labelKey: getSwapTransactionStatusLabelKey("receive", receiveStatus, translationPrefix), + }, + }; +} + +export function useSwapTransactionStatusDisplayViewModel({ + params, + transactionStatus, + accounts, + locale, + useReceiveAccountName, + useTransactionExplorerBuilder = useUndefinedTransactionExplorerBuilder, +}: UseSwapTransactionStatusDisplayViewModelParams): SwapTransactionStatusDisplayViewModel { + const details = transactionStatus.details; + const provider = details?.provider ?? params.provider; + const sendResolved = useMemo( + () => resolveSwapTransactionStatusAccountLike(accounts, details?.fromAccountId), + [accounts, details?.fromAccountId], + ); + const receiveResolved = useMemo( + () => resolveSwapTransactionStatusAccountLike(accounts, details?.toAccountId), + [accounts, details?.toAccountId], + ); + + const receiveAccount = receiveResolved + ? getMainAccount(receiveResolved.account, receiveResolved.parentAccount) + : undefined; + const receiveAccountName = useReceiveAccountName(receiveAccount); + + const [providerData, setProviderData] = useState(); + useEffect(() => { + let cancelled = false; + setProviderData(undefined); + + if (provider) { + getSwapProvider(provider) + .then(data => { + if (!cancelled) setProviderData(data); + }) + .catch(() => { + if (!cancelled) setProviderData(undefined); + }); + } + + return () => { + cancelled = true; + }; + }, [provider]); + + const sendCurrency = sendResolved ? getAccountCurrency(sendResolved.account) : undefined; + const receiveCurrency = receiveResolved ? getAccountCurrency(receiveResolved.account) : undefined; + const sendMainCurrency = + sendCurrency?.type === "TokenCurrency" + ? getCryptoCurrencyById(sendCurrency.parentCurrencyId) + : sendCurrency; + const getTransactionExplorer = useTransactionExplorerBuilder(sendMainCurrency); + const sentAmount = formatSwapTransactionStatusAmount(sendCurrency, details?.sentAmount, locale); + const receivedAmount = formatSwapTransactionStatusAmount( + receiveCurrency, + details?.finalAmount ?? details?.receivedAmount, + locale, + ); + const feesAmount = formatSwapTransactionStatusFeesAmount( + sendResolved, + details?.feesAmount, + locale, + ); + const currentStatus: TransactionStatusValue = + transactionStatus.latestStatus?.status ?? details?.status ?? "pending"; + const sendStatus = details?.sendStatus ?? currentStatus; + const receiveStatus = getSwapTransactionStatusReceiveDisplayStatus( + details?.receiveStatus ?? currentStatus, + currentStatus, + sendStatus, + ); + const explorerUrl = getSwapTransactionStatusExplorerUrl({ + provider, + swapId: params.swapId, + operationHash: details?.operationHash, + fromCurrency: sendCurrency, + getTransactionExplorer, + }); + const isStatusSectionLoading = + transactionStatus.isInitialLoading || !sendCurrency || !receiveCurrency; + + return { + sendCurrency, + receiveCurrency, + receiveAccountCurrency: receiveAccount?.currency, + createdAt: details?.createdAt, + locale, + sendStatus, + receiveStatus, + sentAmount, + receivedAmount, + feesAmount, + receiveAccountName, + provider, + providerData, + swapId: params.swapId, + explorerUrl, + isStatusSectionLoading, + isFooterLoading: transactionStatus.isInitialLoading, + }; +} + +function limitDisplayDecimals(rawAtomic: string, unitMagnitude: number): BigNumber { + const value = new BigNumber(rawAtomic); + const hiddenMagnitude = unitMagnitude - MAX_DISPLAY_DECIMALS; + if (hiddenMagnitude <= 0) return value; + + const factor = new BigNumber(10).pow(hiddenMagnitude); + return value.div(factor).decimalPlaces(0, BigNumber.ROUND_DOWN).times(factor); +} + +function getCurrencyTransactionExplorerUrl( + mainCurrency: CryptoCurrency, + operationHash: string, + getTransactionExplorer: SwapTransactionStatusTransactionExplorerBuilder | undefined, +): string | undefined { + const explorerView = getDefaultExplorerView(mainCurrency); + const operation = { hash: operationHash, extra: {} } as Operation; + + return ( + getTransactionExplorer?.(explorerView, operation) ?? + getDefaultTransactionExplorer(explorerView, operationHash) + ); +} + +function useUndefinedTransactionExplorerBuilder(): undefined { + return undefined; +} diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/fromSwapOperation.test.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/history/fromSwapOperation.test.ts similarity index 94% rename from libs/ledger-live-common/src/exchange/transactionStatus/fromSwapOperation.test.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/history/fromSwapOperation.test.ts index c214ab250cf7..fa867c8b1739 100644 --- a/libs/ledger-live-common/src/exchange/transactionStatus/fromSwapOperation.test.ts +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/history/fromSwapOperation.test.ts @@ -1,6 +1,6 @@ import BigNumber from "bignumber.js"; import type { Account, Operation } from "@ledgerhq/types-live"; -import type { MappedSwapOperation } from "../swap/types"; +import type { MappedSwapOperation } from "../../swap/types"; import { fromSwapOperation } from "./fromSwapOperation"; function makeAccount(id: string): Account { diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/fromSwapOperation.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/history/fromSwapOperation.ts similarity index 63% rename from libs/ledger-live-common/src/exchange/transactionStatus/fromSwapOperation.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/history/fromSwapOperation.ts index 236b9518e91f..b581ffe51696 100644 --- a/libs/ledger-live-common/src/exchange/transactionStatus/fromSwapOperation.ts +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/history/fromSwapOperation.ts @@ -1,5 +1,5 @@ -import type { MappedSwapOperation } from "../swap/types"; -import type { SwapTransactionStatusParams } from "./types"; +import type { MappedSwapOperation } from "../../swap/types"; +import type { SwapTransactionStatusParams } from "../types"; export function fromSwapOperation( mappedSwapOperation: MappedSwapOperation, diff --git a/libs/ledger-live-common/src/exchange/swapTransactionStatus/hooks/useSwapTransactionStatusController.test.tsx b/libs/ledger-live-common/src/exchange/swapTransactionStatus/hooks/useSwapTransactionStatusController.test.tsx new file mode 100644 index 000000000000..3d412fd04340 --- /dev/null +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/hooks/useSwapTransactionStatusController.test.tsx @@ -0,0 +1,177 @@ +/** + * @jest-environment jsdom + */ +import "../../../__tests__/test-helpers/dom-polyfill"; +import { renderHook, act, waitFor, cleanup } from "@testing-library/react"; +import { genAccount } from "@ledgerhq/ledger-wallet-framework/mocks/account"; +import { getTransactionStatus } from "../../../wallet-api/Exchange/transactionStatus/index"; +import type { GetTransactionStatusResponse } from "../../../wallet-api/Exchange/transactionStatus/index"; +import type { AccountLike } from "@ledgerhq/types-live"; +import { useSwapTransactionStatusController } from "./useSwapTransactionStatusController"; + +const mockBridgeSync = jest.fn(); + +jest.mock("../../../bridge/react/index", () => ({ + useBridgeSync: () => mockBridgeSync, +})); + +jest.mock("../../../wallet-api/Exchange/transactionStatus/index", () => ({ + getTransactionStatus: jest.fn(), +})); + +const mockedGetTransactionStatus = jest.mocked(getTransactionStatus); +const STATUS_POLL_INTERVAL_MS = 60_000; +let setTimeoutSpy: jest.SpiedFunction | undefined; + +function makeTransactionStatusResponse( + overrides: Partial = {}, +): GetTransactionStatusResponse { + return { + provider: "lifi", + swapId: "swap-1", + status: "pending", + ...overrides, + } as GetTransactionStatusResponse; +} + +function captureStatusPollTimeout() { + const originalSetTimeout = global.setTimeout; + let runStatusPoll: (() => Promise) | undefined; + + setTimeoutSpy = jest.spyOn(global, "setTimeout").mockImplementation((( + callback: () => unknown, + delay?: number, + ) => { + if (delay === STATUS_POLL_INTERVAL_MS) { + runStatusPoll = async () => { + await callback(); + }; + return 0 as unknown as ReturnType; + } + + return originalSetTimeout(callback, delay); + }) as typeof setTimeout); + + return () => runStatusPoll; +} + +async function flushAsyncEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function renderController({ + accounts = [], + onAutoRedirect, +}: { + accounts?: AccountLike[]; + onAutoRedirect?: (redirectUrl: string) => void; +} = {}) { + return renderHook(() => + useSwapTransactionStatusController({ + params: { swapId: "swap-1", provider: "lifi" }, + accounts, + onAutoRedirect, + }), + ); +} + +describe("useSwapTransactionStatusController", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + setTimeoutSpy?.mockRestore(); + setTimeoutSpy = undefined; + }); + + it("should retry polling after a transient status lookup failure", async () => { + const getRunStatusPoll = captureStatusPollTimeout(); + mockedGetTransactionStatus + .mockRejectedValueOnce(new Error("swap history still loading")) + .mockResolvedValueOnce(makeTransactionStatusResponse()); + + const { unmount } = renderController(); + + await flushAsyncEffects(); + + expect(mockedGetTransactionStatus).toHaveBeenCalledTimes(1); + expect(getRunStatusPoll()).toEqual(expect.any(Function)); + + await act(async () => { + await getRunStatusPoll()?.(); + }); + + expect(mockedGetTransactionStatus).toHaveBeenCalledTimes(2); + unmount(); + }); + + it("should stop polling after a terminal status is received", async () => { + const getRunStatusPoll = captureStatusPollTimeout(); + mockedGetTransactionStatus.mockResolvedValueOnce( + makeTransactionStatusResponse({ status: "finished" }), + ); + + const { result, unmount } = renderController(); + + await flushAsyncEffects(); + + expect(result.current.latestStatus?.status).toBe("finished"); + expect(getRunStatusPoll()).toBeUndefined(); + expect(mockedGetTransactionStatus).toHaveBeenCalledTimes(1); + unmount(); + }); + + it("should call the app redirect handler when a terminal status arrives while hidden", async () => { + const onAutoRedirect = jest.fn(); + mockedGetTransactionStatus.mockResolvedValueOnce( + makeTransactionStatusResponse({ status: "finished" }), + ); + + const { unmount } = renderHook(() => + useSwapTransactionStatusController({ + params: { + swapId: "swap-1", + provider: "lifi", + redirectUrl: "ledgerlive://swap/done", + }, + accounts: [], + onAutoRedirect, + }), + ); + + await waitFor(() => { + expect(onAutoRedirect).toHaveBeenCalledWith("ledgerlive://swap/done"); + }); + unmount(); + }); + + it("should update leg statuses from confirmed local account operations", async () => { + const account = genAccount("swap-status-account", { operationsSize: 1 }); + account.operations[0] = { + ...account.operations[0], + hash: "0xabc", + blockHeight: 123, + hasFailed: false, + }; + mockedGetTransactionStatus.mockResolvedValueOnce( + makeTransactionStatusResponse({ + operationHash: "0xabc", + fromAccountId: account.id, + status: "unknown", + }), + ); + + const { result, unmount } = renderController({ accounts: [account] }); + + await waitFor(() => { + expect(result.current.details?.sendStatus).toBe("finished"); + expect(result.current.details?.receiveStatus).toBe("unknown"); + }); + unmount(); + }); +}); diff --git a/libs/ledger-live-common/src/exchange/swapTransactionStatus/hooks/useSwapTransactionStatusController.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/hooks/useSwapTransactionStatusController.ts new file mode 100644 index 000000000000..2584e6bc09b5 --- /dev/null +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/hooks/useSwapTransactionStatusController.ts @@ -0,0 +1,228 @@ +import { useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { flattenAccounts } from "../../../account/index"; +import { useBridgeSync } from "../../../bridge/react/index"; +import { + createInitialSwapTransactionStatusState, + getSwapTransactionLegStatusesFromAccounts, + shouldPollSwapTransactionStatus, + swapTransactionStatusReducer, + type SwapTransactionLegStatuses, + type SwapTransactionStatusControllerState, +} from "../status/statusController"; +import type { SwapTransactionStatusParams } from "../types"; +import { + getTransactionStatus, + type GetTransactionStatusResponse, +} from "../../../wallet-api/Exchange/transactionStatus/index"; +import type { AccountLike } from "@ledgerhq/types-live"; + +const SOFT_DEADLINE_MS = 5_000; +const ACCOUNT_SYNC_INTERVAL_MS = 10_000; +const STATUS_POLL_INTERVAL_MS = 60_000; +const STATUS_QUERY_KEY = "swap-transaction-status"; + +export type SwapTransactionStatusControllerViewModel = { + phase: SwapTransactionStatusControllerState["phase"]; + latestStatus: SwapTransactionStatusControllerState["latestStatus"]; + details?: GetTransactionStatusResponse; + isInitialLoading: boolean; + isSettled: boolean; +}; + +export function useSwapTransactionStatusController({ + params, + accounts, + onAutoRedirect, +}: { + params: SwapTransactionStatusParams; + accounts: AccountLike[]; + onAutoRedirect?: (redirectUrl: string) => void; +}): SwapTransactionStatusControllerViewModel { + const flattenedAccounts = useMemo(() => flattenAccounts(accounts), [accounts]); + const flattenedAccountsRef = useRef(flattenedAccounts); + const [details, setDetails] = useState(); + const [state, dispatch] = useReducer( + swapTransactionStatusReducer, + undefined, + createInitialSwapTransactionStatusState, + ); + const latestStatusRef = useRef(state.latestStatus?.status); + + useEffect(() => { + flattenedAccountsRef.current = flattenedAccounts; + }, [flattenedAccounts]); + + useEffect(() => { + latestStatusRef.current = state.latestStatus?.status; + }, [state.latestStatus?.status]); + + useEffect(() => { + let cancelled = false; + let timeout: ReturnType | undefined; + + const loadTransactionStatus = async (): Promise => { + try { + const response = await getTransactionStatus( + { + swapId: params.swapId, + provider: params.provider, + }, + { accounts: flattenedAccountsRef.current }, + ); + if (cancelled) return undefined; + setDetails(response); + if (response.provider && response.status) { + latestStatusRef.current = response.status; + dispatch({ + type: "POLL_SUCCEEDED", + status: { + provider: response.provider, + swapId: response.swapId, + status: response.status, + finalAmount: response.finalAmount, + }, + }); + } + return response; + } catch { + // Local swap history can be unavailable while accounts are still loading. + return undefined; + } + }; + + const scheduleNextPoll = (response: GetTransactionStatusResponse | undefined) => { + if (!cancelled && shouldRetryTransactionStatus(response)) { + timeout = setTimeout(pollTransactionStatus, STATUS_POLL_INTERVAL_MS); + } + }; + + const pollTransactionStatus = async () => { + if ( + latestStatusRef.current !== undefined && + !shouldPollSwapTransactionStatus(latestStatusRef.current) + ) { + return; + } + const response = await loadTransactionStatus(); + scheduleNextPoll(response); + }; + + pollTransactionStatus(); + return () => { + cancelled = true; + if (timeout !== undefined) clearTimeout(timeout); + }; + }, [params.provider, params.swapId]); + + useOnChainConfirmationSignal({ + accounts, + fromAccountId: details?.fromAccountId, + operationHash: details?.operationHash, + providerStatus: details?.status, + enabled: state.phase !== "settled_visible", + onLegStatusesChanged: legStatuses => { + setDetails(current => (current ? { ...current, ...legStatuses } : current)); + }, + }); + + useEffect(() => { + const handle = setTimeout( + () => { + dispatch({ type: "SOFT_DEADLINE_REACHED" }); + }, + params.redirectUrl ? SOFT_DEADLINE_MS : 0, + ); + return () => clearTimeout(handle); + }, [params.redirectUrl]); + + const autoRedirectFiredRef = useRef(false); + useEffect(() => { + if (!params.redirectUrl || !state.shouldAutoRedirect || autoRedirectFiredRef.current) return; + autoRedirectFiredRef.current = true; + onAutoRedirect?.(params.redirectUrl); + }, [onAutoRedirect, params.redirectUrl, state.shouldAutoRedirect]); + + return { + phase: state.phase, + latestStatus: state.latestStatus, + details, + isInitialLoading: state.phase === "polling_hidden" && !state.latestStatus, + isSettled: state.phase === "settled_visible", + }; +} + +function shouldRetryTransactionStatus(response: GetTransactionStatusResponse | undefined): boolean { + return ( + !response || + response.providerRequired === true || + !response.status || + shouldPollSwapTransactionStatus(response.status) + ); +} + +function useOnChainConfirmationSignal({ + accounts, + fromAccountId, + operationHash, + providerStatus, + enabled, + onLegStatusesChanged, +}: { + accounts: AccountLike[]; + fromAccountId: string | undefined; + operationHash: string | undefined; + providerStatus: GetTransactionStatusResponse["status"]; + enabled: boolean; + onLegStatusesChanged: (status: SwapTransactionLegStatuses) => void; +}): void { + const sync = useBridgeSync(); + const flattenedAccounts = useMemo(() => flattenAccounts(accounts), [accounts]); + const syncAccountId = useMemo( + () => resolveSyncAccountId(flattenedAccounts, fromAccountId), + [flattenedAccounts, fromAccountId], + ); + + useEffect(() => { + if (!enabled || !syncAccountId || !operationHash) return; + const handle = setInterval(() => { + sync({ + type: "SYNC_SOME_ACCOUNTS", + accountIds: [syncAccountId], + priority: 100, + reason: STATUS_QUERY_KEY, + }); + }, ACCOUNT_SYNC_INTERVAL_MS); + return () => clearInterval(handle); + }, [enabled, operationHash, sync, syncAccountId]); + + const legStatuses = useMemo( + () => + enabled + ? getSwapTransactionLegStatusesFromAccounts({ + accounts: flattenedAccounts, + operationHash, + providerStatus, + }) + : undefined, + [enabled, flattenedAccounts, operationHash, providerStatus], + ); + + const firedRef = useRef(false); + useEffect(() => { + if (!legStatuses?.sendStatus || legStatuses.sendStatus === "pending" || firedRef.current) { + return; + } + firedRef.current = true; + onLegStatusesChanged(legStatuses); + }, [legStatuses, onLegStatusesChanged]); +} + +function resolveSyncAccountId( + accounts: AccountLike[], + accountId: string | undefined, +): string | undefined { + if (!accountId) return undefined; + const account = accounts.find(a => a.id === accountId); + if (!account) return accountId; + return account.type === "TokenAccount" ? account.parentId : account.id; +} diff --git a/libs/ledger-live-common/src/exchange/swapTransactionStatus/index.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/index.ts new file mode 100644 index 000000000000..a589bf0d4095 --- /dev/null +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/index.ts @@ -0,0 +1,3 @@ +export * from "./transactionStatus"; +export * from "./display"; +export * from "./hooks/useSwapTransactionStatusController"; diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/parseParams.test.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/params/parseParams.test.ts similarity index 100% rename from libs/ledger-live-common/src/exchange/transactionStatus/parseParams.test.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/params/parseParams.test.ts diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/parseParams.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/params/parseParams.ts similarity index 91% rename from libs/ledger-live-common/src/exchange/transactionStatus/parseParams.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/params/parseParams.ts index 129c87a83f75..23797b7207db 100644 --- a/libs/ledger-live-common/src/exchange/transactionStatus/parseParams.ts +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/params/parseParams.ts @@ -1,10 +1,10 @@ -import type { SwapStatus } from "../swap/types"; +import type { SwapStatus } from "../../swap/types"; import type { SwapTransactionStatusParseResult, SwapTransactionStatusParamsError, SwapTransactionStatusRawParams, -} from "./types"; -import { isTransactionStatusValue } from "./statusValues"; +} from "../types"; +import { isTransactionStatusValue } from "../status/statusValues"; const ALLOWED_REDIRECT_PROTOCOLS = new Set(["https:", "ledgerlive:", "ledgerwallet:"]); diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/statusController.test.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/status/statusController.test.ts similarity index 99% rename from libs/ledger-live-common/src/exchange/transactionStatus/statusController.test.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/status/statusController.test.ts index cd10f3450b92..d1c699804924 100644 --- a/libs/ledger-live-common/src/exchange/transactionStatus/statusController.test.ts +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/status/statusController.test.ts @@ -1,5 +1,5 @@ import { genAccount } from "@ledgerhq/ledger-wallet-framework/mocks/account"; -import type { SwapStatus } from "../swap/types"; +import type { SwapStatus } from "../../swap/types"; import { createInitialSwapTransactionStatusState, getSendSwapStatus, diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/statusController.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/status/statusController.ts similarity index 98% rename from libs/ledger-live-common/src/exchange/transactionStatus/statusController.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/status/statusController.ts index bfc9cbc4f104..c1b0b3ef3b52 100644 --- a/libs/ledger-live-common/src/exchange/transactionStatus/statusController.ts +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/status/statusController.ts @@ -1,6 +1,6 @@ import type { AccountLike } from "@ledgerhq/types-live"; import { TransactionStatus } from "@ledgerhq/wallet-api-exchange-module"; -import type { SwapStatus } from "../swap/types"; +import type { SwapStatus } from "../../swap/types"; export type SwapTransactionStatusPhase = "polling_hidden" | "polling_visible" | "settled_visible"; diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/statusValues.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/status/statusValues.ts similarity index 100% rename from libs/ledger-live-common/src/exchange/transactionStatus/statusValues.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/status/statusValues.ts diff --git a/libs/ledger-live-common/src/exchange/swapTransactionStatus/transactionStatus.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/transactionStatus.ts new file mode 100644 index 000000000000..d52e713c4b3c --- /dev/null +++ b/libs/ledger-live-common/src/exchange/swapTransactionStatus/transactionStatus.ts @@ -0,0 +1,6 @@ +export * from "./api/fetchSwapStatus"; +export * from "./history/fromSwapOperation"; +export * from "./params/parseParams"; +export * from "./status/statusController"; +export * from "./status/statusValues"; +export * from "./types"; diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/types.ts b/libs/ledger-live-common/src/exchange/swapTransactionStatus/types.ts similarity index 100% rename from libs/ledger-live-common/src/exchange/transactionStatus/types.ts rename to libs/ledger-live-common/src/exchange/swapTransactionStatus/types.ts diff --git a/libs/ledger-live-common/src/exchange/transactionStatus/index.ts b/libs/ledger-live-common/src/exchange/transactionStatus/index.ts deleted file mode 100644 index b3ae0ed88c7f..000000000000 --- a/libs/ledger-live-common/src/exchange/transactionStatus/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from "./fetchSwapStatus"; -export * from "./fromSwapOperation"; -export * from "./parseParams"; -export * from "./statusController"; -export * from "./statusValues"; -export * from "./types"; diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/transactionStatus/getTransactionStatus.test.ts b/libs/ledger-live-common/src/wallet-api/Exchange/transactionStatus/getTransactionStatus.test.ts index 1bd1d996eb74..c2a35113dafd 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/transactionStatus/getTransactionStatus.test.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/transactionStatus/getTransactionStatus.test.ts @@ -2,7 +2,7 @@ import BigNumber from "bignumber.js"; import type { Account, Operation } from "@ledgerhq/types-live"; import type { MappedSwapOperation } from "../../../exchange/swap/types"; import getCompleteSwapHistory from "../../../exchange/swap/getCompleteSwapHistory"; -import { fetchTransactionSwapStatus } from "../../../exchange/transactionStatus/fetchSwapStatus"; +import { fetchTransactionSwapStatus } from "../../../exchange/swapTransactionStatus/transactionStatus"; import { getTransactionStatus } from "./getTransactionStatus"; jest.mock("../../../exchange/swap/getCompleteSwapHistory", () => ({ @@ -10,9 +10,13 @@ jest.mock("../../../exchange/swap/getCompleteSwapHistory", () => ({ default: jest.fn(), })); -jest.mock("../../../exchange/transactionStatus/fetchSwapStatus", () => ({ - fetchTransactionSwapStatus: jest.fn(), -})); +jest.mock("../../../exchange/swapTransactionStatus/transactionStatus", () => { + const actual = jest.requireActual("../../../exchange/swapTransactionStatus/transactionStatus"); + return { + ...actual, + fetchTransactionSwapStatus: jest.fn(), + }; +}); const mockedGetCompleteSwapHistory = jest.mocked(getCompleteSwapHistory); const mockedFetchTransactionSwapStatus = jest.mocked(fetchTransactionSwapStatus); diff --git a/libs/ledger-live-common/src/wallet-api/Exchange/transactionStatus/getTransactionStatus.ts b/libs/ledger-live-common/src/wallet-api/Exchange/transactionStatus/getTransactionStatus.ts index 130602c2e279..830695468325 100644 --- a/libs/ledger-live-common/src/wallet-api/Exchange/transactionStatus/getTransactionStatus.ts +++ b/libs/ledger-live-common/src/wallet-api/Exchange/transactionStatus/getTransactionStatus.ts @@ -4,10 +4,10 @@ import { flattenAccounts, getAccountCurrency } from "../../../account"; import getCompleteSwapHistory from "../../../exchange/swap/getCompleteSwapHistory"; import { swapProviderRequiresOperationId } from "../../../exchange/swap/providersRequiringOperationId"; import { + fetchTransactionSwapStatus, getSwapTransactionLegStatusesFromAccounts, isTransactionStatusValue, -} from "../../../exchange/transactionStatus"; -import { fetchTransactionSwapStatus } from "../../../exchange/transactionStatus/fetchSwapStatus"; +} from "../../../exchange/swapTransactionStatus/transactionStatus"; import type { MappedSwapOperation, SwapStatus, diff --git a/libs/ledger-live-common/src/wallet-api/react.test.ts b/libs/ledger-live-common/src/wallet-api/react.test.ts index b9b9e8dace37..6364ce7ca4f7 100644 --- a/libs/ledger-live-common/src/wallet-api/react.test.ts +++ b/libs/ledger-live-common/src/wallet-api/react.test.ts @@ -1,15 +1,7 @@ /** * @jest-environment jsdom */ -if (typeof globalThis.setImmediate !== "function") { - // Force React scheduler to avoid MessageChannel in jsdom + detectOpenHandles. - // @ts-expect-error Test-only polyfill for environments without setImmediate. - globalThis.setImmediate = (callback: (...args: unknown[]) => void, ...args: unknown[]) => { - setTimeout(() => callback(...args), 0); - }; -} -// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports -const { renderHook, act, cleanup } = require("@testing-library/react"); +import { renderHook, act, cleanup } from "@testing-library/react"; import { initialState as walletState } from "@ledgerhq/live-wallet/store"; import { createFixtureAccount } from "../mock/fixtures/cryptoCurrencies"; import type { TrackingAPI } from "./tracking";