From 7d3f286fa559953988e37e14558c0783fec3482f Mon Sep 17 00:00:00 2001 From: yakuramori <62520712+yury-dubinin@users.noreply.github.com> Date: Thu, 7 Nov 2024 13:03:06 +0100 Subject: [PATCH 1/4] More USD per Market Order (#3937) --- .github/workflows/monitoring-limit-geo-e2e-tests.yml | 6 +++--- packages/web/e2e/pages/trade-page.ts | 5 ----- .../web/e2e/tests/monitoring.market.wallet.spec.ts | 10 ++++++---- 3 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/workflows/monitoring-limit-geo-e2e-tests.yml b/.github/workflows/monitoring-limit-geo-e2e-tests.yml index 5c96ad5bd5..c53c4451a4 100644 --- a/.github/workflows/monitoring-limit-geo-e2e-tests.yml +++ b/.github/workflows/monitoring-limit-geo-e2e-tests.yml @@ -48,7 +48,7 @@ jobs: fe-limit-eu-tests: timeout-minutes: 12 - name: prod-fe-trade-eu-tests + name: prod-fe-limit-eu-tests needs: fe-trade-eu-tests runs-on: macos-latest steps: @@ -118,7 +118,7 @@ jobs: USE_TEST_PROXY: "use" run: | cd packages/web - npx playwright test monitoring.market --timeout 180000 + npx playwright test monitoring.market --timeout 90000 - name: upload monitoring test results if: failure() id: monitoring-test-results @@ -155,7 +155,7 @@ jobs: PRIVATE_KEY: ${{ secrets.TEST_PRIVATE_KEY_3 }} run: | cd packages/web - npx playwright test monitoring.market --timeout 180000 + npx playwright test monitoring.market --timeout 90000 - name: upload monitoring test results if: failure() id: monitoring-test-results diff --git a/packages/web/e2e/pages/trade-page.ts b/packages/web/e2e/pages/trade-page.ts index abdfe6954f..507400d4e1 100644 --- a/packages/web/e2e/pages/trade-page.ts +++ b/packages/web/e2e/pages/trade-page.ts @@ -289,11 +289,6 @@ export class TradePage extends BasePage { } async buyAndGetWalletMsg(context: BrowserContext, limit = false) { - // Make sure to have sufficient balance and swap button is enabled - expect( - await this.isInsufficientBalance(), - "Insufficient balance for the swap!" - ).toBeFalsy(); await expect(this.buyBtn, "Buy button is disabled!").toBeEnabled({ timeout: 7000, }); diff --git a/packages/web/e2e/tests/monitoring.market.wallet.spec.ts b/packages/web/e2e/tests/monitoring.market.wallet.spec.ts index aef992d0d1..d67ad7268d 100644 --- a/packages/web/e2e/tests/monitoring.market.wallet.spec.ts +++ b/packages/web/e2e/tests/monitoring.market.wallet.spec.ts @@ -7,7 +7,7 @@ import { UnzipExtension } from "~/e2e/unzip-extension"; import { TradePage } from "../pages/trade-page"; import { WalletPage } from "../pages/wallet-page"; -test.describe("Test Filled Limit Order feature", () => { +test.describe("Test Market Buy/Sell Order feature", () => { let context: BrowserContext; const privateKey = process.env.PRIVATE_KEY ?? "private_key"; let tradePage: TradePage; @@ -48,7 +48,9 @@ test.describe("Test Filled Limit Order feature", () => { await tradePage.goto(); await tradePage.openBuyTab(); await tradePage.selectAsset(name); - await tradePage.enterAmount("1.05"); + await tradePage.enterAmount("1.55"); + await tradePage.isSufficientBalanceForTrade(); + await tradePage.showSwapInfo(); const { msgContentAmount } = await tradePage.buyAndGetWalletMsg(context); expect(msgContentAmount).toBeTruthy(); expect(msgContentAmount).toContain("type: osmosis/poolmanager/"); @@ -62,7 +64,7 @@ test.describe("Test Filled Limit Order feature", () => { await tradePage.goto(); await tradePage.openSellTab(); await tradePage.selectAsset("WBTC"); - await tradePage.enterAmount("1.04"); + await tradePage.enterAmount("1.54"); await tradePage.isSufficientBalanceForTrade(); await tradePage.showSwapInfo(); const { msgContentAmount } = await tradePage.sellAndGetWalletMsg(context); @@ -76,7 +78,7 @@ test.describe("Test Filled Limit Order feature", () => { await tradePage.goto(); await tradePage.openSellTab(); await tradePage.selectAsset("OSMO"); - await tradePage.enterAmount("1.04"); + await tradePage.enterAmount("1.54"); await tradePage.isSufficientBalanceForTrade(); await tradePage.showSwapInfo(); const { msgContentAmount } = await tradePage.sellAndGetWalletMsg(context); From 4f0d65055c652f9b0983dd760928e7ee7f0858a5 Mon Sep 17 00:00:00 2001 From: Jose Felix Date: Sat, 9 Nov 2024 02:01:19 -0400 Subject: [PATCH 2/4] (Portfolio) Portfolio Recent Deposits and Withdrawals; Fix IBC Transfer Status; Display fee in bridge provider dropdown (#3928) * feat: refactor transfer store tx snapshot * feat: display recent transfers in recent activity * feat: add pending state * feat: add transaction row in transaction page * fix: layout * feat: make transaction rows responsive * feat: add transaction details for withdrawals and deposits * fix: bridge provider select, ibc tx tracking and improve transaction details * test: fix transfer status tests * fix: squid test * feat: remove unused localization key * fix: remove unused localization key * improvement: @jonator feedback * improvement: @CryptoAssassin1 feedback --- .../__tests__/axelar-transfer-status.spec.ts | 65 ++- packages/bridge/src/axelar/transfer-status.ts | 32 +- .../ibc/__tests__/ibc-transfer-status.spec.ts | 205 +++++++--- packages/bridge/src/ibc/transfer-status.ts | 53 ++- packages/bridge/src/interface.ts | 74 +++- .../__tests__/skip-transfer-status.spec.ts | 125 ++++-- packages/bridge/src/skip/transfer-status.ts | 35 +- .../__tests__/squid-bridge-provider.spec.ts | 4 + .../__tests__/squid-transfer-status.spec.ts | 113 ++++-- packages/bridge/src/squid/index.ts | 2 + packages/bridge/src/squid/transfer-status.ts | 35 +- packages/trpc/src/chains.ts | 2 +- packages/tx/src/tracer.ts | 10 +- packages/utils/src/bitcoin.ts | 6 +- packages/utils/src/ethereum.ts | 16 + packages/utils/src/solana.ts | 11 + packages/web/components/assets/chain-logo.tsx | 23 +- .../web/components/bridge/amount-screen.tsx | 2 +- .../bridge/bridge-provider-dropdown.tsx | 17 +- .../components/bridge/use-bridge-quotes.ts | 94 +++-- .../components/buttons/copy-icon-button.tsx | 12 +- .../nomic/nomic-pending-transfers.tsx | 4 +- .../recent-activity-transaction-row.tsx | 73 ---- .../recent-activity/recent-activity.tsx | 99 +++-- .../transactions/transaction-content.tsx | 8 +- .../transaction-details-modal.tsx | 16 +- .../transaction-details-slideover.tsx | 20 +- .../transaction-content.tsx | 107 ----- .../transaction-details-item.tsx | 34 ++ ...ntent.tsx => transaction-swap-details.tsx} | 6 +- .../transaction-transfer-details.tsx | 377 ++++++++++++++++++ ...ttons.tsx => transaction-options-menu.tsx} | 17 +- .../transactions/transaction-pagination.tsx | 4 +- .../transactions/transaction-row.tsx | 228 ----------- .../transactions/transaction-rows.tsx | 196 ++++++--- .../transaction-containers.tsx | 119 ++++++ .../transaction-swap-row.tsx | 177 ++++++++ .../transaction-transfer-row.tsx | 353 ++++++++++++++++ .../transactions/transaction-utils.tsx | 45 --- packages/web/hooks/use-transaction-chain.ts | 37 ++ packages/web/hooks/use-transaction-history.ts | 83 ++++ packages/web/knip.json | 4 +- packages/web/localizations/de.json | 18 +- packages/web/localizations/en.json | 21 +- packages/web/localizations/es.json | 18 +- packages/web/localizations/fa.json | 18 +- packages/web/localizations/fr.json | 18 +- packages/web/localizations/gu.json | 18 +- packages/web/localizations/hi.json | 18 +- packages/web/localizations/ja.json | 18 +- packages/web/localizations/ko.json | 18 +- packages/web/localizations/pl.json | 18 +- packages/web/localizations/pt-br.json | 18 +- packages/web/localizations/ro.json | 18 +- packages/web/localizations/ru.json | 18 +- packages/web/localizations/tr.json | 18 +- packages/web/localizations/zh-cn.json | 18 +- packages/web/localizations/zh-hk.json | 18 +- packages/web/localizations/zh-tw.json | 18 +- packages/web/pages/transactions.tsx | 35 +- packages/web/stores/root.ts | 7 +- packages/web/stores/transfer-history.tsx | 273 +++++++------ 62 files changed, 2488 insertions(+), 1079 deletions(-) delete mode 100644 packages/web/components/transactions/recent-activity/recent-activity-transaction-row.tsx rename packages/web/components/transactions/{transaction-details => }/transaction-details-modal.tsx (62%) rename packages/web/components/transactions/{transaction-details => }/transaction-details-slideover.tsx (57%) delete mode 100644 packages/web/components/transactions/transaction-details/transaction-content.tsx create mode 100644 packages/web/components/transactions/transaction-details/transaction-details-item.tsx rename packages/web/components/transactions/transaction-details/{transaction-details-content.tsx => transaction-swap-details.tsx} (98%) create mode 100644 packages/web/components/transactions/transaction-details/transaction-transfer-details.tsx rename packages/web/components/transactions/{transaction-buttons.tsx => transaction-options-menu.tsx} (78%) delete mode 100644 packages/web/components/transactions/transaction-row.tsx create mode 100644 packages/web/components/transactions/transaction-types/transaction-containers.tsx create mode 100644 packages/web/components/transactions/transaction-types/transaction-swap-row.tsx create mode 100644 packages/web/components/transactions/transaction-types/transaction-transfer-row.tsx delete mode 100644 packages/web/components/transactions/transaction-utils.tsx create mode 100644 packages/web/hooks/use-transaction-chain.ts create mode 100644 packages/web/hooks/use-transaction-history.ts diff --git a/packages/bridge/src/axelar/__tests__/axelar-transfer-status.spec.ts b/packages/bridge/src/axelar/__tests__/axelar-transfer-status.spec.ts index 2d2c9b9dd5..fdc71bbd32 100644 --- a/packages/bridge/src/axelar/__tests__/axelar-transfer-status.spec.ts +++ b/packages/bridge/src/axelar/__tests__/axelar-transfer-status.spec.ts @@ -2,7 +2,11 @@ import { rest } from "msw"; import { server } from "../../__tests__/msw"; -import { BridgeEnvironment, TransferStatusReceiver } from "../../interface"; +import { + BridgeEnvironment, + TransferStatusReceiver, + TxSnapshot, +} from "../../interface"; import { TransferStatus } from "../queries"; import { AxelarTransferStatusProvider } from "../transfer-status"; @@ -35,6 +39,43 @@ describe("AxelarTransferStatusProvider", () => { receiveNewTxStatus: jest.fn(), }; + const testSnapshot: TxSnapshot = { + direction: "deposit", + createdAtUnix: Date.now(), + type: "bridge-transfer", + provider: "Axelar", + fromAddress: "fromAddress", + toAddress: "toAddress", + osmoBech32Address: "osmoAddress", + fromAsset: { + amount: "100", + denom: "denom", + imageUrl: "imageUrl", + address: "address", + decimals: 6, + }, + toAsset: { + amount: "100", + denom: "denom", + imageUrl: "imageUrl", + address: "address", + decimals: 6, + }, + status: "pending", + sendTxHash: "testTxHash", + fromChain: { + prettyName: "Chain A", + chainId: 1, + chainType: "evm", + }, + toChain: { + prettyName: "Chain B", + chainId: 2, + chainType: "evm", + }, + estimatedArrivalUnix: Date.now() + 1000, + }; + beforeEach(() => { provider = new AxelarTransferStatusProvider("mainnet" as BridgeEnvironment); provider.statusReceiverDelegate = mockReceiver; @@ -46,7 +87,7 @@ describe("AxelarTransferStatusProvider", () => { }); it("should generate correct explorer URL", () => { - const url = provider.makeExplorerUrl("testTxHash"); + const url = provider.makeExplorerUrl(testSnapshot); expect(url).toBe("https://axelarscan.io/transfer/testTxHash"); }); @@ -62,10 +103,10 @@ describe("AxelarTransferStatusProvider", () => { ) ); - await provider.trackTxStatus("testTxHash"); + await provider.trackTxStatus(testSnapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - "AxelartestTxHash", + "testTxHash", "success", undefined ); @@ -100,10 +141,10 @@ describe("AxelarTransferStatusProvider", () => { ) ); - await provider.trackTxStatus("testTxHash"); + await provider.trackTxStatus(testSnapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - "AxelartestTxHash", + "testTxHash", "failed", "insufficientFee" ); @@ -162,10 +203,10 @@ describe("AxelarTransferStatusProvider", () => { ) ); - await provider.trackTxStatus("testTxHash"); + await provider.trackTxStatus(testSnapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - "AxelartestTxHash", + "testTxHash", "failed", undefined ); @@ -181,14 +222,8 @@ describe("AxelarTransferStatusProvider", () => { ) ); - await provider.trackTxStatus("testTxHash"); + await provider.trackTxStatus(testSnapshot); expect(mockReceiver.receiveNewTxStatus).not.toHaveBeenCalled(); }); - - it("should generate correct explorer URL with serialized params", () => { - const serializedParams = JSON.stringify({ sendTxHash: "testTxHash" }); - const url = provider.makeExplorerUrl(serializedParams); - expect(url).toBe("https://axelarscan.io/transfer/testTxHash"); - }); }); diff --git a/packages/bridge/src/axelar/transfer-status.ts b/packages/bridge/src/axelar/transfer-status.ts index 9918b77d57..7d11b966ff 100644 --- a/packages/bridge/src/axelar/transfer-status.ts +++ b/packages/bridge/src/axelar/transfer-status.ts @@ -3,16 +3,16 @@ import { poll } from "@osmosis-labs/utils"; import type { BridgeEnvironment, BridgeTransferStatus, - GetTransferStatusParams, TransferStatusProvider, TransferStatusReceiver, + TxSnapshot, } from "../interface"; import { AxelarBridgeProvider } from "."; import { getTransferStatus } from "./queries"; /** Tracks (polls Axelar endpoint) and reports status updates on Axelar bridge transfers. */ export class AxelarTransferStatusProvider implements TransferStatusProvider { - readonly keyPrefix = AxelarBridgeProvider.ID; + readonly providerId = AxelarBridgeProvider.ID; readonly sourceDisplayName = "Axelar Bridge"; public statusReceiverDelegate?: TransferStatusReceiver; @@ -31,13 +31,8 @@ export class AxelarTransferStatusProvider implements TransferStatusProvider { } /** Request to start polling a new transaction. */ - async trackTxStatus(serializedParamsOrHash: string): Promise { - const sendTxHash = serializedParamsOrHash.startsWith("{") - ? (JSON.parse(serializedParamsOrHash) as GetTransferStatusParams) - .sendTxHash - : serializedParamsOrHash; - - const snapshotKey = `${this.keyPrefix}${serializedParamsOrHash}`; + async trackTxStatus(snapshot: TxSnapshot): Promise { + const { sendTxHash } = snapshot; await poll({ fn: async () => { @@ -101,18 +96,22 @@ export class AxelarTransferStatusProvider implements TransferStatusProvider { maxAttempts: undefined, // unlimited attempts while tab is open or until success/fail }) .then((s) => { - if (s) this.receiveConclusiveStatus(snapshotKey, s); + if (s) this.receiveConclusiveStatus(sendTxHash, s); }) .catch((e) => console.error(`Polling Axelar has failed`, e)); } receiveConclusiveStatus( - key: string, + sendTxHash: string, txStatus: BridgeTransferStatus | undefined ): void { if (txStatus && txStatus.id) { const { status, reason } = txStatus; - this.statusReceiverDelegate?.receiveNewTxStatus(key, status, reason); + this.statusReceiverDelegate?.receiveNewTxStatus( + sendTxHash, + status, + reason + ); } else { console.error( "Axelar transfer finished poll but neither succeeded or failed" @@ -120,11 +119,8 @@ export class AxelarTransferStatusProvider implements TransferStatusProvider { } } - makeExplorerUrl(serializedParamsOrKey: string): string { - const txHash = serializedParamsOrKey.startsWith("{") - ? (JSON.parse(serializedParamsOrKey) as GetTransferStatusParams) - .sendTxHash - : serializedParamsOrKey; - return `${this.axelarScanBaseUrl}/transfer/${txHash}`; + makeExplorerUrl(snapshot: TxSnapshot): string { + const { sendTxHash } = snapshot; + return `${this.axelarScanBaseUrl}/transfer/${sendTxHash}`; } } diff --git a/packages/bridge/src/ibc/__tests__/ibc-transfer-status.spec.ts b/packages/bridge/src/ibc/__tests__/ibc-transfer-status.spec.ts index 6bb2ab27cc..e0ba8ec8a5 100644 --- a/packages/bridge/src/ibc/__tests__/ibc-transfer-status.spec.ts +++ b/packages/bridge/src/ibc/__tests__/ibc-transfer-status.spec.ts @@ -3,7 +3,7 @@ import { TxTracer } from "@osmosis-labs/tx"; import { MockAssetLists } from "../../__tests__/mock-asset-lists"; import { MockChains } from "../../__tests__/mock-chains"; -import { TransferStatusReceiver } from "../../interface"; +import { TransferStatusReceiver, TxSnapshot } from "../../interface"; import { IbcTransferStatusProvider } from "../transfer-status"; const makeRpcStatusResponse = ( @@ -72,13 +72,50 @@ describe("IbcTransferStatusProvider", () => { let mockReceiver: jest.Mocked; let consoleSpy: jest.SpyInstance; + const createTxSnapshot = ( + overrides: Partial = {} + ): TxSnapshot => ({ + direction: "deposit", + createdAtUnix: Date.now(), + type: "bridge-transfer", + provider: "IBC", + fromAddress: "osmo1fromaddress", + toAddress: "cosmos1toaddress", + osmoBech32Address: "osmo1osmoaddress", + fromAsset: { + denom: "OSMO", + address: "uosmo", + amount: "1000", + decimals: 6, + }, + toAsset: { + denom: "ATOM", + address: "uatom", + amount: "500", + decimals: 6, + }, + status: "pending", + sendTxHash: "ABC123", + fromChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + toChain: { + chainId: "cosmoshub-4", + prettyName: "Cosmos Hub", + chainType: "cosmos", + }, + estimatedArrivalUnix: Date.now() + 600, + ...overrides, + }); + beforeEach(() => { mockReceiver = { receiveNewTxStatus: jest.fn(), }; provider = new IbcTransferStatusProvider(MockChains, MockAssetLists); provider.statusReceiverDelegate = mockReceiver; - // silences console errors and serves as a spy to test for calls consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {}); }); @@ -89,48 +126,55 @@ describe("IbcTransferStatusProvider", () => { }); it("should handle numerical chain IDs - from chain ID", async () => { - const params = JSON.stringify({ - sendTxHash: "ABC123", - fromChainId: 1, - toChainId: "osmosis-1", + const snapshot: TxSnapshot = createTxSnapshot({ + fromChain: { + chainId: 1, + prettyName: "MockChain", + chainType: "evm", + }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(consoleSpy).toHaveBeenCalledWith( "Unexpected failure when tracing IBC transfer status", new Error("Unexpected numerical chain ID for cosmos tx: 1") ); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "connection-error" ); }); it("should handle numerical chain IDs - to chain ID", async () => { - const params = JSON.stringify({ - sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: 123, + const snapshot: TxSnapshot = createTxSnapshot({ + toChain: { + chainId: 123, + prettyName: "MockChain", + chainType: "evm", + }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(consoleSpy).toHaveBeenCalledWith( "Unexpected failure when tracing IBC transfer status", new Error("Unexpected numerical chain ID for cosmos tx: 123") ); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "connection-error" ); }); it("should handle invalid destTimeoutHeight", async () => { - const params = JSON.stringify({ + const snapshot: TxSnapshot = createTxSnapshot({ sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: "cosmoshub-4", + toChain: { + chainId: "cosmoshub-4", + prettyName: "Cosmos Hub", + chainType: "cosmos", + }, }); (queryTx as jest.Mock).mockResolvedValue({ @@ -151,34 +195,41 @@ describe("IbcTransferStatusProvider", () => { }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(consoleSpy).toHaveBeenCalledWith( "Unexpected failure when tracing IBC transfer status", new Error("Invalid destination timeout height: 123-0") ); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "connection-error" ); }); it("should handle failed transactions", async () => { (queryTx as jest.Mock).mockResolvedValue({ - // positive error code = failed tx on chain tx_response: { code: 1 }, }); - const params = JSON.stringify({ + const snapshot: TxSnapshot = createTxSnapshot({ sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: "osmosis-1", + fromChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + toChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "failed" ); }); @@ -188,16 +239,24 @@ describe("IbcTransferStatusProvider", () => { tx_response: { code: 0, raw_log: "", events: [] }, }); - const params = JSON.stringify({ + const snapshot: TxSnapshot = createTxSnapshot({ sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: "osmosis-1", + fromChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + toChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "failed" ); }); @@ -221,20 +280,27 @@ describe("IbcTransferStatusProvider", () => { }, }); (queryRPCStatus as jest.Mock).mockResolvedValue( - // not timed out, but this is irrelevant since the traceTx promise resolves immediately makeRpcStatusResponse("90") ); - const params = JSON.stringify({ + const snapshot: TxSnapshot = createTxSnapshot({ sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: "cosmoshub-4", + fromChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + toChain: { + chainId: "cosmoshub-4", + prettyName: "Cosmos Hub", + chainType: "cosmos", + }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "success" ); }); @@ -258,20 +324,27 @@ describe("IbcTransferStatusProvider", () => { }, }); (queryRPCStatus as jest.Mock).mockResolvedValue( - // not timed out, but this is irrelevant since the traceTx promise resolves immediately makeRpcStatusResponse("90", "osmosis-2") ); - const params = JSON.stringify({ + const snapshot: TxSnapshot = createTxSnapshot({ sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: "cosmoshub-4", + fromChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + toChain: { + chainId: "cosmoshub-4", + prettyName: "Cosmos Hub", + chainType: "cosmos", + }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "success" ); }); @@ -295,32 +368,36 @@ describe("IbcTransferStatusProvider", () => { }, }); (queryRPCStatus as jest.Mock).mockResolvedValueOnce( - // times out since this response block time is greater than the timeout height (100) makeRpcStatusResponse("110") ); - // cause a delay in the IBC ack tx trace to allow the timeout timeout to resolve first (TxTracer as jest.Mock).mockImplementation(() => ({ traceTx: jest .fn() .mockReturnValueOnce( - // the mock returned block time should be about 900 ms, so 1500 ms - // should be enough to ensure the timeout trace resolves first in a stable way (timing in JS is not guaranteed) new Promise((resolve) => setTimeout(resolve, 1500)) ) .mockResolvedValueOnce(undefined), close: jest.fn(), })); - const params = JSON.stringify({ + const snapshot: TxSnapshot = createTxSnapshot({ sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: "cosmoshub-4", + fromChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + toChain: { + chainId: "cosmoshub-4", + prettyName: "Cosmos Hub", + chainType: "cosmos", + }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "refunded" ); expect(TxTracer).toHaveBeenCalledTimes(2); @@ -329,32 +406,38 @@ describe("IbcTransferStatusProvider", () => { it("should handle unexpected errors", async () => { (queryTx as jest.Mock).mockRejectedValue(new Error("Network error")); - const params = JSON.stringify({ + const snapshot: TxSnapshot = createTxSnapshot({ sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: "osmosis-1", + fromChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + toChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, }); - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(consoleSpy).toHaveBeenCalledWith( "Unexpected failure when tracing IBC transfer status", new Error("Network error") ); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `IBC${params}`, + snapshot.sendTxHash, "connection-error" ); }); - it("should generate correct explorer url", async () => { - const params = JSON.stringify({ + it("should generate correct explorer url", () => { + const snapshot: TxSnapshot = createTxSnapshot({ sendTxHash: "ABC123", - fromChainId: "osmosis-1", - toChainId: "osmosis-1", }); - const url = provider.makeExplorerUrl(params); + const url = provider.makeExplorerUrl(snapshot); expect(url).toBe(`https://www.mintscan.io/osmosis/txs/ABC123`); }); diff --git a/packages/bridge/src/ibc/transfer-status.ts b/packages/bridge/src/ibc/transfer-status.ts index aa919e1be5..084bc78c97 100644 --- a/packages/bridge/src/ibc/transfer-status.ts +++ b/packages/bridge/src/ibc/transfer-status.ts @@ -4,17 +4,17 @@ import { AssetList, Chain } from "@osmosis-labs/types"; import { ChainIdHelper } from "@osmosis-labs/utils"; import { - GetTransferStatusParams, TransferStatus, TransferStatusProvider, TransferStatusReceiver, + TxSnapshot, } from "../interface"; import { IbcBridgeProvider } from "."; export type IbcTransferStatus = "pending" | "complete" | "timeout" | "refunded"; export class IbcTransferStatusProvider implements TransferStatusProvider { - readonly keyPrefix = IbcBridgeProvider.ID; + readonly providerId = IbcBridgeProvider.ID; readonly sourceDisplayName = "IBC Transfer"; public statusReceiverDelegate?: TransferStatusReceiver; @@ -35,12 +35,13 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { protected readonly connectionTimeoutMs = 60 * 1000 * 3 ) {} - async trackTxStatus(serializedParamsOrHash: string): Promise { + async trackTxStatus(snapshot: TxSnapshot): Promise { + const { + sendTxHash, + fromChain: { chainId: fromChainId }, + toChain: { chainId: toChainId }, + } = snapshot; try { - const { sendTxHash, fromChainId, toChainId } = JSON.parse( - serializedParamsOrHash - ) as GetTransferStatusParams; - if (typeof fromChainId === "number") { throw new Error( "Unexpected numerical chain ID for cosmos tx: " + fromChainId @@ -62,14 +63,14 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { if (tx_response.code) { console.error("IBC transfer status: initial tx failed:", sendTxHash); - return this.pushNewStatus(serializedParamsOrHash, "failed"); + return this.pushNewStatus(sendTxHash, "failed"); } const msgEvents = parseMsgTransferEvents(tx_response); if (!msgEvents) { console.error("IBC transfer status: no IBC events found:", sendTxHash); - return this.pushNewStatus(serializedParamsOrHash, "failed"); + return this.pushNewStatus(sendTxHash, "failed"); } await this.traceStatus({ @@ -79,11 +80,11 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { destChannelId: msgEvents.destChannelId, destTimeoutHeight: msgEvents.timeoutHeight, sequence: msgEvents.sequence, - serializedParamsOrHash, + sendTxHash, }); } catch (e) { console.error("Unexpected failure when tracing IBC transfer status", e); - this.pushNewStatus(serializedParamsOrHash, "connection-error"); + this.pushNewStatus(sendTxHash, "connection-error"); } } @@ -100,7 +101,7 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { destChannelId, destTimeoutHeight, sequence, - serializedParamsOrHash, + sendTxHash, }: { sourceChainId: string; sourceChannelId: string; @@ -108,7 +109,7 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { destChannelId: string; destTimeoutHeight: string; sequence: string; - serializedParamsOrHash: string; + sendTxHash: string; }): Promise { const destBlockSubscriber = this.getBlockSubscriber(destChainId); const subscriptions: Promise< @@ -178,9 +179,9 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { // But, if the timeout is faster than the packet received, the raced promise would return undefined because the `traceTimeoutHeight` method returns nothing. switch (result) { case "received": - return this.pushNewStatus(serializedParamsOrHash, "success"); + return this.pushNewStatus(sendTxHash, "success"); case "connection-error": - return this.pushNewStatus(serializedParamsOrHash, "connection-error"); + return this.pushNewStatus(sendTxHash, "connection-error"); } // If the packet timed out, wait until the packet timeout sent to the source chain. @@ -192,7 +193,7 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { }) .finally(() => timeoutTracer.close()); - this.pushNewStatus(serializedParamsOrHash, "refunded"); + this.pushNewStatus(sendTxHash, "refunded"); } /** @@ -247,7 +248,6 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { ); } - // eslint-disable-next-line return this.blockSubscriberMap.get(chainId)!; } @@ -267,20 +267,15 @@ export class IbcTransferStatusProvider implements TransferStatusProvider { } /** Sends a status to the receiver with prefix key prepended. */ - protected pushNewStatus( - serializedParamsOrHash: string, - status: TransferStatus - ) { - return this.statusReceiverDelegate?.receiveNewTxStatus( - `${this.keyPrefix}${serializedParamsOrHash}`, - status - ); + protected pushNewStatus(sendTxHash: string, status: TransferStatus) { + return this.statusReceiverDelegate?.receiveNewTxStatus(sendTxHash, status); } - makeExplorerUrl(serializedParamsOrKey: string): string { - const { fromChainId, sendTxHash } = JSON.parse( - serializedParamsOrKey - ) as GetTransferStatusParams; + makeExplorerUrl(snapshot: TxSnapshot): string { + const { + sendTxHash, + fromChain: { chainId: fromChainId }, + } = snapshot; const chain = this.chainList.find( (chain) => chain.chain_id === fromChainId diff --git a/packages/bridge/src/interface.ts b/packages/bridge/src/interface.ts index 9b9425702c..24c4929ce3 100644 --- a/packages/bridge/src/interface.ts +++ b/packages/bridge/src/interface.ts @@ -6,6 +6,8 @@ import type { LRUCache } from "lru-cache"; import { Address, Hex } from "viem"; import { z } from "zod"; +import { Bridge } from "./bridge-providers"; + export type BridgeEnvironment = "mainnet" | "testnet"; export interface BridgeProviderContext { @@ -445,27 +447,77 @@ export interface BridgeTransferStatus { export interface TransferStatusReceiver { /** Key with prefix (`keyPrefix`) included. */ receiveNewTxStatus( - prefixedKey: string, + sendTxHash: string, status: TransferStatus, displayReason?: string ): void; } /** A simplified transfer status. */ -export type TransferStatus = - | "success" - | "pending" - | "failed" - | "refunded" - | "connection-error"; +export const transferStatusSchema = z.enum([ + "success", + "pending", + "failed", + "refunded", + "connection-error", +]); /** A simplified reason for transfer failure. */ -export type TransferFailureReason = "insufficientFee"; +export const transferFailureReasonSchema = z.enum(["insufficientFee"]); +export type TransferFailureReason = z.infer; + +export type TransferStatus = z.infer; + +const txSnapshotSchema = z.object({ + direction: z.enum(["deposit", "withdraw"]), + createdAtUnix: z.number(), + type: z.literal("bridge-transfer"), + reason: transferFailureReasonSchema.optional(), + provider: z.string().transform((val) => val as Bridge), + fromAddress: z.string(), + toAddress: z.string(), + osmoBech32Address: z.string(), + networkFee: bridgeAssetSchema + .extend({ + amount: z.string(), + imageUrl: z.string().optional(), + }) + .optional(), + providerFee: bridgeAssetSchema + .extend({ + amount: z.string(), + imageUrl: z.string().optional(), + }) + .optional(), + fromAsset: bridgeAssetSchema.extend({ + amount: z.string(), + imageUrl: z.string().optional(), + }), + toAsset: bridgeAssetSchema.extend({ + amount: z.string().optional(), + imageUrl: z.string().optional(), + }), + status: transferStatusSchema, + sendTxHash: z.string(), + fromChain: bridgeChainSchema.and( + z.object({ + prettyName: z.string(), + }) + ), + toChain: bridgeChainSchema.and( + z.object({ + prettyName: z.string(), + }) + ), + estimatedArrivalUnix: z.number(), +}); + +export type TxSnapshot = z.infer; /** Plugin to fetch status of many transactions from a remote source. */ export interface TransferStatusProvider { /** Example: axelar */ - readonly keyPrefix: string; + readonly providerId: string; readonly sourceDisplayName?: string; /** Destination for updates to tracked transactions. */ statusReceiverDelegate?: TransferStatusReceiver; @@ -474,8 +526,8 @@ export interface TransferStatusProvider { * Source instance should begin tracking a transaction identified by `key`. * @param key Example: Tx hash without prefix i.e. `0x...` */ - trackTxStatus(key: string): void; + trackTxStatus(snapshot: TxSnapshot): void; /** Make url to this tx explorer. */ - makeExplorerUrl(key: string): string; + makeExplorerUrl(snapshot: TxSnapshot): string; } diff --git a/packages/bridge/src/skip/__tests__/skip-transfer-status.spec.ts b/packages/bridge/src/skip/__tests__/skip-transfer-status.spec.ts index 551acca3c7..9241bbd07d 100644 --- a/packages/bridge/src/skip/__tests__/skip-transfer-status.spec.ts +++ b/packages/bridge/src/skip/__tests__/skip-transfer-status.spec.ts @@ -3,7 +3,11 @@ import { rest } from "msw"; import { MockChains } from "../../__tests__/mock-chains"; import { server } from "../../__tests__/msw"; -import { BridgeEnvironment, TransferStatusReceiver } from "../../interface"; +import { + BridgeEnvironment, + TransferStatusReceiver, + TxSnapshot, +} from "../../interface"; import { SkipApiClient } from "../client"; import { SkipStatusProvider, @@ -40,6 +44,53 @@ describe("SkipTransferStatusProvider", () => { receiveNewTxStatus: jest.fn(), }; + const baseTxSnapshot: TxSnapshot = { + direction: "deposit", + createdAtUnix: Math.floor(Date.now() / 1000), + type: "bridge-transfer", + provider: "Skip", + fromAddress: "fromAddressSample", + toAddress: "toAddressSample", + osmoBech32Address: "osmoBech32AddressSample", + networkFee: { + denom: "OSMO", + address: "uosmo", + decimals: 6, + amount: "10", + }, + providerFee: { + denom: "OSMO", + address: "uosmo", + decimals: 6, + amount: "5", + }, + fromAsset: { + denom: "OSMO", + address: "uosmo", + decimals: 6, + amount: "1000", + }, + toAsset: { + denom: "ATOM", + address: "uatom", + decimals: 6, + amount: "1000", + }, + status: "pending", + sendTxHash: "testTxHash", + fromChain: { + chainId: 1, + prettyName: "Chain One", + chainType: "evm", + }, + toChain: { + chainId: 2, + prettyName: "Chain Two", + chainType: "evm", + }, + estimatedArrivalUnix: Math.floor(Date.now() / 1000) + 3600, + }; + beforeEach(() => { provider = new SkipTransferStatusProvider( "mainnet" as BridgeEnvironment, @@ -64,12 +115,12 @@ describe("SkipTransferStatusProvider", () => { }) ); - const params = JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1 }); + const snapshot = { ...baseTxSnapshot }; - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `Skip${params}`, + snapshot.sendTxHash, "success", undefined ); @@ -82,12 +133,12 @@ describe("SkipTransferStatusProvider", () => { }) ); - const params = JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1 }); + const snapshot = { ...baseTxSnapshot }; - await provider.trackTxStatus(params); + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `Skip${params}`, + snapshot.sendTxHash, "failed", undefined ); @@ -100,21 +151,23 @@ describe("SkipTransferStatusProvider", () => { }) ); - await provider.trackTxStatus( - JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1 }) - ); + const snapshot = { ...baseTxSnapshot }; + + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).not.toHaveBeenCalled(); }); it("should generate correct explorer URL", () => { - const url = provider.makeExplorerUrl( - JSON.stringify({ - sendTxHash: "testTxHash", - fromChainId: 2, - toChainId: "osmosis-1", - }) - ); + const snapshot: TxSnapshot = { + ...baseTxSnapshot, + fromChain: { + chainId: 2, + prettyName: "Chain Two", + chainType: "evm", + }, + }; + const url = provider.makeExplorerUrl(snapshot); expect(url).toBe("https://axelarscan.io/gmp/testTxHash"); }); @@ -124,13 +177,15 @@ describe("SkipTransferStatusProvider", () => { MockChains, SkipStatusProvider ); - const url = testnetProvider.makeExplorerUrl( - JSON.stringify({ - sendTxHash: "testTxHash", - fromChainId: 2, - toChainId: "osmosis-1", - }) - ); + const snapshot: TxSnapshot = { + ...baseTxSnapshot, + fromChain: { + chainId: 2, + prettyName: "Chain Two", + chainType: "evm", + }, + }; + const url = testnetProvider.makeExplorerUrl(snapshot); expect(url).toBe("https://testnet.axelarscan.io/gmp/testTxHash"); }); @@ -140,13 +195,21 @@ describe("SkipTransferStatusProvider", () => { MockChains, SkipStatusProvider ); - const url = cosmosProvider.makeExplorerUrl( - JSON.stringify({ - sendTxHash: "cosmosTxHash", - fromChainId: "cosmoshub-4", - toChainId: "osmosis-1", - }) - ); + const snapshot: TxSnapshot = { + ...baseTxSnapshot, + sendTxHash: "cosmosTxHash", + toChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + fromChain: { + chainId: "cosmoshub-4", + prettyName: "Cosmos Hub", + chainType: "cosmos", + }, + }; + const url = cosmosProvider.makeExplorerUrl(snapshot); expect(url).toBe("https://www.mintscan.io/cosmos/txs/cosmosTxHash"); }); }); diff --git a/packages/bridge/src/skip/transfer-status.ts b/packages/bridge/src/skip/transfer-status.ts index 650cce723b..468b4276c0 100644 --- a/packages/bridge/src/skip/transfer-status.ts +++ b/packages/bridge/src/skip/transfer-status.ts @@ -4,10 +4,10 @@ import { poll } from "@osmosis-labs/utils"; import type { BridgeEnvironment, BridgeTransferStatus, - GetTransferStatusParams, TransferStatus, TransferStatusProvider, TransferStatusReceiver, + TxSnapshot, } from "../interface"; import { SkipBridgeProvider } from "./index"; import { SkipTxStatusResponse } from "./types"; @@ -29,7 +29,7 @@ export interface SkipStatusProvider { /** Tracks (polls skip endpoint) and reports status updates on Skip bridge transfers. */ export class SkipTransferStatusProvider implements TransferStatusProvider { - readonly keyPrefix = SkipBridgeProvider.ID; + readonly providerId = SkipBridgeProvider.ID; readonly sourceDisplayName = "Skip Bridge"; statusReceiverDelegate?: TransferStatusReceiver | undefined; @@ -47,12 +47,11 @@ export class SkipTransferStatusProvider implements TransferStatusProvider { : "https://testnet.axelarscan.io"; } - async trackTxStatus(serializedParams: string): Promise { - const { sendTxHash, fromChainId } = JSON.parse( - serializedParams - ) as GetTransferStatusParams; - - const snapshotKey = `${this.keyPrefix}${serializedParams}`; + async trackTxStatus(snapshot: TxSnapshot): Promise { + const { + sendTxHash, + fromChain: { chainId: fromChainId }, + } = snapshot; await poll({ fn: async () => { @@ -101,14 +100,16 @@ export class SkipTransferStatusProvider implements TransferStatusProvider { }) .catch((e) => console.error(`Polling Skip has failed`, e)) .then((s) => { - if (s) this.receiveConclusiveStatus(snapshotKey, s); + if (s) this.receiveConclusiveStatus(sendTxHash, s); }); } - makeExplorerUrl(serializedParams: string): string { - const { sendTxHash, fromChainId, toChainId } = JSON.parse( - serializedParams - ) as GetTransferStatusParams; + makeExplorerUrl(snapshot: TxSnapshot): string { + const { + sendTxHash, + fromChain: { chainId: fromChainId }, + toChain: { chainId: toChainId }, + } = snapshot; if (typeof fromChainId === "number" || typeof toChainId === "number") { // EVM transfer @@ -130,12 +131,16 @@ export class SkipTransferStatusProvider implements TransferStatusProvider { } receiveConclusiveStatus( - key: string, + sendTxHash: string, txStatus: BridgeTransferStatus | undefined ): void { if (txStatus && txStatus.id) { const { status, reason } = txStatus; - this.statusReceiverDelegate?.receiveNewTxStatus(key, status, reason); + this.statusReceiverDelegate?.receiveNewTxStatus( + sendTxHash, + status, + reason + ); } else { console.error( "Skip transfer finished poll but neither succeeded or failed" diff --git a/packages/bridge/src/squid/__tests__/squid-bridge-provider.spec.ts b/packages/bridge/src/squid/__tests__/squid-bridge-provider.spec.ts index 2520d45fb5..98fd03eacf 100644 --- a/packages/bridge/src/squid/__tests__/squid-bridge-provider.spec.ts +++ b/packages/bridge/src/squid/__tests__/squid-bridge-provider.spec.ts @@ -158,6 +158,7 @@ describe("SquidBridgeProvider", () => { chainId: 1, address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", decimals: 18, + coinGeckoId: "ethereum", }, estimatedTime: 960, estimatedGasFee: { @@ -165,6 +166,7 @@ describe("SquidBridgeProvider", () => { denom: "ETH", address: "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", decimals: 18, + coinGeckoId: "ethereum", }, transactionRequest: { type: "evm", @@ -243,11 +245,13 @@ describe("SquidBridgeProvider", () => { address: "ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", decimals: 18, + coinGeckoId: "weth", }, estimatedTime: 60, estimatedGasFee: { amount: "20000", denom: "axlETH", + coinGeckoId: "weth", address: "ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", decimals: 18, diff --git a/packages/bridge/src/squid/__tests__/squid-transfer-status.spec.ts b/packages/bridge/src/squid/__tests__/squid-transfer-status.spec.ts index 0fcd39ccb8..27f4d4e2a2 100644 --- a/packages/bridge/src/squid/__tests__/squid-transfer-status.spec.ts +++ b/packages/bridge/src/squid/__tests__/squid-transfer-status.spec.ts @@ -3,7 +3,11 @@ import { rest } from "msw"; import { MockChains } from "../../__tests__/mock-chains"; import { server } from "../../__tests__/msw"; -import { BridgeEnvironment, TransferStatusReceiver } from "../../interface"; +import { + BridgeEnvironment, + TransferStatusReceiver, + TxSnapshot, +} from "../../interface"; import { SquidTransferStatusProvider } from "../transfer-status"; jest.mock("@osmosis-labs/utils", () => { @@ -35,6 +39,44 @@ describe("SquidTransferStatusProvider", () => { receiveNewTxStatus: jest.fn(), }; + const createTxSnapshot = ( + overrides: Partial = {} + ): TxSnapshot => ({ + direction: "deposit", + createdAtUnix: Date.now(), + type: "bridge-transfer", + provider: "Squid", + fromAddress: "0xFromAddress", + toAddress: "0xToAddress", + osmoBech32Address: "osmo1address", + fromAsset: { + denom: "OSMO", + address: "uosmo", + decimals: 6, + amount: "1000", + }, + toAsset: { + denom: "ATOM", + address: "uatom", + decimals: 6, + amount: "1000", + }, + status: "pending", + sendTxHash: "testTxHash", + fromChain: { + chainId: 1, + prettyName: "Chain One", + chainType: "evm", + }, + toChain: { + chainId: 2, + prettyName: "Chain Two", + chainType: "evm", + }, + estimatedArrivalUnix: Date.now() + 600, + ...overrides, + }); + beforeEach(() => { provider = new SquidTransferStatusProvider( "mainnet" as BridgeEnvironment, @@ -56,12 +98,12 @@ describe("SquidTransferStatusProvider", () => { }) ); - await provider.trackTxStatus( - JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1 }) - ); + const snapshot = createTxSnapshot(); + + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `Squid${JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1 })}`, + snapshot.sendTxHash, "success", undefined ); @@ -76,12 +118,12 @@ describe("SquidTransferStatusProvider", () => { }) ); - await provider.trackTxStatus( - JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1 }) - ); + const snapshot = createTxSnapshot(); + + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).toHaveBeenCalledWith( - `Squid${JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1 })}`, + snapshot.sendTxHash, "failed", "insufficientFee" ); @@ -94,17 +136,20 @@ describe("SquidTransferStatusProvider", () => { }) ); - await provider.trackTxStatus( - JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1 }) - ); + const snapshot = createTxSnapshot(); + + await provider.trackTxStatus(snapshot); expect(mockReceiver.receiveNewTxStatus).not.toHaveBeenCalled(); }); it("should generate correct explorer URL", () => { - const url = provider.makeExplorerUrl( - JSON.stringify({ sendTxHash: "testTxHash", fromChainId: 1, toChainId: 2 }) - ); + const snapshot = createTxSnapshot({ + sendTxHash: "testTxHash", + fromChain: { chainId: 1, prettyName: "Chain A", chainType: "evm" }, + toChain: { chainId: 2, prettyName: "Chain B", chainType: "evm" }, + }); + const url = provider.makeExplorerUrl(snapshot); expect(url).toBe("https://axelarscan.io/gmp/testTxHash"); }); @@ -113,13 +158,16 @@ describe("SquidTransferStatusProvider", () => { "testnet" as BridgeEnvironment, MockChains ); - const url = testnetProvider.makeExplorerUrl( - JSON.stringify({ - sendTxHash: "testTxHash", - fromChainId: 1, - toChainId: 2, - }) - ); + const snapshot = createTxSnapshot({ + sendTxHash: "testTxHash", + fromChain: { + chainId: 1, + prettyName: "Chain A", + chainType: "evm", + }, + toChain: { chainId: 2, prettyName: "Chain B", chainType: "evm" }, + }); + const url = testnetProvider.makeExplorerUrl(snapshot); expect(url).toBe("https://testnet.axelarscan.io/gmp/testTxHash"); }); @@ -128,13 +176,20 @@ describe("SquidTransferStatusProvider", () => { "mainnet" as BridgeEnvironment, MockChains ); - const url = cosmosProvider.makeExplorerUrl( - JSON.stringify({ - sendTxHash: "cosmosTxHash", - fromChainId: "cosmoshub-4", - toChainId: "osmosis-1", - }) - ); + const snapshot = createTxSnapshot({ + sendTxHash: "cosmosTxHash", + fromChain: { + chainId: "cosmoshub-4", + prettyName: "Cosmos Hub", + chainType: "cosmos", + }, + toChain: { + chainId: "osmosis-1", + prettyName: "Osmosis", + chainType: "cosmos", + }, + }); + const url = cosmosProvider.makeExplorerUrl(snapshot); expect(url).toBe("https://www.mintscan.io/cosmos/txs/cosmosTxHash"); }); }); diff --git a/packages/bridge/src/squid/index.ts b/packages/bridge/src/squid/index.ts index cafbbeea9a..e98b6d3a46 100644 --- a/packages/bridge/src/squid/index.ts +++ b/packages/bridge/src/squid/index.ts @@ -240,6 +240,7 @@ export class SquidBridgeProvider implements BridgeProvider { chainId: feeCosts[0].token.chainId, decimals: feeCosts[0].token.decimals, address: feeCosts[0].token.address, + coinGeckoId: feeCosts[0].token.coingeckoId, } : { ...fromAsset, @@ -254,6 +255,7 @@ export class SquidBridgeProvider implements BridgeProvider { amount: gasCosts[0].amount, decimals: gasCosts[0].token.decimals, address: gasCosts[0].token.address, + coinGeckoId: gasCosts[0].token.coingeckoId, } : { ...fromAsset, diff --git a/packages/bridge/src/squid/transfer-status.ts b/packages/bridge/src/squid/transfer-status.ts index 883ea39115..55622b638e 100644 --- a/packages/bridge/src/squid/transfer-status.ts +++ b/packages/bridge/src/squid/transfer-status.ts @@ -5,15 +5,15 @@ import { apiClient, poll } from "@osmosis-labs/utils"; import type { BridgeEnvironment, BridgeTransferStatus, - GetTransferStatusParams, TransferStatusProvider, TransferStatusReceiver, + TxSnapshot, } from "../interface"; import { SquidBridgeProvider } from "."; /** Tracks (polls squid endpoint) and reports status updates on Squid bridge transfers. */ export class SquidTransferStatusProvider implements TransferStatusProvider { - readonly keyPrefix = SquidBridgeProvider.ID; + readonly providerId = SquidBridgeProvider.ID; readonly sourceDisplayName = "Squid Bridge"; public statusReceiverDelegate?: TransferStatusReceiver; @@ -32,11 +32,12 @@ export class SquidTransferStatusProvider implements TransferStatusProvider { } /** Request to start polling a new transaction. */ - async trackTxStatus(serializedParams: string): Promise { - const { sendTxHash, fromChainId, toChainId } = JSON.parse( - serializedParams - ) as GetTransferStatusParams; - const snapshotKey = `${this.keyPrefix}${serializedParams}`; + async trackTxStatus(snapshot: TxSnapshot): Promise { + const { + sendTxHash, + fromChain: { chainId: fromChainId }, + toChain: { chainId: toChainId }, + } = snapshot; await poll({ fn: async () => { const url = new URL(`${this.apiUrl}/v1/status`); @@ -84,17 +85,21 @@ export class SquidTransferStatusProvider implements TransferStatusProvider { }) .catch((e) => console.error(`Polling Squid has failed`, e)) .then((s) => { - if (s) this.receiveConclusiveStatus(snapshotKey, s); + if (s) this.receiveConclusiveStatus(sendTxHash, s); }); } receiveConclusiveStatus( - key: string, + sendTxHash: string, txStatus: BridgeTransferStatus | undefined ): void { if (txStatus && txStatus.id) { const { status, reason } = txStatus; - this.statusReceiverDelegate?.receiveNewTxStatus(key, status, reason); + this.statusReceiverDelegate?.receiveNewTxStatus( + sendTxHash, + status, + reason + ); } else { console.error( "Squid transfer finished poll but neither succeeded or failed" @@ -102,10 +107,12 @@ export class SquidTransferStatusProvider implements TransferStatusProvider { } } - makeExplorerUrl(serializedParams: string): string { - const { sendTxHash, fromChainId, toChainId } = JSON.parse( - serializedParams - ) as GetTransferStatusParams; + makeExplorerUrl(snapshot: TxSnapshot): string { + const { + sendTxHash, + fromChain: { chainId: fromChainId }, + toChain: { chainId: toChainId }, + } = snapshot; if (typeof fromChainId === "number" || typeof toChainId === "number") { // EVM transfer diff --git a/packages/trpc/src/chains.ts b/packages/trpc/src/chains.ts index 72fab52b29..403af8888d 100644 --- a/packages/trpc/src/chains.ts +++ b/packages/trpc/src/chains.ts @@ -6,7 +6,7 @@ import { createTRPCRouter, publicProcedure } from "./api"; export const chainsRouter = createTRPCRouter({ /** Get Cosmos chain. */ - getChain: publicProcedure + getCosmosChain: publicProcedure .input( z.object({ findChainNameOrId: z.string(), diff --git a/packages/tx/src/tracer.ts b/packages/tx/src/tracer.ts index 951d3f7c6f..2318924909 100644 --- a/packages/tx/src/tracer.ts +++ b/packages/tx/src/tracer.ts @@ -303,7 +303,7 @@ export class TxTracer { const params = { query: - `tm.event='Tx' and ` + + `tm.event='Tx' AND ` + Object.keys(query) .map((key) => { return { @@ -316,10 +316,10 @@ export class TxTracer { typeof obj.value === "string" ? `'${obj.value}'` : obj.value }`; }) - .join(" and "), + .join(" AND "), page: "1", per_page: "1", - order_by: "desc", + order_by: "asc", }; return new Promise((resolve, reject) => { @@ -372,10 +372,10 @@ export class TxTracer { typeof obj.value === "string" ? `'${obj.value}'` : obj.value }`; }) - .join(" and "), + .join(" AND "), page: "1", per_page: "1", - order_by: "desc", + order_by: "asc", }; return this.query("tx_search", params); diff --git a/packages/utils/src/bitcoin.ts b/packages/utils/src/bitcoin.ts index 890fc1bce6..6baf706d74 100644 --- a/packages/utils/src/bitcoin.ts +++ b/packages/utils/src/bitcoin.ts @@ -12,12 +12,12 @@ export const BitcoinTestnetExplorerUrl = export const getBitcoinExplorerUrl = ({ txHash, - isTestnet = false, + env = "mainnet", }: { txHash: string; - isTestnet?: boolean; + env?: "mainnet" | "testnet"; }) => { - return isTestnet + return env === "testnet" ? BitcoinTestnetExplorerUrl.replace("{txHash}", txHash) : BitcoinMainnetExplorerUrl.replace("{txHash}", txHash); }; diff --git a/packages/utils/src/ethereum.ts b/packages/utils/src/ethereum.ts index 96304cc441..f8f96d737d 100644 --- a/packages/utils/src/ethereum.ts +++ b/packages/utils/src/ethereum.ts @@ -156,3 +156,19 @@ export const EthereumChainInfo = [ relativeLogoUrl: "/networks/optimism.svg", }), ] as const; + +export function getEvmExplorerUrl({ + hash, + chainId, +}: { + hash: string; + chainId: number; +}) { + const chain = EthereumChainInfo.find((chain) => chain.id === chainId); + if (!chain) return undefined; + + const explorerUrl = chain.blockExplorers?.default.url; + if (!explorerUrl) return undefined; + + return `${explorerUrl}/tx/${hash}`; +} diff --git a/packages/utils/src/solana.ts b/packages/utils/src/solana.ts index f41193e9c1..90e2683352 100644 --- a/packages/utils/src/solana.ts +++ b/packages/utils/src/solana.ts @@ -4,3 +4,14 @@ export const SolanaChainInfo = { chainName: "Solana", color: "#9945FF", }; + +export function getSolanaExplorerUrl({ + hash, + env = "mainnet", +}: { + hash: string; + env?: "mainnet" | "testnet"; +}) { + if (env === "testnet") return `https://solscan.io/tx/${hash}?cluster=testnet`; + return `https://solscan.io/tx/${hash}`; +} diff --git a/packages/web/components/assets/chain-logo.tsx b/packages/web/components/assets/chain-logo.tsx index e76b1f7174..96b2cddb1c 100644 --- a/packages/web/components/assets/chain-logo.tsx +++ b/packages/web/components/assets/chain-logo.tsx @@ -6,8 +6,9 @@ interface ChainLogoProps { color: string | undefined; logoUri: string | undefined; prettyName?: string; - size?: "xs" | "sm" | "lg"; + size?: "xs" | "sm" | "md" | "lg"; className?: string; + classes?: Partial>; } export const ChainLogo: FunctionComponent = ({ @@ -16,6 +17,7 @@ export const ChainLogo: FunctionComponent = ({ prettyName, size = "sm", className, + classes, }) => { return (
= ({ { xs: "h-4 w-4 rounded-sm", sm: "h-6 w-6 rounded-md", + md: "h-8 w-8 rounded-md", lg: "h-12 w-12 rounded-xl", }[size], - className + className, + classes?.container )} style={{ - background: color ? rgba(color, 0.3) : undefined, + background: + color === "transparent" + ? "transparent" + : color + ? rgba(color, 0.3) + : undefined, }} > {logoUri && ( {`${prettyName} { const minFee = quotes - .map((q) => q.data.transferFeeFiat?.toDec() ?? new Dec(0)) + .map((q) => q.data.totalFeeFiatValue?.toDec() ?? new Dec(0)) .reduce((acc, fee) => { if (acc === null || fee.lt(acc)) { return fee; @@ -62,7 +62,7 @@ export const BridgeProviderDropdown = ({ }, null as Dec | null); const uniqueCheapestQuotes = quotes.filter((q) => { - const feeDec = q.data.transferFeeFiat?.toDec(); + const feeDec = q.data.totalFeeFiatValue?.toDec(); return !isNil(feeDec) && !isNil(minFee) && feeDec.equals(minFee); }); @@ -109,17 +109,10 @@ export const BridgeProviderDropdown = ({ data: { provider, estimatedTime, - transferFeeFiat, - gasCostFiat, expectedOutputFiat, + totalFeeFiatValue, }, }) => { - const totalFee = transferFeeFiat - ?.add( - gasCostFiat ?? - new PricePretty(transferFeeFiat.fiatCurrency, 0) - ) - .toString(); const isSelected = selectedQuote.provider.id === provider.id; const isCheapest = cheapestQuote?.data.provider.id === provider.id; @@ -176,7 +169,7 @@ export const BridgeProviderDropdown = ({ {expectedOutputFiat.toString()}

- ~ {totalFee} {t("transfer.fee")} + ~ {totalFeeFiatValue?.toString()} {t("transfer.fee")}

diff --git a/packages/web/components/bridge/use-bridge-quotes.ts b/packages/web/components/bridge/use-bridge-quotes.ts index c0e288b30e..7c1eb399f3 100644 --- a/packages/web/components/bridge/use-bridge-quotes.ts +++ b/packages/web/components/bridge/use-bridge-quotes.ts @@ -6,7 +6,6 @@ import { BridgeError, CosmosBridgeTransactionRequest, EvmBridgeTransactionRequest, - GetTransferStatusParams, } from "@osmosis-labs/bridge"; import { DeliverTxResponse } from "@osmosis-labs/stores"; import { isNil } from "@osmosis-labs/utils"; @@ -213,6 +212,7 @@ export const useBridgeQuotes = ({ fromChain, toChain, input, + totalFeeFiatValue, } = quote; const priceImpact = new RatePretty( @@ -251,6 +251,7 @@ export const useBridgeQuotes = ({ provider, fromChain, toChain, + totalFeeFiatValue, isSlippageTooHigh: transferSlippage.gt(new Dec(0.06)), // warn if expected output is less than 6% of input amount isPriceImpactTooHigh: priceImpact.toDec().gte(new Dec(0.1)), // warn if price impact is greater than 10%. }; @@ -430,26 +431,57 @@ export const useBridgeQuotes = ({ const [transferInitiated, setTransferInitiated] = useState(false); const trackTransferStatus = useCallback( ({ - estimatedArrivalUnix, - providerId, - params, + sendTxHash, + quote, }: { - estimatedArrivalUnix: number; - providerId: Bridge; - params: GetTransferStatusParams; + sendTxHash: string; + quote: NonNullable["quote"]; }) => { - if (inputAmountRaw !== "" && availableBalance && inputCoin) { + if ( + inputAmountRaw !== "" && + availableBalance && + inputCoin && + fromAsset && + toAsset && + fromChain && + toChain && + fromAddress && + toAddress + ) { transferHistoryStore.pushTxNow({ - prefixedKey: `${providerId}${JSON.stringify(params)}`, - amount: inputCoin.trim(true).toString(), - amountLogo: isWithdraw ? toAsset?.imageUrl : fromAsset.imageUrl, - isWithdraw, - chainPrettyName: - direction === "deposit" - ? fromChain?.prettyName ?? "" - : toChain?.prettyName ?? "", - estimatedArrivalUnix, - accountAddress: (isWithdraw ? fromAddress : toAddress) ?? "", // use osmosis account for account keys (vs any EVM account) + createdAtUnix: dayjs().unix(), + direction, + fromAsset: { + ...fromAsset, + amount: inputCoin.trim(true).toCoin().amount, + }, + fromAddress, + toAddress, + fromChain, + toChain, + toAsset, + provider: quote.provider.id, + osmoBech32Address: (isWithdraw ? fromAddress : toAddress) ?? "", // use osmosis account for account keys (vs any EVM account), + sendTxHash, + status: "pending", + type: "bridge-transfer", + estimatedArrivalUnix: dayjs().unix() + quote.estimatedTime, + networkFee: quote.estimatedGasFee + ? { + address: quote.estimatedGasFee.amount.currency.coinMinimalDenom, + denom: quote.estimatedGasFee.amount.currency.coinDenom, + decimals: quote.estimatedGasFee.amount.currency.coinDecimals, + amount: quote.estimatedGasFee.amount.toCoin().amount, + } + : undefined, + providerFee: quote.transferFee + ? { + denom: quote.transferFee.amount.currency.coinDenom, + address: quote.transferFee.amount.currency.coinMinimalDenom, + decimals: quote.transferFee.amount.currency.coinDecimals, + amount: quote.transferFee.amount.toCoin().amount, + } + : undefined, }); } }, @@ -457,14 +489,14 @@ export const useBridgeQuotes = ({ availableBalance, direction, fromAddress, - fromAsset?.imageUrl, - fromChain?.prettyName, + fromAsset, + fromChain, inputAmountRaw, inputCoin, isWithdraw, toAddress, - toAsset?.imageUrl, - toChain?.prettyName, + toAsset, + toChain, transferHistoryStore, ] ); @@ -549,13 +581,8 @@ export const useBridgeQuotes = ({ }); trackTransferStatus({ - estimatedArrivalUnix: dayjs().unix() + quote.estimatedTime, - providerId: quote.provider.id, - params: { - sendTxHash: sendTxHash as string, - fromChainId: quote.fromChain.chainId, - toChainId: quote.toChain.chainId, - }, + quote, + sendTxHash, }); // TODO: Investigate if this is still needed @@ -631,13 +658,8 @@ export const useBridgeQuotes = ({ } trackTransferStatus({ - estimatedArrivalUnix: dayjs().unix() + quote.estimatedTime, - providerId: quote.provider.id, - params: { - sendTxHash: tx.transactionHash, - fromChainId: quote.fromChain.chainId, - toChainId: quote.toChain.chainId, - }, + sendTxHash: tx.transactionHash, + quote, }); onTransferProp?.(); diff --git a/packages/web/components/buttons/copy-icon-button.tsx b/packages/web/components/buttons/copy-icon-button.tsx index a344bd6c7e..5c62ef93ef 100644 --- a/packages/web/components/buttons/copy-icon-button.tsx +++ b/packages/web/components/buttons/copy-icon-button.tsx @@ -1,3 +1,4 @@ +import classNames from "classnames"; import { useState } from "react"; import { useCopyToClipboard, useTimeoutFn } from "react-use"; @@ -7,9 +8,11 @@ import { CopyIcon, Icon } from "~/components/assets"; export const CopyIconButton = ({ valueToCopy, label, + classes, }: { valueToCopy: string; label: string | JSX.Element; + classes?: Partial>; }) => { const [hasCopied, setHasCopied] = useState(false); const [, copyToClipboard] = useCopyToClipboard(); @@ -23,10 +26,15 @@ export const CopyIconButton = ({ return ( + )} + + + ); +}; diff --git a/packages/web/components/transactions/transaction-buttons.tsx b/packages/web/components/transactions/transaction-options-menu.tsx similarity index 78% rename from packages/web/components/transactions/transaction-buttons.tsx rename to packages/web/components/transactions/transaction-options-menu.tsx index 549d7c7dab..07055a8531 100644 --- a/packages/web/components/transactions/transaction-buttons.tsx +++ b/packages/web/components/transactions/transaction-options-menu.tsx @@ -1,11 +1,16 @@ -import { Popover, Transition } from "@headlessui/react"; +import { + Popover, + PopoverButton, + PopoverPanel, + Transition, +} from "@headlessui/react"; import classNames from "classnames"; import Link from "next/link"; import { EventName } from "~/config"; import { useAmplitudeAnalytics, useTranslation } from "~/hooks"; -export const TransactionButtons = ({ +export const TransactionOptionsMenu = ({ address, }: { open: boolean; @@ -29,9 +34,9 @@ export const TransactionButtons = ({ return ( - + ⋯ - + - + {options.map(({ id, href, description }, i, original) => ( {description} ))} - + ); diff --git a/packages/web/components/transactions/transaction-pagination.tsx b/packages/web/components/transactions/transaction-pagination.tsx index b476dba1da..9bdf99de1c 100644 --- a/packages/web/components/transactions/transaction-pagination.tsx +++ b/packages/web/components/transactions/transaction-pagination.tsx @@ -8,7 +8,7 @@ import { PaginationPrevious, } from "~/components/ui/pagination"; -const TransactionsPaginaton = ({ +const TransactionsPagination = ({ showPrevious, showNext, previousHref, @@ -37,4 +37,4 @@ const TransactionsPaginaton = ({ ); }; -export { TransactionsPaginaton }; +export { TransactionsPagination as TransactionsPaginaton }; diff --git a/packages/web/components/transactions/transaction-row.tsx b/packages/web/components/transactions/transaction-row.tsx deleted file mode 100644 index 0d9dad692a..0000000000 --- a/packages/web/components/transactions/transaction-row.tsx +++ /dev/null @@ -1,228 +0,0 @@ -import { CoinPretty, PricePretty } from "@keplr-wallet/unit"; -import classNames from "classnames"; -import { FunctionComponent } from "react"; - -import { FallbackImg, Icon } from "~/components/assets"; -import { theme } from "~/tailwind.config"; -import { formatFiatPrice, formatPretty } from "~/utils/formatter"; - -import { Spinner } from "../loaders"; - -type TransactionStatus = "pending" | "success" | "failed"; - -type Effect = "swap" | "deposit" | "withdraw"; - -export interface TransactionRow { - isSelected?: boolean; - status: TransactionStatus; - /** At a high level- what this transaction does. */ - effect: Effect; - title: { - [key in TransactionStatus]: string; - }; - caption?: string; - tokenConversion?: { - tokenIn: { - amount: CoinPretty; - value?: PricePretty; - }; - tokenOut: { - amount: CoinPretty; - value?: PricePretty; - }; - }; - transfer?: { - direction: "deposit" | "withdraw"; - amount: CoinPretty; - value?: PricePretty; - }; - onClick?: () => void; - hash?: string; -} - -export const TransactionRow: FunctionComponent = ({ - isSelected = false, - status, - effect, - title, - caption, - tokenConversion, - transfer, - onClick, - hash, -}) => { - const effectIconId = effect === "swap" ? "swap" : "down-arrow"; - - return ( -
onClick?.()} - > -
- {status === "pending" ? ( - - ) : ( -
- {status === "success" ? ( - effect === "withdraw" ? ( - - ) : ( - - ) - ) : ( - - )} -
- )} - -
-

- {title[status]} -

- {tokenConversion && ( -
- {formatPretty(tokenConversion.tokenOut.amount, { - maxDecimals: 6, - })} - -
- )} -
-
- {caption &&

{caption}

} - {tokenConversion && ( - - )} - {transfer && } -
- ); -}; - -/** UI for displaying one token being converted into another by this transaction. */ -const TokenConversion: FunctionComponent< - { status: TransactionStatus; effect: Effect } & NonNullable< - TransactionRow["tokenConversion"] - > -> = ({ status, tokenIn, tokenOut, effect }) => { - return ( -
-
-
- {tokenIn.value && ( -
- {formatPretty(tokenIn.amount, { maxDecimals: 6 })} -
- )} -
- {tokenIn.value && `- ${formatFiatPrice(tokenIn.value)}`} -
-
- -
- -
- -
- {tokenOut.value && ( -
- {formatPretty(tokenOut.amount, { maxDecimals: 6 })} -
- )} -
- {tokenOut.value && `+ ${formatFiatPrice(tokenOut.value)}`} -
-
-
-
- ); -}; - -/** UI for displaying a token being deposited or withdrawn from Osmosis. */ -const TokenTransfer: FunctionComponent< - { - status: TransactionStatus; - } & NonNullable -> = ({ status, direction, amount, value }) => ( -
- -
- {formatPretty(amount, { maxDecimals: 6 })} -
- {value && ( -
- {direction === "withdraw" ? "-" : "+"} {value.symbol} - {Number(value.toDec().abs().toString()).toFixed(2)} -
- )} -
-); diff --git a/packages/web/components/transactions/transaction-rows.tsx b/packages/web/components/transactions/transaction-rows.tsx index 5045902086..d442c325b6 100644 --- a/packages/web/components/transactions/transaction-rows.tsx +++ b/packages/web/components/transactions/transaction-rows.tsx @@ -1,12 +1,51 @@ -import { FormattedTransaction } from "@osmosis-labs/server"; +import dayjs from "dayjs"; +import isToday from "dayjs/plugin/isToday"; +import isYesterday from "dayjs/plugin/isYesterday"; -import { TransactionRow } from "~/components/transactions/transaction-row"; -import { - groupTransactionsByDate, - useFormatDate, -} from "~/components/transactions/transaction-utils"; +import { TransactionSwapRow } from "~/components/transactions/transaction-types/transaction-swap-row"; +import { TransactionTransferRow } from "~/components/transactions/transaction-types/transaction-transfer-row"; import { EventName } from "~/config"; import { useAmplitudeAnalytics, useTranslation } from "~/hooks"; +import { HistoryTransaction } from "~/hooks/use-transaction-history"; + +dayjs.extend(isToday); +dayjs.extend(isYesterday); + +const formatDate = ( + t: ReturnType["t"], + dateString: string +) => { + const date = dayjs(dateString); + if (date.isToday()) return t("date.earlierToday"); + if (date.isYesterday()) return t("date.yesterday"); + + const month = date.format("MMMM"); + + if (date.isSame(dayjs(), "year")) return `${month} ${date.format("D")}`; + + return `${month} ${date.format("D, YYYY")}`; +}; + +const groupTransactionsByDate = ( + transactions: HistoryTransaction[] +): Record => { + return transactions.reduce((acc, transaction) => { + // extract date from block timestamp + const date = dayjs( + transaction.__type === "recentTransfer" + ? transaction.compareDate + : transaction.blockTimestamp + ).format("YYYY-MM-DD"); + + if (!acc[date]) { + acc[date] = []; + } + + acc[date].push(transaction); + + return acc; + }, {} as Record); +}; export const TransactionRows = ({ transactions, @@ -15,13 +54,12 @@ export const TransactionRows = ({ setOpen, open, }: { - transactions: FormattedTransaction[]; + transactions: HistoryTransaction[]; selectedTransactionHash?: string; setSelectedTransactionHash: (hash: string) => void; setOpen: (open: boolean) => void; open: boolean; }) => { - const formatDate = useFormatDate(); const { logEvent } = useAmplitudeAnalytics(); const { t } = useTranslation(); @@ -31,65 +69,101 @@ export const TransactionRows = ({ ([date, transactions]) => (
- {formatDate(date)} + {formatDate(t, date)}

{transactions .map((transaction) => { - const isSelected = selectedTransactionHash === transaction.hash; - return ( - { - // TODO - once there are more transaction types, we can add more event names - logEvent([ - EventName.TransactionsPage.swapClicked, - { - tokenIn: - transaction.metadata[0].value[0].txInfo.tokenIn - .token.denom, - tokenOut: + if (transaction.__type === "transaction") { + const isSelected = + selectedTransactionHash === transaction.hash; + return ( + setOpen(true), 1); - } - }} - tokenConversion={{ - tokenIn: { - amount: - transaction?.metadata?.[0]?.value?.[0]?.txInfo - ?.tokenIn?.token, - value: - transaction?.metadata?.[0]?.value?.[0]?.txInfo - ?.tokenIn?.usd, - }, - tokenOut: { - amount: - transaction?.metadata?.[0]?.value?.[0]?.txInfo - ?.tokenOut?.token, - - value: - transaction.metadata[0].value[0].txInfo.tokenOut.usd, - }, - }} - /> - ); + }} + onClick={() => { + // TODO - once there are more transaction types, we can add more event names + logEvent([ + EventName.TransactionsPage.swapClicked, + { + tokenIn: + transaction.metadata[0].value[0].txInfo.tokenIn + .token.denom, + tokenOut: + transaction.metadata[0].value[0].txInfo.tokenOut + .token.denom, + }, + ]); + + setSelectedTransactionHash(transaction.hash); + + // delay to ensure the slide over transitions smoothly + if (!open) { + setTimeout(() => setOpen(true), 1); + } + }} + /> + ); + } + + if (transaction.__type === "recentTransfer") { + const isSelected = + selectedTransactionHash === transaction.sendTxHash; + return ( + { + // TODO - once there are more transaction types, we can add more event names + // logEvent([ + // EventName.TransactionsPage.swapClicked, + // { + // tokenIn: + // transaction.metadata[0].value[0].txInfo.tokenIn + // .token.denom, + // tokenOut: + // transaction.metadata[0].value[0].txInfo.tokenOut + // .token.denom, + // }, + // ]); + + setSelectedTransactionHash(transaction.sendTxHash); + + // delay to ensure the slide over transitions smoothly + if (!open) { + setTimeout(() => setOpen(true), 1); + } + }} + hash={transaction.sendTxHash} + isSelected={isSelected} + /> + ); + } + + return null; }) // filters out any transactions with missing metadata .filter(Boolean)} diff --git a/packages/web/components/transactions/transaction-types/transaction-containers.tsx b/packages/web/components/transactions/transaction-types/transaction-containers.tsx new file mode 100644 index 0000000000..801f2aa137 --- /dev/null +++ b/packages/web/components/transactions/transaction-types/transaction-containers.tsx @@ -0,0 +1,119 @@ +import classNames from "classnames"; +import React from "react"; + +import { Icon } from "~/components/assets"; +import { SpriteIconId } from "~/config"; +import { theme } from "~/tailwind.config"; + +export const SmallTransactionContainer = ({ + status, + title, + leftComponent, + rightComponent, + isSelected, + onClick, +}: { + status: "pending" | "failed" | "success"; + title: { [key in "pending" | "failed" | "success"]: string }; + leftComponent: JSX.Element | null; + rightComponent: JSX.Element | null; + isSelected?: boolean; + onClick?: () => void; +}) => ( +
onClick?.()} + > +
+

+ {title[status]} +

+ {leftComponent} +
+
{rightComponent}
+
+); + +export const LargeTransactionContainer = ({ + iconId, + status, + title, + rightComponent, + isSelected, + onClick, + hash, +}: { + iconId: SpriteIconId; + status: "pending" | "failed" | "success"; + title: { [key in "pending" | "failed" | "success"]: string }; + rightComponent: JSX.Element | null; + isSelected?: boolean; + onClick?: () => void; + hash?: string; +}) => ( +
onClick?.()} + > +
+
+ {status === "success" || status === "pending" ? ( + + ) : ( + + )} +
+ +
+

+ {title[status]} +

+
+
+ + {rightComponent} +
+); diff --git a/packages/web/components/transactions/transaction-types/transaction-swap-row.tsx b/packages/web/components/transactions/transaction-types/transaction-swap-row.tsx new file mode 100644 index 0000000000..6898a09e3f --- /dev/null +++ b/packages/web/components/transactions/transaction-types/transaction-swap-row.tsx @@ -0,0 +1,177 @@ +import { CoinPretty, PricePretty } from "@keplr-wallet/unit"; +import classNames from "classnames"; +import React from "react"; + +import { FallbackImg, Icon } from "~/components/assets"; +import { + LargeTransactionContainer, + SmallTransactionContainer, +} from "~/components/transactions/transaction-types/transaction-containers"; +import { useWindowSize } from "~/hooks"; +import { useTranslation } from "~/hooks/language/context"; +import { formatFiatPrice, formatPretty } from "~/utils/formatter"; + +interface TransactionSwapRowProps { + size: "sm" | "lg"; + transaction: { + code: number; + tokenIn: { + amount: CoinPretty; + value?: PricePretty; + }; + tokenOut: { + amount: CoinPretty; + value?: PricePretty; + }; + }; + isSelected?: boolean; + onClick?: () => void; + hash?: string; +} + +export const TransactionSwapRow = ({ + transaction, + size: sizeProp, + isSelected, + onClick, + hash, +}: TransactionSwapRowProps) => { + const { t } = useTranslation(); + const { isMobile } = useWindowSize(); + + const size = isMobile ? "sm" : sizeProp; + + const status = transaction.code === 0 ? "success" : "failed"; + + const leftComponent = ( +
+
+ {transaction.tokenIn?.value && + formatFiatPrice(transaction.tokenIn?.value)}{" "} + {transaction.tokenIn.amount.denom}{" "} + {" "} + {transaction.tokenOut.amount.denom} +
+
+ ); + + const rightComponent = + size === "sm" ? ( +
+ + + +
+ ) : ( +
+
+
+ {transaction.tokenIn.value && ( +
+ {formatPretty(transaction.tokenIn.amount, { maxDecimals: 6 })} +
+ )} +
+ {transaction.tokenIn.value && + `- ${formatFiatPrice(transaction.tokenIn.value)}`} +
+
+ +
+ +
+ +
+ {transaction.tokenOut.value && ( +
+ {formatPretty(transaction.tokenOut.amount, { maxDecimals: 6 })} +
+ )} +
+ {transaction.tokenOut.value && + `+ ${formatFiatPrice(transaction.tokenOut.value)}`} +
+
+
+
+ ); + + if (size === "sm") { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/web/components/transactions/transaction-types/transaction-transfer-row.tsx b/packages/web/components/transactions/transaction-types/transaction-transfer-row.tsx new file mode 100644 index 0000000000..4229310948 --- /dev/null +++ b/packages/web/components/transactions/transaction-types/transaction-transfer-row.tsx @@ -0,0 +1,353 @@ +import { CoinPretty, Dec } from "@keplr-wallet/unit"; +import { TxSnapshot } from "@osmosis-labs/bridge"; +import { shorten } from "@osmosis-labs/utils"; +import classNames from "classnames"; +import React from "react"; + +import { FallbackImg, Icon } from "~/components/assets"; +import { ChainLogo } from "~/components/assets/chain-logo"; +import { SkeletonLoader, Spinner } from "~/components/loaders"; +import { + LargeTransactionContainer, + SmallTransactionContainer, +} from "~/components/transactions/transaction-types/transaction-containers"; +import { useWindowSize } from "~/hooks"; +import { useTranslation } from "~/hooks/language/context"; +import { useCoinFiatValue } from "~/hooks/queries/assets/use-coin-fiat-value"; +import { useTransactionChain } from "~/hooks/use-transaction-chain"; +import { formatPretty } from "~/utils/formatter"; + +interface TransactionTransferRowProps { + size: "sm" | "lg"; + transaction: TxSnapshot; + isSelected?: boolean; + onClick?: () => void; + hash?: string; +} + +export const TransactionTransferRow = ({ + transaction, + size: sizeProp, + isSelected, + onClick, + hash, +}: TransactionTransferRowProps) => { + const { t } = useTranslation(); + const { isMobile, width } = useWindowSize(); + + const size = isMobile ? "sm" : sizeProp; + + const chain = + transaction.direction === "withdraw" + ? transaction.toChain + : transaction.fromChain; + + const { chainPrettyName, chainLogoUri, chainColor } = useTransactionChain({ + chain, + }); + + const text = + transaction.direction === "withdraw" + ? t("transfer.to").toLowerCase() + : t("transfer.from").toLowerCase(); + + const simplifiedStatus = (() => { + if (transaction.status === "success") return "success"; + if (["refunded", "connection-error", "failed"].includes(transaction.status)) + return "failed"; + return "pending"; + })(); + + const fromAsset = new CoinPretty( + { + coinDecimals: transaction.fromAsset.decimals, + coinDenom: transaction.fromAsset.denom, + coinMinimalDenom: transaction.fromAsset.address, + coinImageUrl: transaction.fromAsset.imageUrl, + }, + new Dec(transaction.fromAsset.amount) + ); + const toAsset = new CoinPretty( + { + coinDecimals: transaction.toAsset.decimals, + coinDenom: transaction.toAsset.denom, + coinMinimalDenom: transaction.toAsset.address, + coinImageUrl: transaction.toAsset.imageUrl, + }, + new Dec(transaction.fromAsset.amount) + ); + + const leftComponent = ( +
+ {formatPretty(fromAsset, { maxDecimals: 6 })} {text} {chainPrettyName} +
+ ); + + const rightSmallComponentList = [ +
+ {simplifiedStatus === "pending" && + transaction?.direction === "deposit" && ( + + )} + +
, + , +
+ {simplifiedStatus === "pending" && + transaction?.direction === "withdraw" && ( + + )} + +
, + ]; + + const rightLargeComponentList = [ +
+ {transaction.direction === "withdraw" && ( +
+

+ {formatPretty(fromAsset)} +

+ +
+ )} +
+ {simplifiedStatus === "pending" && + transaction?.direction === "deposit" && ( + + )} + +
+ {transaction.direction === "deposit" && ( +
+

+ {formatPretty(toAsset)} +

+ +
+ )} +
, + , +
+ {transaction.direction === "deposit" && ( +
+

+ {t("transfer.from")} {chainPrettyName} +

+

+ {shorten(transaction.toAddress, { + prefixLength: width < 924 ? 4 : 10, + suffixLength: width < 924 ? 4 : 8, + })} +

+
+ )} +
+ {simplifiedStatus === "pending" && + transaction?.direction === "withdraw" && ( + + )} + +
+ {transaction.direction === "withdraw" && ( +
+

+ {t("transfer.to")} {chainPrettyName} +

+

+ {shorten(transaction.toAddress, { + prefixLength: width < 924 ? 4 : 10, + suffixLength: width < 924 ? 4 : 8, + })} +

+
+ )} +
, + ]; + + const rightComponent = + size === "lg" ? ( +
+ {transaction?.direction === "withdraw" + ? rightLargeComponentList + : rightLargeComponentList.reverse()} +
+ ) : ( + <> + {transaction?.direction === "withdraw" + ? rightSmallComponentList + : rightSmallComponentList.reverse()} + + ); + + const pendingText = + transaction.direction === "withdraw" + ? t("transactions.historyTable.pendingWithdraw") + : t("transactions.historyTable.pendingDeposit"); + const successText = + transaction.direction === "withdraw" + ? t("transactions.historyTable.successWithdraw") + : t("transactions.historyTable.successDeposit"); + const failedText = + transaction.direction === "withdraw" + ? t("transactions.historyTable.failWithdraw") + : t("transactions.historyTable.failDeposit"); + + if (size === "sm") { + return ( + + ); + } + + return ( + + ); +}; + +const Price = ({ amount }: { amount: CoinPretty }) => { + const { fiatValue, isLoading } = useCoinFiatValue(amount); + + if (!fiatValue && !isLoading) return null; + + return ( + +

+ {fiatValue?.toString()} +

+
+ ); +}; diff --git a/packages/web/components/transactions/transaction-utils.tsx b/packages/web/components/transactions/transaction-utils.tsx deleted file mode 100644 index deeedf4109..0000000000 --- a/packages/web/components/transactions/transaction-utils.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { FormattedTransaction } from "@osmosis-labs/server"; -import dayjs from "dayjs"; -import isToday from "dayjs/plugin/isToday"; -import isYesterday from "dayjs/plugin/isYesterday"; -import { useTranslation } from "hooks"; - -dayjs.extend(isToday); -dayjs.extend(isYesterday); - -// TODO: move this to formatter or colocate with transactions files. Can import `t` directly. Then delete this file - -export const groupTransactionsByDate = ( - transactions: FormattedTransaction[] -): Record => { - return transactions.reduce((acc, transaction) => { - // extract date from block timestamp - const date = dayjs(transaction.blockTimestamp).format("YYYY-MM-DD"); - - if (!acc[date]) { - acc[date] = []; - } - - acc[date].push(transaction); - - return acc; - }, {} as Record); -}; - -export const useFormatDate = () => { - const { t } = useTranslation(); - - const formatDate = (dateString: string) => { - const date = dayjs(dateString); - if (date.isToday()) return t("date.earlierToday"); - if (date.isYesterday()) return t("date.yesterday"); - - const month = date.format("MMMM"); - - if (date.isSame(dayjs(), "year")) return `${month} ${date.format("D")}`; - - return `${month} ${date.format("D, YYYY")}`; - }; - - return formatDate; -}; diff --git a/packages/web/hooks/use-transaction-chain.ts b/packages/web/hooks/use-transaction-chain.ts new file mode 100644 index 0000000000..cfddd9aa39 --- /dev/null +++ b/packages/web/hooks/use-transaction-chain.ts @@ -0,0 +1,37 @@ +import { BridgeChain } from "@osmosis-labs/bridge"; + +import { api } from "~/utils/trpc"; + +export const useTransactionChain = ({ chain }: { chain: BridgeChain }) => { + const { data: cosmosChain } = api.edge.chains.getCosmosChain.useQuery( + { + findChainNameOrId: chain!.chainId.toString(), + }, + { + enabled: chain?.chainType === "cosmos", + useErrorBoundary: false, + } + ); + const { data: evmChain } = api.edge.chains.getEvmChain.useQuery( + { + chainId: Number(chain!.chainId), + }, + { + enabled: chain?.chainType === "evm", + useErrorBoundary: false, + } + ); + + const chainPrettyName = + chain?.chainType === "cosmos" ? cosmosChain?.pretty_name : evmChain?.name; + const chainLogoUri = + chain?.chainType === "cosmos" + ? cosmosChain?.logoURIs?.png ?? cosmosChain?.logoURIs?.svg + : evmChain?.relativeLogoUrl; + const chainColor = + chain?.chainType === "cosmos" + ? cosmosChain?.logoURIs?.theme?.primary_color_hex + : evmChain?.color; + + return { chainPrettyName, chainLogoUri, chainColor, cosmosChain, evmChain }; +}; diff --git a/packages/web/hooks/use-transaction-history.ts b/packages/web/hooks/use-transaction-history.ts new file mode 100644 index 0000000000..6f7f39d554 --- /dev/null +++ b/packages/web/hooks/use-transaction-history.ts @@ -0,0 +1,83 @@ +import { useMemo } from "react"; + +import { useStore } from "~/stores"; +import { api } from "~/utils/trpc"; + +export type HistoryTransaction = ReturnType< + typeof useTransactionHistory +>["transactions"][number]; + +export type HistorySwapTransaction = Extract< + HistoryTransaction, + { __type: "transaction" } +>; +export type HistoryBridgeTransaction = Extract< + HistoryTransaction, + { __type: "recentTransfer" } +>; + +export const useTransactionHistory = ({ + pageSize = "100", + pageNumber = "0", +}: { + pageNumber?: string; + pageSize?: string; +} = {}) => { + const { accountStore, transferHistoryStore } = useStore(); + + const wallet = accountStore.getWallet(accountStore.osmosisChainId); + const recentTransfers = transferHistoryStore.getHistoriesByAccount( + wallet?.address || "" + ); + + const { data: transactionsData, isLoading } = + api.edge.transactions.getTransactions.useQuery( + { + address: wallet?.address || "", + page: pageNumber, + pageSize, + }, + { + enabled: Boolean(wallet?.isWalletConnected && wallet?.address), + } + ); + + const { transactions, hasNextPage } = useMemo( + () => + transactionsData ?? { + transactions: [], + hasNextPage: false, + }, + [transactionsData] + ); + + const mergedActivity = useMemo( + () => [ + ...transactions.map((tx) => ({ + ...tx, + __type: "transaction" as const, + compareDate: new Date(tx.blockTimestamp), + })), + ...recentTransfers.map((transfer) => ({ + ...transfer, + __type: "recentTransfer" as const, + compareDate: transfer.createdAt, + })), + ], + [transactions, recentTransfers] + ); + + const sortedActivity = useMemo( + () => + mergedActivity.sort( + (a, b) => b.compareDate.getTime() - a.compareDate.getTime() + ), + [mergedActivity] + ); + + return { + transactions: sortedActivity, + hasNextPage, + isLoading, + }; +}; diff --git a/packages/web/knip.json b/packages/web/knip.json index 3418f57a9b..83f7ade61d 100644 --- a/packages/web/knip.json +++ b/packages/web/knip.json @@ -1,9 +1,11 @@ { + "$schema": "https://unpkg.com/knip@5/schema.json", "ignore": [ ".next/**/*", "**/tradingview/**", "**/light-weight-charts/**", "**/generated/**", "*.spec.*" - ] + ], + "ignoreDependencies": ["chokidar-cli", "concurrently", "dotenv-cli"] } diff --git a/packages/web/localizations/de.json b/packages/web/localizations/de.json index 49c332c6c3..0104cb55c7 100644 --- a/packages/web/localizations/de.json +++ b/packages/web/localizations/de.json @@ -1103,9 +1103,23 @@ "totalFees": "Gesamtkosten", "transactionHash": "Transaktions-Hash", "viewOnExplorer": "Im Explorer anzeigen", - "launchAlert": "Derzeit wird nur der Handelsverlauf angezeigt. Unterstützung für weitere Transaktionstypen folgt in Kürze.", "history": "Geschichte", - "orders": "Aufträge" + "orders": "Aufträge", + "historyTable": { + "pendingDeposit": "Ausstehende Einzahlung", + "pendingWithdraw": "Ausstehende Auszahlung", + "successDeposit": "Hinterlegt", + "successWithdraw": "Zurückgezogen", + "failDeposit": "Einzahlung fehlgeschlagen", + "failWithdraw": "Auszahlung fehlgeschlagen" + }, + "transfer": { + "asset": "Vermögenswert", + "amountAsset": "Betrag {asset}", + "totalValue": "Gesamtwert", + "providerFee": "Anbietergebühr", + "networkFee": "Netzwerkgebühr" + } }, "date": { "earlierToday": "Heute früh", diff --git a/packages/web/localizations/en.json b/packages/web/localizations/en.json index a75f5dca6e..95fac273ac 100644 --- a/packages/web/localizations/en.json +++ b/packages/web/localizations/en.json @@ -1086,7 +1086,7 @@ }, "transactions": { "noRecent": "No recent transactions", - "recentShownHere": "Recent swaps will appear here.", + "recentShownHere": "Recent swaps, deposits and withdrawals will appear here.", "noTransactions": "No transactions", "pastShownHere": "Your past trades on Osmosis will appear here.", "connectToSee": "Connect to see your transaction history", @@ -1103,9 +1103,24 @@ "totalFees": "Total Fees", "transactionHash": "Transaction Hash", "viewOnExplorer": "View on explorer", - "launchAlert": "Currently only trade history is displayed. Support for more transaction types coming soon.", + "launchAlert": "Currently only trade and transfer history is displayed. Support for more transaction types coming soon.", "history": "History", - "orders": "Orders" + "orders": "Orders", + "historyTable": { + "pendingDeposit": "Pending deposit", + "pendingWithdraw": "Pending withdraw", + "successDeposit": "Deposited", + "successWithdraw": "Withdrew", + "failDeposit": "Deposit failed", + "failWithdraw": "Withdraw failed" + }, + "transfer": { + "asset": "Asset", + "amountAsset": "Amount {asset}", + "totalValue": "Total value", + "providerFee": "Provider fee", + "networkFee": "Network fee" + } }, "date": { "earlierToday": "Earlier today", diff --git a/packages/web/localizations/es.json b/packages/web/localizations/es.json index 2b08af7597..6facdd7429 100644 --- a/packages/web/localizations/es.json +++ b/packages/web/localizations/es.json @@ -1103,9 +1103,23 @@ "totalFees": "Tarifas totales", "transactionHash": "Hash de transacción", "viewOnExplorer": "Ver en el explorador", - "launchAlert": "Actualmente solo se muestra el historial comercial. Próximamente soporte para más tipos de transacciones.", "history": "Historia", - "orders": "Pedidos" + "orders": "Pedidos", + "historyTable": { + "pendingDeposit": "Depósito pendiente", + "pendingWithdraw": "Pendiente de retiro", + "successDeposit": "Depositado", + "successWithdraw": "Se retiró", + "failDeposit": "El depósito falló", + "failWithdraw": "Retiro fallido" + }, + "transfer": { + "asset": "Activo", + "amountAsset": "Cantidad {asset}", + "totalValue": "Valor total", + "providerFee": "Tarifa del proveedor", + "networkFee": "Tarifa de red" + } }, "date": { "earlierToday": "El día de hoy", diff --git a/packages/web/localizations/fa.json b/packages/web/localizations/fa.json index d670d9d29d..338195d89f 100644 --- a/packages/web/localizations/fa.json +++ b/packages/web/localizations/fa.json @@ -1103,9 +1103,23 @@ "totalFees": "مجموع هزینه ها", "transactionHash": "هش تراکنش", "viewOnExplorer": "مشاهده در اکسپلورر", - "launchAlert": "در حال حاضر فقط سابقه تجارت نمایش داده می شود. پشتیبانی از انواع تراکنش های بیشتر به زودی.", "history": "تاریخ", - "orders": "سفارشات" + "orders": "سفارشات", + "historyTable": { + "pendingDeposit": "سپرده معلق", + "pendingWithdraw": "در انتظار برداشت", + "successDeposit": "سپرده شد", + "successWithdraw": "عقب نشینی کرد", + "failDeposit": "سپرده گذاری انجام نشد", + "failWithdraw": "برداشت ناموفق بود" + }, + "transfer": { + "asset": "دارایی", + "amountAsset": "مقدار {asset}", + "totalValue": "ارزش کل", + "providerFee": "هزینه ارائه دهنده", + "networkFee": "هزینه شبکه" + } }, "date": { "earlierToday": "اوایل امروز", diff --git a/packages/web/localizations/fr.json b/packages/web/localizations/fr.json index e355024042..b0a225fa14 100644 --- a/packages/web/localizations/fr.json +++ b/packages/web/localizations/fr.json @@ -1103,9 +1103,23 @@ "totalFees": "Total des frais", "transactionHash": "Hachage des transactions", "viewOnExplorer": "Afficher sur l'explorateur", - "launchAlert": "Actuellement, seul l'historique des échanges est affiché. Prise en charge d'autres types de transactions à venir.", "history": "Histoire", - "orders": "Ordres" + "orders": "Ordres", + "historyTable": { + "pendingDeposit": "Dépôt en attente", + "pendingWithdraw": "En attente de retrait", + "successDeposit": "Déposé", + "successWithdraw": "Retiré", + "failDeposit": "Dépôt échoué", + "failWithdraw": "Retrait échoué" + }, + "transfer": { + "asset": "Actif", + "amountAsset": "Montant {asset}", + "totalValue": "Valeur totale", + "providerFee": "Frais de prestataire", + "networkFee": "Frais de réseau" + } }, "date": { "earlierToday": "Plus tôt aujourd'hui", diff --git a/packages/web/localizations/gu.json b/packages/web/localizations/gu.json index 8afa9f7d01..d7bfda651a 100644 --- a/packages/web/localizations/gu.json +++ b/packages/web/localizations/gu.json @@ -1103,9 +1103,23 @@ "totalFees": "કુલ ફી", "transactionHash": "ટ્રાન્ઝેક્શન હેશ", "viewOnExplorer": "એક્સપ્લોરર પર જુઓ", - "launchAlert": "હાલમાં માત્ર વેપાર ઇતિહાસ પ્રદર્શિત થાય છે. વધુ વ્યવહાર પ્રકારો માટે સમર્થન ટૂંક સમયમાં આવી રહ્યું છે.", "history": "ઇતિહાસ", - "orders": "ઓર્ડર" + "orders": "ઓર્ડર", + "historyTable": { + "pendingDeposit": "બાકી થાપણ", + "pendingWithdraw": "બાકી ઉપાડ", + "successDeposit": "જમા કરાવ્યું", + "successWithdraw": "પાછી ખેંચી લીધી", + "failDeposit": "ડિપોઝિટ નિષ્ફળ", + "failWithdraw": "ઉપાડ નિષ્ફળ" + }, + "transfer": { + "asset": "એસેટ", + "amountAsset": "રકમ {asset}", + "totalValue": "કુલ મૂલ્ય", + "providerFee": "પ્રદાતા ફી", + "networkFee": "નેટવર્ક ફી" + } }, "date": { "earlierToday": "આજેવહેલા", diff --git a/packages/web/localizations/hi.json b/packages/web/localizations/hi.json index 3bb69affef..a300fadd66 100644 --- a/packages/web/localizations/hi.json +++ b/packages/web/localizations/hi.json @@ -1103,9 +1103,23 @@ "totalFees": "कुल शुल्क", "transactionHash": "लेनदेन हैश", "viewOnExplorer": "एक्सप्लोरर पर देखें", - "launchAlert": "वर्तमान में केवल व्यापार इतिहास प्रदर्शित किया जाता है। जल्द ही अधिक लेनदेन प्रकारों के लिए सहायता उपलब्ध होगी।", "history": "इतिहास", - "orders": "आदेश" + "orders": "आदेश", + "historyTable": { + "pendingDeposit": "लंबित जमा", + "pendingWithdraw": "लंबित निकासी", + "successDeposit": "जमा किया", + "successWithdraw": "वापस ले लिया", + "failDeposit": "जमा विफल", + "failWithdraw": "निकासी विफल" + }, + "transfer": { + "asset": "संपत्ति", + "amountAsset": "राशि {asset}", + "totalValue": "कुल मूल्य", + "providerFee": "प्रदाता शुल्क", + "networkFee": "नेटवर्क शुल्क" + } }, "date": { "earlierToday": "आज पहले", diff --git a/packages/web/localizations/ja.json b/packages/web/localizations/ja.json index 5744dcbcd8..1014d6fa72 100644 --- a/packages/web/localizations/ja.json +++ b/packages/web/localizations/ja.json @@ -1103,9 +1103,23 @@ "totalFees": "合計料金", "transactionHash": "トランザクションハッシュ", "viewOnExplorer": "エクスプローラーで見る", - "launchAlert": "現在は取引履歴のみが表示されます。他の取引タイプも近日中にサポートされる予定です。", "history": "歴史", - "orders": "注文" + "orders": "注文", + "historyTable": { + "pendingDeposit": "保留中の入金", + "pendingWithdraw": "保留中の撤回", + "successDeposit": "寄託", + "successWithdraw": "撤退", + "failDeposit": "入金に失敗しました", + "failWithdraw": "引き出しに失敗しました" + }, + "transfer": { + "asset": "資産", + "amountAsset": "金額{asset}", + "totalValue": "合計金額", + "providerFee": "プロバイダー料金", + "networkFee": "ネットワーク料金" + } }, "date": { "earlierToday": "本日", diff --git a/packages/web/localizations/ko.json b/packages/web/localizations/ko.json index 28d7199f80..b40f89fed6 100644 --- a/packages/web/localizations/ko.json +++ b/packages/web/localizations/ko.json @@ -1103,9 +1103,23 @@ "totalFees": "총 수수료", "transactionHash": "거래 해시", "viewOnExplorer": "탐색기에서 보기", - "launchAlert": "현재는 거래 내역만 표시됩니다. 더 많은 거래 유형이 곧 지원될 예정입니다.", "history": "역사", - "orders": "명령" + "orders": "명령", + "historyTable": { + "pendingDeposit": "입금 보류 중", + "pendingWithdraw": "인출 보류 중", + "successDeposit": "입금됨", + "successWithdraw": "철회했다", + "failDeposit": "입금 실패", + "failWithdraw": "출금 실패" + }, + "transfer": { + "asset": "유산", + "amountAsset": "금액 {asset}", + "totalValue": "총 가치", + "providerFee": "제공자 수수료", + "networkFee": "네트워크 수수료" + } }, "date": { "earlierToday": "오늘 일찍", diff --git a/packages/web/localizations/pl.json b/packages/web/localizations/pl.json index ef453397e7..2103a56ace 100644 --- a/packages/web/localizations/pl.json +++ b/packages/web/localizations/pl.json @@ -1103,9 +1103,23 @@ "totalFees": "Wszystkie koszty", "transactionHash": "Hash transakcji", "viewOnExplorer": "Zobacz w eksploratorze", - "launchAlert": "Obecnie wyświetlana jest tylko historia transakcji. Wkrótce obsługa większej liczby typów transakcji.", "history": "Historia", - "orders": "Zamówienia" + "orders": "Zamówienia", + "historyTable": { + "pendingDeposit": "Oczekujący depozyt", + "pendingWithdraw": "Oczekujące na wypłatę", + "successDeposit": "Złożony", + "successWithdraw": "Wycofany", + "failDeposit": "Depozyt nie powiódł się", + "failWithdraw": "Wypłata nie powiodła się" + }, + "transfer": { + "asset": "Zaleta", + "amountAsset": "Kwota {asset}", + "totalValue": "Wartość całkowita", + "providerFee": "Opłata dostawcy", + "networkFee": "Opłata sieciowa" + } }, "date": { "earlierToday": "Dzisiaj wcześniej", diff --git a/packages/web/localizations/pt-br.json b/packages/web/localizations/pt-br.json index edb2e59f59..48f2c0d972 100644 --- a/packages/web/localizations/pt-br.json +++ b/packages/web/localizations/pt-br.json @@ -1103,9 +1103,23 @@ "totalFees": "Taxas totais", "transactionHash": "Hash de transação", "viewOnExplorer": "Ver no explorador", - "launchAlert": "Atualmente apenas o histórico de negociações é exibido. Suporte para mais tipos de transação em breve.", "history": "História", - "orders": "Pedidos" + "orders": "Pedidos", + "historyTable": { + "pendingDeposit": "Depósito pendente", + "pendingWithdraw": "Pendente retirar", + "successDeposit": "Depositado", + "successWithdraw": "Retirou-se", + "failDeposit": "Falha no depósito", + "failWithdraw": "Falha na retirada" + }, + "transfer": { + "asset": "Ativo", + "amountAsset": "Quantidade {asset}", + "totalValue": "Valor total", + "providerFee": "Taxa de provedor", + "networkFee": "Taxa de rede" + } }, "date": { "earlierToday": "Hoje mais cedo", diff --git a/packages/web/localizations/ro.json b/packages/web/localizations/ro.json index 117bc78ba9..cc740b5a51 100644 --- a/packages/web/localizations/ro.json +++ b/packages/web/localizations/ro.json @@ -1103,9 +1103,23 @@ "totalFees": "Taxe totale", "transactionHash": "Hash de tranzacție", "viewOnExplorer": "Vizualizare pe explorer", - "launchAlert": "Momentan este afișat doar istoricul comerțului. Asistență pentru mai multe tipuri de tranzacții în curând.", "history": "Istorie", - "orders": "Comenzi" + "orders": "Comenzi", + "historyTable": { + "pendingDeposit": "Depunerea în așteptare", + "pendingWithdraw": "În așteptarea retragerii", + "successDeposit": "Depus", + "successWithdraw": "S-a retras", + "failDeposit": "Depunerea nu a reușit", + "failWithdraw": "Retragerea a eșuat" + }, + "transfer": { + "asset": "Atu", + "amountAsset": "Suma {asset}", + "totalValue": "Valoarea totală", + "providerFee": "Taxa de furnizor", + "networkFee": "Taxa de retea" + } }, "date": { "earlierToday": "Mai devreme astazi", diff --git a/packages/web/localizations/ru.json b/packages/web/localizations/ru.json index 4664209993..0163f4a9f5 100644 --- a/packages/web/localizations/ru.json +++ b/packages/web/localizations/ru.json @@ -1103,9 +1103,23 @@ "totalFees": "Общая сумма сборов", "transactionHash": "Хэш транзакции", "viewOnExplorer": "Посмотреть в проводнике", - "launchAlert": "В настоящее время отображается только история торговли. Скоро появится поддержка большего количества типов транзакций.", "history": "История", - "orders": "Заказы" + "orders": "Заказы", + "historyTable": { + "pendingDeposit": "Ожидаемый депозит", + "pendingWithdraw": "Ожидается отзыв", + "successDeposit": "Депонированный", + "successWithdraw": "Снято", + "failDeposit": "Депозит не удалось", + "failWithdraw": "Вывод не удался" + }, + "transfer": { + "asset": "Объект", + "amountAsset": "Сумма {asset}", + "totalValue": "Общая стоимость", + "providerFee": "Плата провайдера", + "networkFee": "Сетевой сбор" + } }, "date": { "earlierToday": "Ранее сегодня", diff --git a/packages/web/localizations/tr.json b/packages/web/localizations/tr.json index d4ebcd18f4..68c9ff1475 100644 --- a/packages/web/localizations/tr.json +++ b/packages/web/localizations/tr.json @@ -1103,9 +1103,23 @@ "totalFees": "Toplam ücretler", "transactionHash": "İşlem Karması", "viewOnExplorer": "Explorer'da görüntüle", - "launchAlert": "Şu anda yalnızca ticaret geçmişi görüntüleniyor. Yakında daha fazla işlem türü için destek sunulacak.", "history": "Tarih", - "orders": "Emirler" + "orders": "Emirler", + "historyTable": { + "pendingDeposit": "Bekleyen mevduat", + "pendingWithdraw": "Bekleyen çekme", + "successDeposit": "Yatırılan", + "successWithdraw": "Geri çekildi", + "failDeposit": "Para yatırma başarısız oldu", + "failWithdraw": "Geri çekilme başarısız oldu" + }, + "transfer": { + "asset": "Varlık", + "amountAsset": "Tutar {asset}", + "totalValue": "Toplam değer", + "providerFee": "Sağlayıcı ücreti", + "networkFee": "Ağ ücreti" + } }, "date": { "earlierToday": "Bugün erken saatlerde", diff --git a/packages/web/localizations/zh-cn.json b/packages/web/localizations/zh-cn.json index 1d2e503129..0ee8c2249b 100644 --- a/packages/web/localizations/zh-cn.json +++ b/packages/web/localizations/zh-cn.json @@ -1103,9 +1103,23 @@ "totalFees": "总费用", "transactionHash": "交易哈希", "viewOnExplorer": "在资源管理器中查看", - "launchAlert": "目前仅显示交易历史。即将支持更多交易类型。", "history": "历史", - "orders": "命令" + "orders": "命令", + "historyTable": { + "pendingDeposit": "待存款", + "pendingWithdraw": "等待提款", + "successDeposit": "已存入", + "successWithdraw": "已退出", + "failDeposit": "存款失败", + "failWithdraw": "提现失败" + }, + "transfer": { + "asset": "资产", + "amountAsset": "金额{asset}", + "totalValue": "总价值", + "providerFee": "供应商费用", + "networkFee": "网络费" + } }, "date": { "earlierToday": "今天早些时候", diff --git a/packages/web/localizations/zh-hk.json b/packages/web/localizations/zh-hk.json index 7d4a3ac32f..c20120eb8e 100644 --- a/packages/web/localizations/zh-hk.json +++ b/packages/web/localizations/zh-hk.json @@ -1103,9 +1103,23 @@ "totalFees": "總費用", "transactionHash": "交易哈希", "viewOnExplorer": "在資源管理器上查看", - "launchAlert": "目前僅顯示交易歷史記錄。即將支援更多交易類型。", "history": "歷史", - "orders": "命令" + "orders": "命令", + "historyTable": { + "pendingDeposit": "待定存款", + "pendingWithdraw": "待提款", + "successDeposit": "已存入", + "successWithdraw": "退出", + "failDeposit": "存款失敗", + "failWithdraw": "提現失敗" + }, + "transfer": { + "asset": "資產", + "amountAsset": "金額{asset}", + "totalValue": "總價值", + "providerFee": "提供者費用", + "networkFee": "網路費" + } }, "date": { "earlierToday": "今天早些時候", diff --git a/packages/web/localizations/zh-tw.json b/packages/web/localizations/zh-tw.json index 12da3f6fa8..23ecb11a96 100644 --- a/packages/web/localizations/zh-tw.json +++ b/packages/web/localizations/zh-tw.json @@ -1103,9 +1103,23 @@ "totalFees": "總費用", "transactionHash": "交易哈希", "viewOnExplorer": "在資源管理器上查看", - "launchAlert": "目前僅顯示交易歷史記錄。即將支援更多交易類型。", "history": "歷史", - "orders": "命令" + "orders": "命令", + "historyTable": { + "pendingDeposit": "待定存款", + "pendingWithdraw": "待提款", + "successDeposit": "已存入", + "successWithdraw": "退出", + "failDeposit": "存款失敗", + "failWithdraw": "提現失敗" + }, + "transfer": { + "asset": "資產", + "amountAsset": "金額{asset}", + "totalValue": "總價值", + "providerFee": "提供者費用", + "networkFee": "網路費" + } }, "date": { "earlierToday": "今天早些時候", diff --git a/packages/web/pages/transactions.tsx b/packages/web/pages/transactions.tsx index 99410f1913..d65d8325a2 100644 --- a/packages/web/pages/transactions.tsx +++ b/packages/web/pages/transactions.tsx @@ -6,8 +6,8 @@ import { useEffect, useMemo, useState } from "react"; import { LinkButton } from "~/components/buttons/link-button"; import { TransactionContent } from "~/components/transactions/transaction-content"; -import { TransactionDetailsModal } from "~/components/transactions/transaction-details/transaction-details-modal"; -import { TransactionDetailsSlideover } from "~/components/transactions/transaction-details/transaction-details-slideover"; +import { TransactionDetailsModal } from "~/components/transactions/transaction-details-modal"; +import { TransactionDetailsSlideover } from "~/components/transactions/transaction-details-slideover"; import { EventName } from "~/config"; import { useAmplitudeAnalytics, @@ -17,8 +17,8 @@ import { useWalletSelect, useWindowSize, } from "~/hooks"; +import { useTransactionHistory } from "~/hooks/use-transaction-history"; import { useStore } from "~/stores"; -import { api } from "~/utils/trpc"; // @ts-ignore const EXAMPLE = { @@ -53,22 +53,10 @@ const Transactions: React.FC = observer(() => { const { isLoading: isWalletLoading } = useWalletSelect(); - const { data, isFetching: isGetTransactionsFetching } = - api.edge.transactions.getTransactions.useQuery( - { - address, - page: pageString, - pageSize: pageSizeString, - }, - { - enabled: Boolean(wallet?.isWalletConnected && wallet?.address), - } - ); - - const { transactions, hasNextPage } = data ?? { - transactions: [], - hasNextPage: false, - }; + const { transactions, hasNextPage, isLoading } = useTransactionHistory({ + pageNumber: pageString, + pageSize: pageSizeString, + }); useEffect(() => { if (!transactionsPage && _isInitialized) { @@ -129,7 +117,12 @@ const Transactions: React.FC = observer(() => { }; const selectedTransaction = useMemo( - () => transactions.find((tx) => tx.hash === selectedTransactionHash), + () => + transactions.find( + (tx) => + (tx.__type === "recentTransfer" ? tx.sendTxHash : tx.hash) === + selectedTransactionHash + ), [transactions, selectedTransactionHash] ); @@ -142,7 +135,7 @@ const Transactions: React.FC = observer(() => { setOpen={setOpen} open={open} address={address} - isLoading={isGetTransactionsFetching || isWalletLoading} + isLoading={isLoading || isWalletLoading} isWalletConnected={isWalletConnected} page={pageString} hasNextPage={hasNextPage} diff --git a/packages/web/stores/root.ts b/packages/web/stores/root.ts index 817d688b78..d4283e2a53 100644 --- a/packages/web/stores/root.ts +++ b/packages/web/stores/root.ts @@ -51,7 +51,10 @@ import { UserSettings, } from "~/stores/user-settings"; -import { TransferHistoryStore } from "./transfer-history"; +import { + TRANSFER_HISTORY_STORE_KEY, + TransferHistoryStore, +} from "./transfer-history"; const assets = AssetLists.flatMap((list) => list.assets); @@ -266,7 +269,7 @@ export class RootStore { // tRPC queries, the params are not used txEvents?.onFulfill?.("", ""); }, - makeLocalStorageKVStore("nonibc_transfer_history"), + makeIndexedKVStore(TRANSFER_HISTORY_STORE_KEY), transferStatusProviders ); diff --git a/packages/web/stores/transfer-history.tsx b/packages/web/stores/transfer-history.tsx index c5ab4bac72..88c15639c8 100644 --- a/packages/web/stores/transfer-history.tsx +++ b/packages/web/stores/transfer-history.tsx @@ -1,9 +1,11 @@ import { KVStore } from "@keplr-wallet/common"; +import { CoinPretty, Dec } from "@keplr-wallet/unit"; import { TransferFailureReason, TransferStatus, TransferStatusProvider, TransferStatusReceiver, + TxSnapshot, } from "@osmosis-labs/bridge"; import dayjs from "dayjs"; import { @@ -23,23 +25,7 @@ import { RadialProgress } from "~/components/radial-progress"; import { useTranslation } from "~/hooks"; import { humanizeTime } from "~/utils/date"; -/** Persistable data enough to identify a tx. */ -type TxSnapshot = { - /** From Date.getTime(). Assumed local timezone. */ - createdAtMs: number; - prefixedKey: string; - amount: string; - startTimeUnix: number; - amountLogo: string | undefined; - status: TransferStatus; - estimatedArrivalUnix: number | undefined; - chainPrettyName: string; - reason?: TransferFailureReason; - isWithdraw: boolean; - accountAddress: string; -}; - -const STORE_KEY = "nonibc_history_tx_snapshots"; +export const TRANSFER_HISTORY_STORE_KEY = "transfer_history"; /** * Stores and tracks status for bridge transfers. @@ -50,7 +36,7 @@ export class TransferHistoryStore implements TransferStatusReceiver { @observable protected snapshots: TxSnapshot[] = []; @observable - private isRestoredFromLocalStorage = false; + private isRestoredFromIndexedDB = false; /** * Since we can't control how many times a status provider @@ -75,8 +61,8 @@ export class TransferHistoryStore implements TransferStatusReceiver { // persist snapshots on change autorun(() => { - if (this.isRestoredFromLocalStorage) { - this.kvStore.set(STORE_KEY, toJS(this.snapshots)); + if (this.isRestoredFromIndexedDB) { + this.kvStore.set(TRANSFER_HISTORY_STORE_KEY, toJS(this.snapshots)); } }); @@ -84,32 +70,23 @@ export class TransferHistoryStore implements TransferStatusReceiver { } getHistoriesByAccount = computedFn((accountAddress: string) => { - const histories: { - key: string; + const histories: (TxSnapshot & { createdAt: Date; - sourceName?: string; + providerName?: string; status: TransferStatus; - amount: string; - reason?: TransferFailureReason; explorerUrl: string; - isWithdraw: boolean; - }[] = []; + })[] = []; this.snapshots.forEach((snapshot) => { const statusSource = this.transferStatusProviders.find((source) => - snapshot.prefixedKey.startsWith(source.keyPrefix) + snapshot.provider.startsWith(source.providerId) ); - if (statusSource && snapshot.accountAddress === accountAddress) { - const key = snapshot.prefixedKey.slice(statusSource?.keyPrefix.length); - + if (statusSource && snapshot.osmoBech32Address === accountAddress) { histories.push({ - key, - createdAt: new Date(snapshot.createdAtMs), - sourceName: statusSource.sourceDisplayName, - status: snapshot.status, - amount: snapshot.amount, - reason: snapshot.reason, - explorerUrl: statusSource.makeExplorerUrl(key), - isWithdraw: snapshot.isWithdraw, + ...snapshot, + sendTxHash: snapshot.sendTxHash, + createdAt: new Date(snapshot.createdAtUnix * 1000), + providerName: statusSource.sourceDisplayName, + explorerUrl: statusSource.makeExplorerUrl(snapshot), }); } }); @@ -128,74 +105,71 @@ export class TransferHistoryStore implements TransferStatusReceiver { * @param amountLogo The logo URL of the amount's currency. */ @action - pushTxNow({ - prefixedKey, - amount, - isWithdraw, - accountAddress, - chainPrettyName, - estimatedArrivalUnix, - amountLogo, - }: { - prefixedKey: string; - amount: string; - amountLogo: string | undefined; - estimatedArrivalUnix: number | undefined; - chainPrettyName: string; - isWithdraw: boolean; - accountAddress: string; - }) { + pushTxNow(snapshot: TxSnapshot) { + const { + sendTxHash, + estimatedArrivalUnix, + createdAtUnix, + fromChain, + toChain, + toAsset, + fromAsset, + direction, + } = snapshot; const statusSource = this.transferStatusProviders.find((source) => - prefixedKey.startsWith(source.keyPrefix) + snapshot.provider.startsWith(source.providerId) ); // start tracking for life of current session - statusSource?.trackTxStatus( - prefixedKey.slice(statusSource.keyPrefix.length) - ); + statusSource?.trackTxStatus(snapshot); - const startTimeUnix = Date.now() / 1000; + const amountLogo = + direction === "withdraw" ? toAsset?.imageUrl : fromAsset.imageUrl; setTimeout(() => { displayToast( { - titleTranslationKey: isWithdraw - ? "transfer.pendingWithdraw" - : "transfer.pendingDeposit", + titleTranslationKey: + snapshot.direction === "withdraw" + ? "transfer.pendingWithdraw" + : "transfer.pendingDeposit", iconElement: amountLogo && estimatedArrivalUnix ? ( ) : undefined, captionElement: ( ), }, ToastType.LOADING, - { toastId: prefixedKey, autoClose: false } + { + toastId: sendTxHash, + autoClose: false, + } ); }, 1000); - this.snapshots.push({ - createdAtMs: Date.now(), - prefixedKey, - amount, - status: "pending", - isWithdraw, - accountAddress, - chainPrettyName, - estimatedArrivalUnix, - amountLogo, - startTimeUnix, - }); + this.snapshots.push(snapshot); } /** @@ -203,106 +177,139 @@ export class TransferHistoryStore implements TransferStatusReceiver { * of an initiated transfer. */ @action - receiveNewTxStatus( - prefixedKey: string, + async receiveNewTxStatus( + sendTxHash: string, status: TransferStatus, reason: TransferFailureReason | undefined ) { const snapshot = this.snapshots.find( - (snapshot) => snapshot.prefixedKey === prefixedKey + (snapshot) => snapshot.sendTxHash === sendTxHash ); + console.log(snapshot); + if (!snapshot) { console.error("Couldn't find tx snapshot when receiving tx status"); return; } + const { + direction, + toAsset, + fromAsset, + createdAtUnix, + estimatedArrivalUnix, + fromChain, + toChain, + osmoBech32Address, + } = snapshot; + // set updates snapshot.status = status; snapshot.reason = reason; + const amountLogo = + direction === "withdraw" ? toAsset?.imageUrl : fromAsset.imageUrl; + const amount = new CoinPretty( + { + coinDecimals: fromAsset.decimals, + coinMinimalDenom: fromAsset.address, + coinDenom: fromAsset.denom, + }, + new Dec(fromAsset.amount) + ).toString(); + + const chainPrettyName = + direction === "deposit" + ? fromChain?.prettyName ?? "" + : toChain?.prettyName ?? ""; + switch (status) { case "pending": displayToast( { - titleTranslationKey: snapshot.isWithdraw - ? "transfer.pendingWithdraw" - : "transfer.pendingDeposit", + titleTranslationKey: + snapshot.direction === "withdraw" + ? "transfer.pendingWithdraw" + : "transfer.pendingDeposit", iconElement: - snapshot.amountLogo && snapshot.estimatedArrivalUnix ? ( + amountLogo && estimatedArrivalUnix ? ( ) : undefined, captionElement: ( ), }, ToastType.LOADING, - { updateToastId: prefixedKey, autoClose: false } + { updateToastId: sendTxHash, autoClose: false } ); break; case "success": - if (this._resolvedTxStatusKeys.has(prefixedKey)) break; + if (this._resolvedTxStatusKeys.has(sendTxHash)) break; displayToast( { - titleTranslationKey: snapshot.isWithdraw - ? "transfer.completedWithdraw" - : "transfer.completedDeposit", - captionTranslationKey: snapshot.isWithdraw - ? [ - "transfer.amountToChain", - { amount: snapshot.amount, chain: snapshot.chainPrettyName }, - ] - : [ - "transfer.amountFromChain", - { amount: snapshot.amount, chain: snapshot.chainPrettyName }, - ], + titleTranslationKey: + direction === "withdraw" + ? "transfer.completedWithdraw" + : "transfer.completedDeposit", + captionTranslationKey: + direction === "withdraw" + ? [ + "transfer.amountToChain", + { amount: amount, chain: chainPrettyName }, + ] + : [ + "transfer.amountFromChain", + { amount: amount, chain: chainPrettyName }, + ], }, ToastType.SUCCESS, - { updateToastId: prefixedKey } + { updateToastId: sendTxHash } ); - this.onAccountTransferSuccess(snapshot.accountAddress); - this._resolvedTxStatusKeys.add(prefixedKey); + this.onAccountTransferSuccess(osmoBech32Address); + this._resolvedTxStatusKeys.add(sendTxHash); break; case "failed": - if (this._resolvedTxStatusKeys.has(prefixedKey)) break; + if (this._resolvedTxStatusKeys.has(sendTxHash)) break; displayToast( { - titleTranslationKey: snapshot.isWithdraw - ? "transfer.failedWithdraw" - : "transfer.failedDeposit", + titleTranslationKey: + direction === "withdraw" + ? "transfer.failedWithdraw" + : "transfer.failedDeposit", captionTranslationKey: [ "transfer.amountFailedToWithdraw", - { amount: snapshot.amount }, + { amount }, ], }, ToastType.ERROR, - { updateToastId: prefixedKey } + { updateToastId: sendTxHash } ); - this._resolvedTxStatusKeys.add(prefixedKey); + this._resolvedTxStatusKeys.add(sendTxHash); break; case "connection-error": - if (this._resolvedTxStatusKeys.has(prefixedKey)) break; + if (this._resolvedTxStatusKeys.has(sendTxHash)) break; displayToast( { titleTranslationKey: "transfer.connectionError", captionTranslationKey: [ "transfer.amountFailedToWithdraw", - { amount: snapshot.amount }, + { amount }, ], }, ToastType.ERROR, - { updateToastId: prefixedKey } + { updateToastId: sendTxHash } ); - this._resolvedTxStatusKeys.add(prefixedKey); + this._resolvedTxStatusKeys.add(sendTxHash); break; } } @@ -312,23 +319,25 @@ export class TransferHistoryStore implements TransferStatusReceiver { */ protected async restoreSnapshots() { const storedSnapshots = - (await this.kvStore.get(STORE_KEY)) ?? []; + (await this.kvStore.get(TRANSFER_HISTORY_STORE_KEY)) ?? []; storedSnapshots.forEach(async (snapshot) => { if (this.isSnapshotExpired(snapshot)) { return; } const statusSource = this.transferStatusProviders.find((source) => - snapshot.prefixedKey.startsWith(source.keyPrefix) + snapshot.provider.startsWith(source.providerId) ); // start receiving tx status updates again for snapshots that were still pending - if (snapshot.status === "pending" && statusSource) { - statusSource.trackTxStatus( - snapshot.prefixedKey.slice(statusSource.keyPrefix.length) - ); + if ( + (snapshot.status === "pending" || + snapshot.status === "connection-error") && + statusSource + ) { + statusSource.trackTxStatus(snapshot); } else { - this._resolvedTxStatusKeys.add(snapshot.prefixedKey); + this._resolvedTxStatusKeys.add(snapshot.sendTxHash); } runInAction(() => { @@ -337,13 +346,13 @@ export class TransferHistoryStore implements TransferStatusReceiver { }); runInAction(() => { - this.isRestoredFromLocalStorage = true; + this.isRestoredFromIndexedDB = true; }); } protected isSnapshotExpired(snapshot: TxSnapshot): boolean { const expiryMs = this.historyExpireDays * 86_400_00; - return Date.now() - snapshot.createdAtMs > expiryMs; + return Date.now() - snapshot.createdAtUnix * 1000 > expiryMs; } } From 03be37c8c5458f34025ad6ba7cb77ac758a40758 Mon Sep 17 00:00:00 2001 From: Jose Felix Date: Tue, 12 Nov 2024 10:59:05 -0400 Subject: [PATCH 3/4] fix: Add missing translation (#3939) --- packages/web/localizations/de.json | 1 + packages/web/localizations/es.json | 1 + packages/web/localizations/fa.json | 1 + packages/web/localizations/fr.json | 1 + packages/web/localizations/gu.json | 1 + packages/web/localizations/hi.json | 1 + packages/web/localizations/ja.json | 1 + packages/web/localizations/ko.json | 1 + packages/web/localizations/pl.json | 1 + packages/web/localizations/pt-br.json | 1 + packages/web/localizations/ro.json | 1 + packages/web/localizations/ru.json | 1 + packages/web/localizations/tr.json | 1 + packages/web/localizations/zh-cn.json | 1 + packages/web/localizations/zh-hk.json | 1 + packages/web/localizations/zh-tw.json | 1 + 16 files changed, 16 insertions(+) diff --git a/packages/web/localizations/de.json b/packages/web/localizations/de.json index 0104cb55c7..a964620fc8 100644 --- a/packages/web/localizations/de.json +++ b/packages/web/localizations/de.json @@ -1103,6 +1103,7 @@ "totalFees": "Gesamtkosten", "transactionHash": "Transaktions-Hash", "viewOnExplorer": "Im Explorer anzeigen", + "launchAlert": "Derzeit wird nur der Handels- und Überweisungsverlauf angezeigt. Unterstützung für weitere Transaktionstypen folgt in Kürze.", "history": "Geschichte", "orders": "Aufträge", "historyTable": { diff --git a/packages/web/localizations/es.json b/packages/web/localizations/es.json index 6facdd7429..83634ba667 100644 --- a/packages/web/localizations/es.json +++ b/packages/web/localizations/es.json @@ -1103,6 +1103,7 @@ "totalFees": "Tarifas totales", "transactionHash": "Hash de transacción", "viewOnExplorer": "Ver en el explorador", + "launchAlert": "Actualmente, solo se muestra el historial de transacciones y transferencias. Próximamente, se admitirán más tipos de transacciones.", "history": "Historia", "orders": "Pedidos", "historyTable": { diff --git a/packages/web/localizations/fa.json b/packages/web/localizations/fa.json index 338195d89f..caaf0ca5cb 100644 --- a/packages/web/localizations/fa.json +++ b/packages/web/localizations/fa.json @@ -1103,6 +1103,7 @@ "totalFees": "مجموع هزینه ها", "transactionHash": "هش تراکنش", "viewOnExplorer": "مشاهده در اکسپلورر", + "launchAlert": "در حال حاضر فقط تاریخچه معاملات و نقل و انتقالات نمایش داده می شود. پشتیبانی از انواع تراکنش های بیشتر به زودی.", "history": "تاریخ", "orders": "سفارشات", "historyTable": { diff --git a/packages/web/localizations/fr.json b/packages/web/localizations/fr.json index b0a225fa14..b63c7506b5 100644 --- a/packages/web/localizations/fr.json +++ b/packages/web/localizations/fr.json @@ -1103,6 +1103,7 @@ "totalFees": "Total des frais", "transactionHash": "Hachage des transactions", "viewOnExplorer": "Afficher sur l'explorateur", + "launchAlert": "Actuellement, seul l'historique des transactions et des transferts est affiché. D'autres types de transactions seront bientôt disponibles.", "history": "Histoire", "orders": "Ordres", "historyTable": { diff --git a/packages/web/localizations/gu.json b/packages/web/localizations/gu.json index d7bfda651a..c98ece49c7 100644 --- a/packages/web/localizations/gu.json +++ b/packages/web/localizations/gu.json @@ -1103,6 +1103,7 @@ "totalFees": "કુલ ફી", "transactionHash": "ટ્રાન્ઝેક્શન હેશ", "viewOnExplorer": "એક્સપ્લોરર પર જુઓ", + "launchAlert": "હાલમાં માત્ર વેપાર અને ટ્રાન્સફર ઇતિહાસ પ્રદર્શિત થાય છે. વધુ વ્યવહાર પ્રકારો માટે સમર્થન ટૂંક સમયમાં આવી રહ્યું છે.", "history": "ઇતિહાસ", "orders": "ઓર્ડર", "historyTable": { diff --git a/packages/web/localizations/hi.json b/packages/web/localizations/hi.json index a300fadd66..8a9a677868 100644 --- a/packages/web/localizations/hi.json +++ b/packages/web/localizations/hi.json @@ -1103,6 +1103,7 @@ "totalFees": "कुल शुल्क", "transactionHash": "लेनदेन हैश", "viewOnExplorer": "एक्सप्लोरर पर देखें", + "launchAlert": "वर्तमान में केवल व्यापार और हस्तांतरण इतिहास ही प्रदर्शित किया जाता है। जल्द ही अधिक लेनदेन प्रकारों के लिए सहायता उपलब्ध होगी।", "history": "इतिहास", "orders": "आदेश", "historyTable": { diff --git a/packages/web/localizations/ja.json b/packages/web/localizations/ja.json index 1014d6fa72..7017e38b77 100644 --- a/packages/web/localizations/ja.json +++ b/packages/web/localizations/ja.json @@ -1103,6 +1103,7 @@ "totalFees": "合計料金", "transactionHash": "トランザクションハッシュ", "viewOnExplorer": "エクスプローラーで見る", + "launchAlert": "現在、取引と送金の履歴のみが表示されます。他の取引タイプも近日中にサポートされる予定です。", "history": "歴史", "orders": "注文", "historyTable": { diff --git a/packages/web/localizations/ko.json b/packages/web/localizations/ko.json index b40f89fed6..673a784adc 100644 --- a/packages/web/localizations/ko.json +++ b/packages/web/localizations/ko.json @@ -1103,6 +1103,7 @@ "totalFees": "총 수수료", "transactionHash": "거래 해시", "viewOnExplorer": "탐색기에서 보기", + "launchAlert": "현재는 거래 및 이체 내역만 표시됩니다. 더 많은 거래 유형에 대한 지원이 곧 제공됩니다.", "history": "역사", "orders": "명령", "historyTable": { diff --git a/packages/web/localizations/pl.json b/packages/web/localizations/pl.json index 2103a56ace..b9bfcee803 100644 --- a/packages/web/localizations/pl.json +++ b/packages/web/localizations/pl.json @@ -1103,6 +1103,7 @@ "totalFees": "Wszystkie koszty", "transactionHash": "Hash transakcji", "viewOnExplorer": "Zobacz w eksploratorze", + "launchAlert": "Obecnie wyświetlana jest tylko historia transakcji i transferów. Obsługa większej liczby typów transakcji wkrótce.", "history": "Historia", "orders": "Zamówienia", "historyTable": { diff --git a/packages/web/localizations/pt-br.json b/packages/web/localizations/pt-br.json index 48f2c0d972..2a6681f8cf 100644 --- a/packages/web/localizations/pt-br.json +++ b/packages/web/localizations/pt-br.json @@ -1103,6 +1103,7 @@ "totalFees": "Taxas totais", "transactionHash": "Hash de transação", "viewOnExplorer": "Ver no explorador", + "launchAlert": "Atualmente, apenas o histórico de transações e transferências é exibido. Suporte para mais tipos de transações em breve.", "history": "História", "orders": "Pedidos", "historyTable": { diff --git a/packages/web/localizations/ro.json b/packages/web/localizations/ro.json index cc740b5a51..acf9b9662f 100644 --- a/packages/web/localizations/ro.json +++ b/packages/web/localizations/ro.json @@ -1103,6 +1103,7 @@ "totalFees": "Taxe totale", "transactionHash": "Hash de tranzacție", "viewOnExplorer": "Vizualizare pe explorer", + "launchAlert": "Momentan este afișat doar istoricul tranzacțiilor și transferurilor. Asistență pentru mai multe tipuri de tranzacții în curând.", "history": "Istorie", "orders": "Comenzi", "historyTable": { diff --git a/packages/web/localizations/ru.json b/packages/web/localizations/ru.json index 0163f4a9f5..227b67b9c3 100644 --- a/packages/web/localizations/ru.json +++ b/packages/web/localizations/ru.json @@ -1103,6 +1103,7 @@ "totalFees": "Общая сумма сборов", "transactionHash": "Хэш транзакции", "viewOnExplorer": "Посмотреть в проводнике", + "launchAlert": "В настоящее время отображается только история торговли и переводов. Скоро будет поддержка большего количества типов транзакций.", "history": "История", "orders": "Заказы", "historyTable": { diff --git a/packages/web/localizations/tr.json b/packages/web/localizations/tr.json index 68c9ff1475..208733a2eb 100644 --- a/packages/web/localizations/tr.json +++ b/packages/web/localizations/tr.json @@ -1103,6 +1103,7 @@ "totalFees": "Toplam ücretler", "transactionHash": "İşlem Karması", "viewOnExplorer": "Explorer'da görüntüle", + "launchAlert": "Şu anda yalnızca işlem ve transfer geçmişi görüntüleniyor. Yakında daha fazla işlem türü için destek geliyor.", "history": "Tarih", "orders": "Emirler", "historyTable": { diff --git a/packages/web/localizations/zh-cn.json b/packages/web/localizations/zh-cn.json index 0ee8c2249b..0067235761 100644 --- a/packages/web/localizations/zh-cn.json +++ b/packages/web/localizations/zh-cn.json @@ -1103,6 +1103,7 @@ "totalFees": "总费用", "transactionHash": "交易哈希", "viewOnExplorer": "在资源管理器中查看", + "launchAlert": "目前仅显示交易和转账历史记录。即将支持更多交易类型。", "history": "历史", "orders": "命令", "historyTable": { diff --git a/packages/web/localizations/zh-hk.json b/packages/web/localizations/zh-hk.json index c20120eb8e..4ccaeff8c6 100644 --- a/packages/web/localizations/zh-hk.json +++ b/packages/web/localizations/zh-hk.json @@ -1103,6 +1103,7 @@ "totalFees": "總費用", "transactionHash": "交易哈希", "viewOnExplorer": "在資源管理器上查看", + "launchAlert": "目前僅顯示交易和轉帳歷史記錄。即將支援更多交易類型。", "history": "歷史", "orders": "命令", "historyTable": { diff --git a/packages/web/localizations/zh-tw.json b/packages/web/localizations/zh-tw.json index 23ecb11a96..ad01c146aa 100644 --- a/packages/web/localizations/zh-tw.json +++ b/packages/web/localizations/zh-tw.json @@ -1103,6 +1103,7 @@ "totalFees": "總費用", "transactionHash": "交易哈希", "viewOnExplorer": "在資源管理器上查看", + "launchAlert": "目前僅顯示交易和轉帳歷史記錄。即將支援更多交易類型。", "history": "歷史", "orders": "命令", "historyTable": { From cfd4d7a688d3a2f0faedd108b730cf2156f8dec1 Mon Sep 17 00:00:00 2001 From: Enrico Barbieri Date: Tue, 12 Nov 2024 18:06:20 +0100 Subject: [PATCH 4/4] Enricobarbieri1997/refactor remove old custom table component (#3935) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: :lipstick: Replace custom SortDirection with osmosis-labs utils SortDirection * refactor: :lipstick: Replace Table custom component with base table powered by tanstack react-table * refactor: 💄 Remove now unused table component Removed a table component previously used only by another one from which it has been removed * fix: :lipstick: Fix table background having a different tint from component background * refactor: :lipstick: Change to classNames utility from template literal * refactor: :coffin: Remove unused types * refactor: :coffin: Remove almost unused BaseCell type BaseCell type was only used for compatibility with the now removed table component on most types. It was really used on ValidatorInfo that's been changed now * refactor: :lipstick: Add missing padding in superfluid validator selection table header * refactor: :lipstick: Changed background and hover functionality to be consistent with other app tables * refactor: :lipstick: Remove table header background in modals remove table header background in modals following new design guidelines * fix: :lipstick: Change transparent background to be the same as container Transparent background caused readability issues when scrolling the table --- packages/web/components/table/cells/index.ts | 1 - .../table/cells/pool-composition.tsx | 3 +- .../table/cells/pool-quick-actions.tsx | 4 +- packages/web/components/table/cells/types.ts | 42 --- .../components/table/cells/validator-info.tsx | 9 +- packages/web/components/table/index.tsx | 220 ------------- packages/web/components/table/types.ts | 42 --- packages/web/components/types.ts | 2 - packages/web/hooks/data/use-sorted-data.ts | 11 +- packages/web/modals/superfluid-validator.tsx | 290 +++++++++++++----- packages/web/modals/validator-squad-modal.tsx | 2 +- 11 files changed, 221 insertions(+), 405 deletions(-) delete mode 100644 packages/web/components/table/cells/types.ts delete mode 100644 packages/web/components/table/index.tsx delete mode 100644 packages/web/components/table/types.ts diff --git a/packages/web/components/table/cells/index.ts b/packages/web/components/table/cells/index.ts index ad5d1b2fb0..37dc9abba3 100644 --- a/packages/web/components/table/cells/index.ts +++ b/packages/web/components/table/cells/index.ts @@ -1,4 +1,3 @@ export * from "./pool-composition"; export * from "./pool-quick-actions"; -export * from "./types"; export * from "./validator-info"; diff --git a/packages/web/components/table/cells/pool-composition.tsx b/packages/web/components/table/cells/pool-composition.tsx index e57d4eb0e9..5e1eaa3dc2 100644 --- a/packages/web/components/table/cells/pool-composition.tsx +++ b/packages/web/components/table/cells/pool-composition.tsx @@ -4,9 +4,8 @@ import React, { FunctionComponent } from "react"; import { PoolAssetsIcon, PoolAssetsName } from "~/components/assets"; import { Icon } from "~/components/assets"; -import { BaseCell } from "~/components/table"; import { useTranslation } from "~/hooks"; -export interface PoolCompositionCell extends BaseCell { +export interface PoolCompositionCell { poolId: string; poolAssets: { coinImageUrl: string | undefined; diff --git a/packages/web/components/table/cells/pool-quick-actions.tsx b/packages/web/components/table/cells/pool-quick-actions.tsx index b9302b4094..aff5054879 100644 --- a/packages/web/components/table/cells/pool-quick-actions.tsx +++ b/packages/web/components/table/cells/pool-quick-actions.tsx @@ -8,14 +8,12 @@ import React, { import { MenuDropdown, MenuOption } from "~/components//control"; import { Icon } from "~/components/assets"; -import { BaseCell } from "~/components/table"; import { PoolCompositionCell } from "~/components/table/cells/pool-composition"; import { useTranslation } from "~/hooks"; import { useBooleanWithWindowEvent } from "~/hooks"; export interface PoolQuickActionCell - extends BaseCell, - Pick { + extends Pick { /** Used to group quick action cells, to close dropdowns via events aren't related to this cell. */ cellGroupEventEmitter?: EventEmitter; onAddLiquidity?: () => void; diff --git a/packages/web/components/table/cells/types.ts b/packages/web/components/table/cells/types.ts deleted file mode 100644 index 34fc62363f..0000000000 --- a/packages/web/components/table/cells/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Currency } from "@keplr-wallet/types"; -import { Dec } from "@keplr-wallet/unit"; -import { ReactElement } from "react-markdown/lib/react-markdown"; - -import { BaseCell } from "~/components/table"; - -export type AssetCell = BaseCell & { - currency: Currency; - assetName?: string; - chainName?: string; - chainId?: string; - coinDenom: string; - coinImageUrl?: string; - amount: string | ReactElement; - fiatValue?: string | ReactElement; - fiatValueRaw?: Dec; - marketCap?: string; - marketCapRaw?: string; - pricePerUnit?: string; - /** Used by `useFilteredData` to provide user query terms to help users find this cell in the table. - * Be sure to add `"queryTags"` to the keys param. - */ - queryTags?: string[]; - isUnstable?: boolean; - isFavorite?: boolean; - isVerified?: boolean; - onWithdraw?: ( - chainId: string, - coinDenom: string, - externalUrl?: string - ) => void; - onDeposit?: ( - chainId: string, - coinDenom: string, - externalUrl?: string - ) => void; - onToggleFavorite?: () => void; -}; - -export interface ValidatorInfo extends BaseCell { - imgSrc?: string; -} diff --git a/packages/web/components/table/cells/validator-info.tsx b/packages/web/components/table/cells/validator-info.tsx index 7572beedee..e995516f2d 100644 --- a/packages/web/components/table/cells/validator-info.tsx +++ b/packages/web/components/table/cells/validator-info.tsx @@ -1,9 +1,12 @@ import { FunctionComponent } from "react"; -import { ValidatorInfo } from "~/components/table/cells/types"; +export interface ValidatorInfo { + name?: string; + imgSrc?: string; +} export const ValidatorInfoCell: FunctionComponent = ({ - value, + name, imgSrc, }) => (
@@ -14,6 +17,6 @@ export const ValidatorInfoCell: FunctionComponent = ({ src={imgSrc ?? "/icons/profile.svg"} />
- {value ?? ""} + {name ?? ""}
); diff --git a/packages/web/components/table/index.tsx b/packages/web/components/table/index.tsx deleted file mode 100644 index 62742a8917..0000000000 --- a/packages/web/components/table/index.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import classNames from "classnames"; -import Link from "next/link"; -import { useRouter } from "next/router"; -import React, { - PropsWithChildren, - PropsWithoutRef, - useCallback, - useState, -} from "react"; - -import { Icon } from "~/components/assets"; -import { BaseCell, ColumnDef, RowDef } from "~/components/table/types"; -import { InfoTooltip } from "~/components/tooltip"; -import { CustomClasses } from "~/components/types"; -import { useWindowSize } from "~/hooks"; -import { replaceAt } from "~/utils/array"; - -interface Props extends CustomClasses { - /** Functionality common to all columns. */ - columnDefs: ColumnDef[]; - /** Functionality common to all rows. - * Supply an array to configure specific rows, otherwise def is applied to all rows. - */ - rowDefs?: RowDef[] | RowDef; - /** Table of partial data objects. Each custom `ColumnDef.displayCell` component is required to check - * for relevant data regardless, thus not requiring all data in each cell in table. - */ - data: Partial[][]; - headerTrClassName?: string; - tHeadClassName?: string; - tBodyClassName?: string; -} - -/** Generic table that accepts a 2d array of any type of data cell, - * as well as row and column definitions that dictate header and cell appearance & behavior. - */ -export const Table = ({ - columnDefs, - rowDefs, - data, - className, - headerTrClassName, - tHeadClassName, - tBodyClassName, -}: PropsWithoutRef>) => { - const { width } = useWindowSize(); - const router = useRouter(); - - // pass row hovered to cell components. Tailwind preferred for tr/tds. - const [rowsHovered, setRowsHovered] = useState(() => data.map(() => false)); - const setRowHovered = useCallback( - (rowIndex: number, value: boolean) => - setRowsHovered( - replaceAt( - value, - data.map(() => false), - rowIndex - ) - ), - [data] - ); - - return ( - - - - {columnDefs.map((colDef, colIndex) => { - if (colDef.collapseAt && width < colDef.collapseAt) { - return null; - } - - return ( - - ); - })} - - - - {data.map((row, rowIndex) => { - const rowDef = - rowDefs && Array.isArray(rowDefs) ? rowDefs[rowIndex] : rowDefs; - const rowHovered = rowsHovered[rowIndex] ?? false; - const rowIsButton = - rowDef !== undefined && rowDef.onClick && !rowDef.link; - - return ( - setRowHovered(rowIndex, true)} - onMouseLeave={() => setRowHovered(rowIndex, false)} - onClick={() => { - if (rowDef?.link) { - router.push(rowDef.link); - } - - if (rowDef?.onClick) { - rowDef.onClick(rowIndex); - } - }} - > - {/* layout row's cells */} - {row.map((cell, columnIndex) => { - const DisplayCell = columnDefs[columnIndex]?.displayCell; - const customClass = columnDefs[columnIndex]?.className; - const collapseAt = columnDefs[columnIndex]?.collapseAt; - - if (collapseAt && width < collapseAt) { - return null; - } - - return ( - - ); - })} - - ); - })} - -
colDef?.sort?.onClickHeader(colIndex)} - > - -
- {colDef?.display ? ( - typeof colDef.display === "string" ? ( - - {colDef.display} - - ) : ( - <>{colDef.display} - ) - ) : null} - {colDef?.sort && ( -
- {colDef?.sort?.currentDirection === "ascending" ? ( - - ) : colDef?.sort?.currentDirection === "descending" ? ( - - ) : undefined} -
- )} - {colDef.infoTooltip && ( - - )} -
-
-
- - {rowDef?.link ? ( - 0 ? -1 : 0} - > - {DisplayCell ? ( - - ) : ( - cell.value - )} - - ) : DisplayCell ? ( - - ) : ( - cell.value - )} - -
- ); -}; - -/** Wrap non-link non-visual content in a button for accessibility users. */ -const ClickableContent = ({ - isButton = false, - children, -}: PropsWithChildren<{ isButton?: boolean }>) => - isButton ? : <>{children}; - -export * from "./types"; diff --git a/packages/web/components/table/types.ts b/packages/web/components/table/types.ts deleted file mode 100644 index 0dd2719253..0000000000 --- a/packages/web/components/table/types.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ReactElement } from "react"; - -import { CustomClasses, SortDirection } from "~/components/types"; -import { Breakpoint } from "~/hooks"; - -export interface BaseCell { - /** "Default" value to be rendered. - * Leave undefined if using a custom ReactElement to render the cell - * (or have that component accept `value` in it's props). */ - value?: string; - rowHovered?: boolean; -} - -interface ColumnSortDef { - currentDirection?: SortDirection; - onClickHeader: (colIndex: number) => void; -} - -export interface ColumnDef extends CustomClasses { - /** Header label or element. */ - display: string | ReactElement; - /** If set, will enable column header as sort control. */ - sort?: ColumnSortDef; - /** If set, will show a 'i' icon tooltip for hover. */ - infoTooltip?: string; - /** If provided, will be used to render the cell for each row in this column. - * - * Note: components must accept optionals for all cell data and check for the data they need. */ - displayCell?: React.FunctionComponent>; - /** Use to make your table responsive. Uses `use-window-size/Breakpoint` to incrementally - * remove whole columns from display as the screen shrinks in order from `XXL` to `MD` size. - */ - collapseAt?: Breakpoint; -} - -export interface RowDef { - makeClass?: (rowIndex: number) => string; - makeHoverClass?: (rowIndex: number) => string; - onClick?: (rowIndex: number) => void; - /** Overrides `onClick`, will use next/link if row is clicked. */ - link?: string; -} diff --git a/packages/web/components/types.ts b/packages/web/components/types.ts index f34254a497..46eeec46e2 100644 --- a/packages/web/components/types.ts +++ b/packages/web/components/types.ts @@ -19,8 +19,6 @@ export interface Disableable { disabled?: boolean; } -export type SortDirection = "ascending" | "descending"; - export interface Metric { label: string; value: string | ReactElement; diff --git a/packages/web/hooks/data/use-sorted-data.ts b/packages/web/hooks/data/use-sorted-data.ts index b739078d38..4bf4e5eee4 100644 --- a/packages/web/hooks/data/use-sorted-data.ts +++ b/packages/web/hooks/data/use-sorted-data.ts @@ -1,6 +1,6 @@ +import { SortDirection } from "@osmosis-labs/utils"; import { useCallback, useMemo, useState } from "react"; -import { SortDirection } from "~/components/types"; import { DataSorter } from "~/hooks/data/data-sorter"; import { DataProcessor } from "~/hooks/data/types"; import { useUserProcessedData } from "~/hooks/data/use-user-processed-data"; @@ -32,13 +32,10 @@ export function useSortedData( [sorter, data] ); const [sortDirection, setSortDirection] = useState( - initialSortDirection ?? "ascending" + initialSortDirection ?? "asc" ); const toggleSortDirection = useCallback( - () => - setSortDirection( - sortDirection === "ascending" ? "descending" : "ascending" - ), + () => setSortDirection(sortDirection === "asc" ? "desc" : "asc"), [sortDirection] ); const [keypath, setKeypath, results] = useUserProcessedData( @@ -48,7 +45,7 @@ export function useSortedData( ); const directionalResults = useMemo( - () => (sortDirection === "descending" ? [...results].reverse() : results), + () => (sortDirection === "desc" ? [...results].reverse() : results), [sortDirection, results] ); diff --git a/packages/web/modals/superfluid-validator.tsx b/packages/web/modals/superfluid-validator.tsx index 08024bd95e..58c936ac25 100644 --- a/packages/web/modals/superfluid-validator.tsx +++ b/packages/web/modals/superfluid-validator.tsx @@ -1,14 +1,27 @@ import { CoinPretty, RatePretty } from "@keplr-wallet/unit"; import { BondStatus } from "@osmosis-labs/types"; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; import classNames from "classnames"; import { observer } from "mobx-react-lite"; -import { FunctionComponent, useMemo, useState } from "react"; +import { + FunctionComponent, + useCallback, + useMemo, + useRef, + useState, +} from "react"; import { SearchBox } from "~/components/input"; import { Spinner } from "~/components/loaders"; import { SkeletonLoader } from "~/components/loaders/skeleton-loader"; -import { Table } from "~/components/table"; import { ValidatorInfoCell } from "~/components/table/cells/"; +import { SortHeader } from "~/components/table/headers/sort"; import { InfoTooltip } from "~/components/tooltip"; import { Button } from "~/components/ui/button"; import { useTranslation } from "~/hooks"; @@ -16,8 +29,19 @@ import { useWindowSize } from "~/hooks"; import { useFilteredData, useSortedData } from "~/hooks/data"; import { ModalBase, ModalBaseProps } from "~/modals/base"; import { useStore } from "~/stores"; +import { theme } from "~/tailwind.config"; import { api } from "~/utils/trpc"; +type Validator = { + address: string; + validatorName?: string; + validatorImgSrc?: string; + validatorCommission: RatePretty; + isDelegated: number; +}; + +const defaultSortKey = "isDelegated"; + export const SuperfluidValidatorModal: FunctionComponent< { isSuperfluid?: boolean; @@ -38,6 +62,8 @@ export const SuperfluidValidatorModal: FunctionComponent< const { accountStore } = useStore(); const { isMobile } = useWindowSize(); + const tableScrollReference = useRef(null); + const account = accountStore.getWallet(accountStore.osmosisChainId); const { data: validators, isLoading: isLoadingAllValidators } = @@ -45,15 +71,18 @@ export const SuperfluidValidatorModal: FunctionComponent< status: BondStatus.Bonded, }); - const { data: userValidatorDelegations, isLoading: isLoadingUserValidators } = - api.edge.staking.getUserDelegations.useQuery( - { - userOsmoAddress: account?.address ?? "", - }, - { - enabled: !!account?.address, - } - ); + const { + data: userValidatorDelegations, + isLoading: isLoadingUserValidators, + isPreviousData, + } = api.edge.staking.getUserDelegations.useQuery( + { + userOsmoAddress: account?.address ?? "", + }, + { + enabled: !!account?.address, + } + ); const isLoadingValidators = isLoadingAllValidators || isLoadingUserValidators; @@ -73,13 +102,7 @@ export const SuperfluidValidatorModal: FunctionComponent< ); // get minimum info for display, mark validators users are delegated to - const activeDelegatedValidators: { - address: string; - validatorName?: string; - validatorImgSrc?: string; - validatorCommission: RatePretty; - isDelegated: number; - }[] = useMemo( + const activeDelegatedValidators: Validator[] = useMemo( () => validators?.map( ( @@ -108,14 +131,8 @@ export const SuperfluidValidatorModal: FunctionComponent< [validators, userValidatorDelegations, showDelegated, randomSortVals] ); - const [ - sortKey, - setSortKey, - sortDirection, - _, - toggleSortDirection, - sortedData, - ] = useSortedData(activeDelegatedValidators, "isDelegated", "descending"); + const [sortKey, setKeypath, sortDirection, setSortDirection, _, sortedData] = + useSortedData(activeDelegatedValidators, defaultSortKey, "desc"); const [query, setQuery, searchedValidators] = useFilteredData(sortedData, [ "validatorName", "validatorCommission", @@ -125,6 +142,94 @@ export const SuperfluidValidatorModal: FunctionComponent< string | null >(null); + const setSortKey = useCallback( + (key?: string) => { + if (key) { + setKeypath(key); + } else { + setKeypath(defaultSortKey); + } + }, + [setKeypath] + ); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + return [ + columnHelper.accessor((row) => row, { + id: "validatorInfo", + cell: ({ row: { original: validator } }) => ( + + ), + header: () => ( + + ), + }), + columnHelper.accessor((row) => row, { + id: "commission", + header: () => ( + + ), + cell: (cell) => cell.getValue().validatorCommission.toString(), + }), + ]; + }, [sortKey, sortDirection, setSortDirection, setSortKey, t]); + + const table = useReactTable({ + data: searchedValidators, + columns: columns, + manualSorting: true, + manualFiltering: true, + manualPagination: true, + enableFilters: false, + getCoreRowModel: getCoreRowModel(), + }); + + // #region virtualization + // Virtualization is used to render only the visible rows + // and save on performance and memory. + // As the user scrolls, invisible rows are removed from the DOM. + const topOffset = Number( + isMobile + ? theme.extend.height["navbar-mobile"].replace("px", "") + : theme.extend.height.navbar.replace("px", "") + ); + const rowHeightEstimate = 70.5; + const { rows } = table.getRowModel(); + const rowVirtualizer = useVirtualizer({ + count: rows.length, + estimateSize: () => rowHeightEstimate, + paddingStart: topOffset, + getScrollElement: () => tableScrollReference.current, + overscan: 5, + }); + const virtualRows = rowVirtualizer.getVirtualItems(); + const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? rowVirtualizer.getTotalSize() - + (virtualRows?.[virtualRows.length - 1]?.end || 0) + : 0; + return (
@@ -142,68 +247,89 @@ export const SuperfluidValidatorModal: FunctionComponent< size={isMobile ? "medium" : "small"} />
-
+
{isLoadingValidators ? (
) : ( - setSortKey("validatorName") }, - displayCell: ValidatorInfoCell, - }, - { - display: t("superfluidValidator.columns.commission"), - className: classNames( - "text-right !pr-3", - isMobile ? "caption" : undefined - ), - sort: - sortKey === "validatorCommission" - ? { - onClickHeader: toggleSortDirection, - currentDirection: sortDirection, - } - : { - onClickHeader: () => - setSortKey("validatorCommission"), - }, - }, - ]} - rowDefs={searchedValidators.map(({ address, isDelegated }) => ({ - makeClass: () => - `!h-fit ${ - address === selectedValidatorAddress - ? "border border-osmoverse-500" - : isDelegated === 1 - ? "bg-osmoverse-800" - : "bg-osmoverse-900" - }`, - makeHoverClass: () => "bg-osmoverse-900", - onClick: () => setSelectedValidatorAddress(address), - }))} - data={searchedValidators.map( - ({ validatorName, validatorImgSrc, validatorCommission }) => [ - { value: validatorName, imgSrc: validatorImgSrc }, - { value: validatorCommission.toString() }, - ] +
+ > + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {paddingTop > 0 && paddingTop - topOffset > 0 && ( + + + )} + {isLoadingValidators && ( + + + + )} + {virtualRows.map((virtualRow) => { + const { + id, + original: { address }, + } = rows[virtualRow.index]; + return ( + setSelectedValidatorAddress(address)} + > + {rows[virtualRow.index].getVisibleCells().map((cell) => ( + + ))} + + ); + })} + {paddingBottom > 0 && ( + + + )} + +
+ {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+
+ +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+
)}
{availableBondAmount && ( diff --git a/packages/web/modals/validator-squad-modal.tsx b/packages/web/modals/validator-squad-modal.tsx index f399ea385b..6b8ee0b91f 100644 --- a/packages/web/modals/validator-squad-modal.tsx +++ b/packages/web/modals/validator-squad-modal.tsx @@ -524,7 +524,7 @@ export const ValidatorSquadModal: FunctionComponent = .getHeaderGroups() .slice(1) .map((headerGroup) => ( - + {headerGroup.headers.map((header) => { return (