diff --git a/package.json b/package.json index 4b866230ff..2b83731728 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "prettier": "^2.8.8", "ts-jest": "^29.1.2", "turbo": "^1.13.3", - "typescript": "5.4.3" + "typescript": "5.4.5" }, "packageManager": "yarn@1.22.22", "resolutions": { diff --git a/packages/bridge/package.json b/packages/bridge/package.json index 99f1c6a595..2d7cb64e8c 100644 --- a/packages/bridge/package.json +++ b/packages/bridge/package.json @@ -46,7 +46,7 @@ "@types/jest-in-case": "^1.0.6", "jest-in-case": "^1.0.2", "ts-jest": "^29.1.0", - "typescript": "^5.4.3" + "typescript": "^5.4.5" }, "lint-staged": { "*": [ diff --git a/packages/bridge/src/errors.ts b/packages/bridge/src/errors.ts index 6054d1237e..def193473c 100644 --- a/packages/bridge/src/errors.ts +++ b/packages/bridge/src/errors.ts @@ -51,4 +51,5 @@ export enum BridgeError { CreateEVMTxError = "CreateEVMTxError", NoQuotesError = "NoQuotesError", UnsupportedQuoteError = "UnsupportedQuoteError", + InsufficientAmount = "InsufficientAmountError", } diff --git a/packages/bridge/src/ibc/__tests__/ibc-bridge-provider.spec.ts b/packages/bridge/src/ibc/__tests__/ibc-bridge-provider.spec.ts new file mode 100644 index 0000000000..1608aa1441 --- /dev/null +++ b/packages/bridge/src/ibc/__tests__/ibc-bridge-provider.spec.ts @@ -0,0 +1,235 @@ +import { Int } from "@keplr-wallet/unit"; +import { estimateGasFee } from "@osmosis-labs/tx"; +import { CacheEntry } from "cachified"; +import { LRUCache } from "lru-cache"; + +import { MockAssetLists } from "../../__tests__/mock-asset-lists"; +import { MockChains } from "../../__tests__/mock-chains"; +import { BridgeQuoteError } from "../../errors"; +import { + BridgeProviderContext, + BridgeQuote, + GetBridgeQuoteParams, +} from "../../interface"; +import { IbcBridgeProvider } from "../index"; + +jest.mock("@osmosis-labs/tx", () => ({ + estimateGasFee: jest.fn(), +})); + +const mockContext: BridgeProviderContext = { + chainList: MockChains, + assetLists: MockAssetLists, + getTimeoutHeight: jest.fn().mockResolvedValue("1000-1000"), + env: "mainnet", + cache: new LRUCache({ max: 10 }), +}; + +// deposit of ATOM from Cosmos Hub to Osmosis +const mockAtomToOsmosis: GetBridgeQuoteParams = { + fromChain: { + chainId: "cosmoshub-4", + chainType: "cosmos", + }, + toChain: { + chainId: "osmosis-1", + chainType: "cosmos", + }, + fromAsset: { + address: "uatom", + sourceDenom: "uatom", + denom: "ATOM", + decimals: 6, + }, + toAsset: { + address: + "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + sourceDenom: "uatom", + denom: "ATOM", + decimals: 6, + }, + fromAmount: "1000000", // 1 ATOM + fromAddress: "osmo1...", + toAddress: "cosmos1...", +}; + +const mockAtomFromOsmosis: GetBridgeQuoteParams = { + fromChain: { + chainId: "osmosis-1", + chainType: "cosmos", + }, + toChain: { + chainId: "cosmoshub-4", + chainType: "cosmos", + }, + fromAsset: { + address: + "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2", + sourceDenom: "uatom", + denom: "ATOM", + decimals: 6, + }, + toAsset: { + address: "uatom", + sourceDenom: "uatom", + denom: "ATOM", + decimals: 6, + }, + fromAmount: "1000000", // 1 ATOM + fromAddress: "osmo1...", + toAddress: "cosmos1...", +}; + +describe("IbcBridgeProvider", () => { + let provider: IbcBridgeProvider; + + beforeEach(() => { + provider = new IbcBridgeProvider(mockContext); + jest.clearAllMocks(); + }); + + it("should throw an error if fromChain or toChain is not cosmos", async () => { + const invalidParams: GetBridgeQuoteParams = { + ...mockAtomToOsmosis, + fromChain: { chainId: 1, chainType: "evm" }, + }; + await expect(provider.getQuote(invalidParams)).rejects.toThrow( + BridgeQuoteError + ); + }); + + it("should throw an error if gas cost exceeds transfer amount", async () => { + (estimateGasFee as jest.Mock).mockResolvedValue({ + amount: [ + { + amount: new Int(mockAtomFromOsmosis.fromAmount) + .add(new Int(100)) + .toString(), + denom: "uatom", + isNeededForTx: true, + }, + ], + }); + + await expect(provider.getQuote(mockAtomToOsmosis)).rejects.toThrow( + BridgeQuoteError + ); + }); + + it("should return a valid BridgeQuote", async () => { + (estimateGasFee as jest.Mock).mockResolvedValue({ + amount: [{ amount: "5000", denom: "uatom", isNeededForTx: true }], + }); + + const quote: BridgeQuote = await provider.getQuote(mockAtomToOsmosis); + + expect(quote).toHaveProperty("input"); + expect(quote).toHaveProperty("expectedOutput"); + expect(quote).toHaveProperty("fromChain"); + expect(quote.fromChain.chainId).toBe(mockAtomToOsmosis.fromChain.chainId); + expect(quote).toHaveProperty("toChain"); + expect(quote.toChain.chainId).toBe(mockAtomToOsmosis.toChain.chainId); + expect(quote).toHaveProperty("transferFee"); + expect(quote.transferFee.amount).toBe("0"); + expect(quote).toHaveProperty("estimatedTime"); + expect(quote).toHaveProperty("estimatedGasFee"); + expect(quote).toHaveProperty("estimatedGasFee"); + expect(quote.estimatedGasFee!.amount).toBe("5000"); + expect(quote).toHaveProperty("transactionRequest"); + }); + + it("should calculate the correct toAmount when gas fee is needed for tx", async () => { + (estimateGasFee as jest.Mock).mockResolvedValue({ + amount: [{ amount: "5000", denom: "uatom", isNeededForTx: true }], + }); + + const quote: BridgeQuote = await provider.getQuote(mockAtomToOsmosis); + + expect(quote.expectedOutput.amount).toBe( + new Int(mockAtomToOsmosis.fromAmount).sub(new Int("5000")).toString() + ); + }); + + it("should calculate the correct toAmount when gas fee is not needed for tx", async () => { + (estimateGasFee as jest.Mock).mockResolvedValue({ + amount: [{ amount: "5000", denom: "uatom", isNeededForTx: false }], + }); + + const quote: BridgeQuote = await provider.getQuote(mockAtomToOsmosis); + + expect(quote.expectedOutput.amount).toBe(mockAtomToOsmosis.fromAmount); + }); + + describe("getIbcSource", () => { + // extend class to access protected method + class ExtendedIbcBridgeProvider extends IbcBridgeProvider { + public getIbcSourcePublic(params: GetBridgeQuoteParams): { + sourceChannel: string; + sourcePort: string; + sourceDenom: string; + } { + return this.getIbcSource(params); + } + } + + let provider: ExtendedIbcBridgeProvider; + + beforeEach(() => { + provider = new ExtendedIbcBridgeProvider(mockContext); + }); + + it("should return the correct channel, port and denom for transfer from source", () => { + const result = provider.getIbcSourcePublic(mockAtomFromOsmosis); + + expect(result.sourceChannel).toBe("channel-0"); + expect(result.sourcePort).toBe("transfer"); + expect(result.sourceDenom).toBe( + "ibc/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2" + ); + }); + + it("should return the correct channel, port and denom for transfer from counterparty", () => { + const result = provider.getIbcSourcePublic(mockAtomToOsmosis); + + expect(result.sourceChannel).toBe("channel-141"); + expect(result.sourcePort).toBe("transfer"); + expect(result.sourceDenom).toBe("uatom"); + }); + + it("should throw if asset not found", () => { + const invalidParams: GetBridgeQuoteParams = { + ...mockAtomToOsmosis, + toAsset: { + ...mockAtomToOsmosis.toAsset, + sourceDenom: "not-found", + }, + fromAsset: { + ...mockAtomToOsmosis.fromAsset, + sourceDenom: "not-found", + }, + }; + + expect(() => provider.getIbcSourcePublic(invalidParams)).toThrow( + BridgeQuoteError + ); + }); + + it("should throw if there's no transfer method", () => { + const invalidParams: GetBridgeQuoteParams = { + ...mockAtomToOsmosis, + toAsset: { + ...mockAtomToOsmosis.toAsset, + sourceDenom: "uosmo", + }, + fromAsset: { + ...mockAtomToOsmosis.fromAsset, + sourceDenom: "uosmo", + }, + }; + + expect(() => provider.getIbcSourcePublic(invalidParams)).toThrow( + BridgeQuoteError + ); + }); + }); +}); diff --git a/packages/bridge/src/ibc/index.ts b/packages/bridge/src/ibc/index.ts index d38917e043..b586362403 100644 --- a/packages/bridge/src/ibc/index.ts +++ b/packages/bridge/src/ibc/index.ts @@ -66,6 +66,15 @@ export class IbcBridgeProvider implements BridgeProvider { ? new Int(params.fromAmount).sub(new Int(gasFee.amount)).toString() : params.fromAmount; + if (new Int(toAmount).lte(new Int(0))) { + throw new BridgeQuoteError([ + { + errorType: BridgeError.InsufficientAmount, + message: "Insufficient amount for fees", + }, + ]); + } + return { input: { amount: params.fromAmount, diff --git a/packages/proto-codecs/package.json b/packages/proto-codecs/package.json index 0a9e3c8c77..041b8f419d 100644 --- a/packages/proto-codecs/package.json +++ b/packages/proto-codecs/package.json @@ -44,7 +44,7 @@ "devDependencies": { "@cosmology/proto-parser": "^1.5.0", "@cosmology/telescope": "^1.5.1", - "@types/node": "^18.16.3", + "@types/node": "^20.14.1", "regenerator-runtime": "^0.13.11", "rimraf": "^5.0.0", "tsx": "^4.6.2" diff --git a/packages/server/package.json b/packages/server/package.json index 0fe3b5c14f..1910a49735 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -51,7 +51,7 @@ "@types/jest-in-case": "^1.0.6", "jest-in-case": "^1.0.2", "ts-jest": "^29.1.0", - "typescript": "^5.4.3" + "typescript": "^5.4.5" }, "lint-staged": { "*": [ diff --git a/packages/server/src/queries/complex/assets/__tests__/categories.spec.ts b/packages/server/src/queries/complex/assets/__tests__/categories.spec.ts index f5f6c6687f..253d1bba23 100644 --- a/packages/server/src/queries/complex/assets/__tests__/categories.spec.ts +++ b/packages/server/src/queries/complex/assets/__tests__/categories.spec.ts @@ -1,4 +1,4 @@ -import dayjs from "../../../../utils/dayjs"; +import { dayjs } from "../../../../utils/dayjs"; import { AssetLists } from "../../../__tests__/mock-asset-lists"; import { isAssetInCategories } from "../categories"; diff --git a/packages/server/src/queries/complex/assets/categories.ts b/packages/server/src/queries/complex/assets/categories.ts index 292e104a58..c835053172 100644 --- a/packages/server/src/queries/complex/assets/categories.ts +++ b/packages/server/src/queries/complex/assets/categories.ts @@ -3,7 +3,7 @@ import cachified, { CacheEntry } from "cachified"; import { LRUCache } from "lru-cache"; import { DEFAULT_LRU_OPTIONS } from "../../../utils"; -import dayjs from "../../../utils/dayjs"; +import { dayjs } from "../../../utils/dayjs"; import { queryUpcomingAssets } from "../../github"; /** Filters an asset for whether it is included in the given list of categories. */ diff --git a/packages/server/src/queries/complex/assets/price/historical.ts b/packages/server/src/queries/complex/assets/price/historical.ts index f96ee6a9e0..a297cc47a2 100644 --- a/packages/server/src/queries/complex/assets/price/historical.ts +++ b/packages/server/src/queries/complex/assets/price/historical.ts @@ -2,7 +2,7 @@ import cachified, { CacheEntry } from "cachified"; import { LRUCache } from "lru-cache"; import { DEFAULT_LRU_OPTIONS } from "../../../../utils/cache"; -import dayjs from "../../../../utils/dayjs"; +import { dayjs } from "../../../../utils/dayjs"; import { queryMarketChart } from "../../../coingecko"; import { queryTokenHistoricalChart, diff --git a/packages/server/src/queries/complex/earn/strategies.ts b/packages/server/src/queries/complex/earn/strategies.ts index 0c130b1e83..016f1e7b32 100644 --- a/packages/server/src/queries/complex/earn/strategies.ts +++ b/packages/server/src/queries/complex/earn/strategies.ts @@ -17,7 +17,7 @@ import { } from "../../../queries/data-services/earn"; import { queryOsmosisCMS } from "../../../queries/github"; import { DEFAULT_LRU_OPTIONS } from "../../../utils/cache"; -import dayjs from "../../../utils/dayjs"; +import { dayjs } from "../../../utils/dayjs"; import { captureIfError } from "../../../utils/error"; import { type Asset, getAsset } from "../assets"; import { DEFAULT_VS_CURRENCY } from "../assets/config"; diff --git a/packages/server/src/queries/complex/osmosis/lockup.ts b/packages/server/src/queries/complex/osmosis/lockup.ts index 0917d24233..788ae9738c 100644 --- a/packages/server/src/queries/complex/osmosis/lockup.ts +++ b/packages/server/src/queries/complex/osmosis/lockup.ts @@ -1,7 +1,7 @@ import { Chain } from "@osmosis-labs/types"; import { Duration } from "dayjs/plugin/duration"; -import dayjs from "../../../utils/dayjs"; +import { dayjs } from "../../../utils/dayjs"; import { queryAccountLockedLongerDuration } from "../../osmosis/lockup"; export type UserLock = { diff --git a/packages/server/src/queries/complex/pools/bonding.ts b/packages/server/src/queries/complex/pools/bonding.ts index c46dfed638..1e19421ff5 100644 --- a/packages/server/src/queries/complex/pools/bonding.ts +++ b/packages/server/src/queries/complex/pools/bonding.ts @@ -2,7 +2,7 @@ import { CoinPretty, PricePretty, RatePretty } from "@keplr-wallet/unit"; import { AssetList, Chain } from "@osmosis-labs/types"; import type { Duration } from "dayjs/plugin/duration"; -import dayjs from "../../../utils/dayjs"; +import { dayjs } from "../../../utils/dayjs"; import { captureErrorAndReturn } from "../../../utils/error"; import { querySyntheticLockupsByLockId } from "../../osmosis/lockup"; import { diff --git a/packages/server/src/utils/dayjs.ts b/packages/server/src/utils/dayjs.ts index 0ccaba912d..62caf9abdb 100644 --- a/packages/server/src/utils/dayjs.ts +++ b/packages/server/src/utils/dayjs.ts @@ -5,5 +5,4 @@ import duration from "dayjs/plugin/duration"; dayjs.extend(duration); -// eslint-disable-next-line import/no-default-export -export default dayjs; +export { dayjs }; diff --git a/packages/trpc/package.json b/packages/trpc/package.json index db8a63d27f..1896454d0e 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -46,7 +46,7 @@ "@types/jest-in-case": "^1.0.6", "jest-in-case": "^1.0.2", "ts-jest": "^29.1.0", - "typescript": "^5.4.3" + "typescript": "^5.4.5" }, "lint-staged": { "*": [ diff --git a/packages/trpc/src/api.ts b/packages/trpc/src/api.ts index 74258bc260..a1e594348b 100644 --- a/packages/trpc/src/api.ts +++ b/packages/trpc/src/api.ts @@ -18,7 +18,7 @@ import { trpcMiddleware } from "./middleware"; /** * Pass asset lists and chain list to be used cas context in backend service. */ -export type CreateContextOptions = { +type CreateContextOptions = { assetLists: AssetList[]; chainList: Chain[]; }; diff --git a/packages/tx/package.json b/packages/tx/package.json index 11a68e99f6..99b4337291 100644 --- a/packages/tx/package.json +++ b/packages/tx/package.json @@ -41,7 +41,7 @@ "@types/jest-in-case": "^1.0.6", "jest-in-case": "^1.0.2", "ts-jest": "^29.1.0", - "typescript": "^5.4.3" + "typescript": "^5.4.5" }, "lint-staged": { "*": [ diff --git a/packages/tx/src/__tests__/events.spec.ts b/packages/tx/src/__tests__/events.spec.ts index 9e4bb1fb15..1fc006ca41 100644 --- a/packages/tx/src/__tests__/events.spec.ts +++ b/packages/tx/src/__tests__/events.spec.ts @@ -63,6 +63,35 @@ describe("getSumTotalSpenderCoinsSpent", () => { expect(coins).toEqual([]); }); + + it("should only total the amounts for the specified sender", () => { + const coins = getSumTotalSpenderCoinsSpent( + "osmo1gtgx92pxk6hvhc3c3g0xlkrwqq6knymu0e0caw", + mockMultipleEvents + ); + + // Expected result is the sum of the amounts for the specified sender, not for osmo13vhcd3xllpvz8tql4dzp8yszxeas8zxpzptyvjttdy7m64kuyz5sv6caqq + const expectedResult = [ + { + denom: + "factory/osmo1z0qrq605sjgcqpylfl4aa6s90x738j7m58wyatt0tdzflg2ha26q67k743/wbtc", + amount: "58573", + }, + { + denom: + "ibc/EA1D43981D5C9A1C4AAEA9C23BB1D4FA126BA9BC7020A25E0AE4AA841EA25DC5", + amount: "21083680327000100", + }, + { + denom: + "ibc/F4A070A6D78496D53127EA85C094A9EC87DFC1F36071B8CCDDBD020F933D213D", + amount: "131211643845355500", + }, + { denom: "uosmo", amount: "95799380" }, + ]; + + expect(coins).toEqual(expectedResult); + }); }); describe("matchRawCoinValue", () => { @@ -225,4 +254,26 @@ const mockMultipleEvents = [ }, ], }, + { + type: "coin_spent", + attributes: [ + { + key: "spender", + value: + "osmo13vhcd3xllpvz8tql4dzp8yszxeas8zxpzptyvjttdy7m64kuyz5sv6caqq", + index: true, + }, + { + key: "amount", + value: + "2605ibc/498A0751C798A0D9A389AA3691123DADA57DAA4FE165D5C75894505B876BA6E4", + index: true, + }, + { + key: "msg_index", + value: "0", + index: true, + }, + ], + }, ]; diff --git a/packages/tx/src/__tests__/poll-status.spec.ts b/packages/tx/src/__tests__/poll-status.spec.ts new file mode 100644 index 0000000000..d8d6c63a94 --- /dev/null +++ b/packages/tx/src/__tests__/poll-status.spec.ts @@ -0,0 +1,316 @@ +import { queryRPCStatus, QueryStatusResponse } from "@osmosis-labs/server"; + +import { PollingStatusSubscription, StatusHandler } from "../index"; + +jest.useFakeTimers(); + +/** Stuff that's not used but in the status response type. */ +const baseMockStatusResult: QueryStatusResponse["result"] = { + validator_info: { + address: "mock_address", + pub_key: { + type: "mock_type", + value: "mock_value", + }, + voting_power: "mock_voting_power", + }, + node_info: { + protocol_version: { + p2p: "mock_p2p", + block: "mock_block", + app: "mock_app", + }, + id: "mock_id", + listen_addr: "mock_listen_addr", + network: "mock_network", + version: "mock_version", + channels: "mock_channels", + moniker: "mock_moniker", + other: { + tx_index: "on" as const, + rpc_address: "mock_rpc_address", + }, + }, + sync_info: { + // overwrite these, but is otherwise a reasonable time range + latest_block_hash: "mock_latest_block_hash", + latest_app_hash: "mock_latest_app_hash", + earliest_block_hash: "mock_earliest_block_hash", + earliest_app_hash: "mock_earliest_app_hash", + latest_block_height: "100", + earliest_block_height: "90", + latest_block_time: new Date(Date.now() - 10000).toISOString(), + earliest_block_time: new Date(Date.now() - 20000).toISOString(), + catching_up: false, + }, +}; + +jest.mock("@osmosis-labs/server", () => ({ + queryRPCStatus: jest.fn().mockResolvedValue({ + jsonrpc: "2.0", + id: 1, + result: { + validator_info: { + address: "mock_address", + pub_key: { + type: "mock_type", + value: "mock_value", + }, + voting_power: "mock_voting_power", + }, + node_info: { + protocol_version: { + p2p: "mock_p2p", + block: "mock_block", + app: "mock_app", + }, + id: "mock_id", + listen_addr: "mock_listen_addr", + network: "mock_network", + version: "mock_version", + channels: "mock_channels", + moniker: "mock_moniker", + other: { + tx_index: "on" as const, + rpc_address: "mock_rpc_address", + }, + }, + sync_info: { + // reasonable time range + latest_block_hash: "mock_latest_block_hash", + latest_app_hash: "mock_latest_app_hash", + earliest_block_hash: "mock_earliest_block_hash", + earliest_app_hash: "mock_earliest_app_hash", + latest_block_height: "100", + earliest_block_height: "90", + latest_block_time: new Date(Date.now() - 10000).toISOString(), + earliest_block_time: new Date(Date.now() - 20000).toISOString(), + catching_up: false, + }, + }, + }), + DEFAULT_LRU_OPTIONS: { max: 10 }, +})); + +describe("PollingStatusSubscription", () => { + const mockRPC = "http://mock-rpc-url"; + const defaultBlockTimeMs = 7500; + let subscription: PollingStatusSubscription; + + beforeEach(() => { + subscription = new PollingStatusSubscription(mockRPC, defaultBlockTimeMs); + jest.clearAllMocks(); + }); + + it("should initialize with zero subscriptions", () => { + expect(subscription.subscriptionCount).toBe(0); + }); + + it("should handle errors in startSubscription gracefully", async () => { + const handler: StatusHandler = jest.fn(); + (queryRPCStatus as jest.Mock).mockRejectedValue(new Error("Network error")); + + // starts loop + subscription.subscribe(handler); + + // end loop and flush event loop + jest.runAllTimers(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("should increase subscription count when a handler is subscribed", () => { + const handler: StatusHandler = jest.fn(); + subscription.subscribe(handler); + + expect(subscription.subscriptionCount).toBe(1); + + // end loop and flush event loop + jest.runAllTimers(); + }); + + it("should decrease subscription count when a handler is unsubscribed", () => { + const handler: StatusHandler = jest.fn(); + const unsubscribe = subscription.subscribe(handler); + unsubscribe(); + + jest.runAllTimers(); + + expect(subscription.subscriptionCount).toBe(0); + }); + + it("should call handlers with status and block time", async () => { + const mockStatus: QueryStatusResponse = { + jsonrpc: "2.0", + id: 1, + result: { + ...baseMockStatusResult, + sync_info: { + ...baseMockStatusResult.sync_info, + catching_up: false, + latest_block_height: "100", + earliest_block_height: "90", + latest_block_time: new Date().toISOString(), + earliest_block_time: new Date(Date.now() - 10000).toISOString(), + }, + }, + }; + + (queryRPCStatus as jest.Mock).mockResolvedValue(mockStatus); + + const handler: StatusHandler = jest.fn(); + subscription.subscribe(handler); + + // Run all timers to ensure the subscription logic completes + jest.runAllTimers(); + + // Ensure all promises are resolved by pushing to the event queue + await Promise.resolve(); + + expect(handler).toHaveBeenCalledWith(mockStatus, expect.any(Number)); + }); + + describe("calcAverageBlockTimeMs", () => { + class TestPollingStatusSubscription extends PollingStatusSubscription { + public test(status: QueryStatusResponse): number { + return this.calcAverageBlockTimeMs(status); + } + } + + let avgBlockTimeSub: TestPollingStatusSubscription; + + beforeEach(() => { + avgBlockTimeSub = new TestPollingStatusSubscription( + mockRPC, + defaultBlockTimeMs + ); + }); + + it("should return default block time if catching up", () => { + const mockStatus: QueryStatusResponse = { + jsonrpc: "2.0", + id: 1, + result: { + ...baseMockStatusResult, + sync_info: { + ...baseMockStatusResult.sync_info, + catching_up: true, + }, + }, + }; + + const blockTime = avgBlockTimeSub.test(mockStatus); + expect(blockTime).toBe(defaultBlockTimeMs); + }); + + it("should return default block time if block height is NaN", () => { + const mockStatus: QueryStatusResponse = { + jsonrpc: "2.0", + id: 1, + result: { + ...baseMockStatusResult, + sync_info: { + ...baseMockStatusResult.sync_info, + catching_up: false, + latest_block_height: "NaN", + earliest_block_height: "NaN", + latest_block_time: new Date().toISOString(), + earliest_block_time: new Date().toISOString(), + }, + }, + }; + + const blockTime = avgBlockTimeSub.test(mockStatus); + expect(blockTime).toBe(defaultBlockTimeMs); + }); + + it("should calculate a reasonable avg default block time", () => { + const mockStatus: QueryStatusResponse = { + jsonrpc: "2.0", + id: 1, + result: { + ...baseMockStatusResult, + sync_info: { + ...baseMockStatusResult.sync_info, + catching_up: false, + latest_block_height: "100", + earliest_block_height: "90", + latest_block_time: new Date(Date.now() - 10000).toISOString(), + earliest_block_time: new Date(Date.now() - 20000).toISOString(), + }, + }, + }; + + const blockTime = avgBlockTimeSub.test(mockStatus); + const expectedBlockTime = + (new Date(mockStatus.result.sync_info.latest_block_time).getTime() - + new Date(mockStatus.result.sync_info.earliest_block_time).getTime()) / + (parseInt(mockStatus.result.sync_info.latest_block_height) - + parseInt(mockStatus.result.sync_info.earliest_block_height)); + expect(blockTime).toBe(Math.ceil(expectedBlockTime)); + }); + + it("should return default block time if block time is unreasonable", () => { + const mockStatus: QueryStatusResponse = { + jsonrpc: "2.0", + id: 1, + result: { + ...baseMockStatusResult, + sync_info: { + ...baseMockStatusResult.sync_info, + catching_up: false, + latest_block_height: "100", + earliest_block_height: "90", + latest_block_time: new Date().toISOString(), + earliest_block_time: new Date(Date.now() - 1000000).toISOString(), + }, + }, + }; + + const blockTime = avgBlockTimeSub.test(mockStatus); + expect(blockTime).toBe(defaultBlockTimeMs); + }); + + it("should return default block time if latest block height is less or equal to than earliest block height", () => { + const mockStatus: QueryStatusResponse = { + jsonrpc: "2.0", + id: 1, + result: { + ...baseMockStatusResult, + sync_info: { + ...baseMockStatusResult.sync_info, + catching_up: false, + latest_block_height: "80", + earliest_block_height: "90", + latest_block_time: new Date().toISOString(), + earliest_block_time: new Date(Date.now() - 1000000).toISOString(), + }, + }, + }; + + const blockTime = avgBlockTimeSub.test(mockStatus); + expect(blockTime).toBe(defaultBlockTimeMs); + }); + + it("should return default block time if an invalid block time value is returned", () => { + const mockStatus: QueryStatusResponse = { + jsonrpc: "2.0", + id: 1, + result: { + ...baseMockStatusResult, + sync_info: { + ...baseMockStatusResult.sync_info, + catching_up: false, + latest_block_height: "80", + earliest_block_height: "90", + latest_block_time: "invalid", + earliest_block_time: new Date(Date.now() - 1000000).toISOString(), + }, + }, + }; + + const blockTime = avgBlockTimeSub.test(mockStatus); + expect(blockTime).toBe(defaultBlockTimeMs); + }); + }); +}); diff --git a/packages/tx/src/events.ts b/packages/tx/src/events.ts index 6a31b2096f..09d569e3aa 100644 --- a/packages/tx/src/events.ts +++ b/packages/tx/src/events.ts @@ -15,7 +15,8 @@ export function getSumTotalSpenderCoinsSpent( if (type !== "coin_spent") return; if (attributes.length === 0) return; const spendAttribute = attributes.find((attr) => attr.key === "spender"); - if (spendAttribute && spendAttribute.value !== spenderBech32Address) return; + if (!spendAttribute || spendAttribute.value !== spenderBech32Address) + return; // a comma separated list of coins spent const coinsSpentRawAttribute = attributes.find( diff --git a/packages/tx/src/gas.ts b/packages/tx/src/gas.ts index e45df1bebb..d6b22a9eb0 100644 --- a/packages/tx/src/gas.ts +++ b/packages/tx/src/gas.ts @@ -297,9 +297,15 @@ export async function getGasFeeAmount({ bech32Address, }), ]); - const feeBalances = balances.filter((balance) => - chainFeeDenoms.includes(balance.denom) - ); + const feeBalances: { denom: string; amount: string }[] = []; + + // iterate in order of fee denoms + for (const denom of chainFeeDenoms) { + const balance = balances.find((balance) => balance.denom === denom); + if (balance) { + feeBalances.push(balance); + } + } if (!feeBalances.length) { throw new InsufficientFeeError( diff --git a/packages/tx/src/poll-status.ts b/packages/tx/src/poll-status.ts index c87f41cbd3..36353cf722 100644 --- a/packages/tx/src/poll-status.ts +++ b/packages/tx/src/poll-status.ts @@ -36,18 +36,20 @@ export class PollingStatusSubscription { } protected async startSubscription() { + let timeoutId: NodeJS.Timeout | undefined; while (this._subscriptionCount > 0) { try { const status = await queryRPCStatus({ restUrl: this.rpc }); const blockTime = this.calcAverageBlockTimeMs(status); this._handlers.forEach((handler) => handler(status, blockTime)); await new Promise((resolve) => { - setTimeout(resolve, blockTime); + timeoutId = setTimeout(resolve, blockTime); }); } catch (e: any) { - console.error(`Failed to fetch /status: ${e?.toString()}`); + console.error(`Failed to fetch /status: ${e}`); } } + if (timeoutId) clearTimeout(timeoutId); } protected increaseSubscriptionCount() { @@ -68,6 +70,8 @@ export class PollingStatusSubscription { * The estimate is a rough estimate from the latest and earliest block times in sync info, so it may * not be fully up to date if block time changes. * + * Prefers returning defaults vs throwing errors. + * * Returns the default block time if the calculated block time is unexpected or unreasonable. */ protected calcAverageBlockTimeMs(status: QueryStatusResponse): number { @@ -87,6 +91,11 @@ export class PollingStatusSubscription { return this.defaultBlockTimeMs; } + // prevent division by zero + if (latestBlockHeight <= earliestBlockHeight) { + return this.defaultBlockTimeMs; + } + const latestBlockTime = new Date( status.result.sync_info.latest_block_time ).getTime(); @@ -94,10 +103,6 @@ export class PollingStatusSubscription { status.result.sync_info.earliest_block_time ).getTime(); - if (latestBlockHeight <= earliestBlockHeight) { - return this.defaultBlockTimeMs; - } - const avg = Math.ceil( (latestBlockTime - earliestBlockTime) / (latestBlockHeight - earliestBlockHeight) diff --git a/packages/web/.eslintrc.json b/packages/web/.eslintrc.json index 1c409e00b0..64943dc141 100644 --- a/packages/web/.eslintrc.json +++ b/packages/web/.eslintrc.json @@ -25,6 +25,17 @@ "simple-import-sort/imports": "error", "simple-import-sort/exports": "error", "unused-imports/no-unused-imports": "error", - "react/no-unescaped-entities": "off" - } + "react/no-unescaped-entities": "off", + "import/no-default-export": "error" + // see https://stackoverflow.com/questions/44378395/how-to-configure-eslint-so-that-it-disallows-default-exports + }, + "overrides": [ + // Pages router, config files + { + "files": ["pages/**/*", "playwright.config.ts"], + "rules": { + "import/no-default-export": "off" + } + } + ] } diff --git a/packages/web/components/ad-banner/__tests__/ad-banner-content.spec.tsx b/packages/web/components/ad-banner/__tests__/ad-banner-content.spec.tsx index 1342fc32c8..d32e8e4c9c 100644 --- a/packages/web/components/ad-banner/__tests__/ad-banner-content.spec.tsx +++ b/packages/web/components/ad-banner/__tests__/ad-banner-content.spec.tsx @@ -1,4 +1,4 @@ -import { screen } from "@testing-library/react"; +import { act, screen } from "@testing-library/react"; import { renderWithProviders } from "~/__tests__/test-utils"; import { SwapAdBannerResponse } from "~/pages"; @@ -21,7 +21,10 @@ test("renders ad banner content correctly", () => { featured: true, }; - renderWithProviders(); + act(() => { + renderWithProviders(); + }); + const headerElement = screen.getByText(mockAd.headerOrTranslationKey); const subheaderElement = screen.getByText(mockAd.subheaderOrTranslationKey); const imageElement = screen.getByAltText(mockAd.iconImageAltOrTranslationKey); @@ -62,12 +65,15 @@ test("renders ad banner content with localization correctly", () => { const ad = mockAdResponseWithLocalization.banners[0]; - renderWithProviders( - - ); + act(() => { + renderWithProviders( + + ); + }); + const headerElement = screen.getByText("Mock Header"); const subheaderElement = screen.getByText("Mock Subheader"); const imageElement = screen.getByAltText("Mock Icon"); diff --git a/packages/web/components/assets/highlights-categories.tsx b/packages/web/components/assets/highlights-categories.tsx index 4ea063383b..4af56149d5 100644 --- a/packages/web/components/assets/highlights-categories.tsx +++ b/packages/web/components/assets/highlights-categories.tsx @@ -4,7 +4,7 @@ import Link from "next/link"; import { FunctionComponent, ReactNode } from "react"; import { PriceChange } from "~/components/assets/price"; -import SkeletonLoader from "~/components/loaders/skeleton-loader"; +import { SkeletonLoader } from "~/components/loaders/skeleton-loader"; import { EventName } from "~/config"; import { Breakpoint, diff --git a/packages/web/components/assets/notifi-alerts/position-near-bounds.tsx b/packages/web/components/assets/notifi-alerts/position-near-bounds.tsx deleted file mode 100644 index b2c8ff79fd..0000000000 --- a/packages/web/components/assets/notifi-alerts/position-near-bounds.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { FunctionComponent } from "react"; - -export const PositionNearBoundsIcon: FunctionComponent<{ - className?: string; -}> = ({ className }) => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/packages/web/components/assets/notifi-alerts/price-alert.tsx b/packages/web/components/assets/notifi-alerts/price-alert.tsx deleted file mode 100644 index 9f4dc62cb0..0000000000 --- a/packages/web/components/assets/notifi-alerts/price-alert.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { FunctionComponent } from "react"; - -export const PriceAlertIcon: FunctionComponent<{ className?: string }> = ({ - className, -}) => { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/packages/web/components/buttons/clipboard-button.tsx b/packages/web/components/buttons/clipboard-button.tsx index 2efe543bfa..35f56570a2 100644 --- a/packages/web/components/buttons/clipboard-button.tsx +++ b/packages/web/components/buttons/clipboard-button.tsx @@ -5,12 +5,12 @@ import { useState } from "react"; import { Icon } from "~/components/assets"; import { SpriteIconId } from "~/config"; -import IconButton from "./icon-button"; +import { IconButton } from "./icon-button"; /** * Renders an icon within a button. */ -const ClipboardButton = forwardRef< +export const ClipboardButton = forwardRef< HTMLButtonElement, { value?: string; @@ -78,5 +78,3 @@ const ClipboardButton = forwardRef< ); }); - -export default ClipboardButton; diff --git a/packages/web/components/buttons/icon-button.tsx b/packages/web/components/buttons/icon-button.tsx index 263b88260c..3d2bd68349 100644 --- a/packages/web/components/buttons/icon-button.tsx +++ b/packages/web/components/buttons/icon-button.tsx @@ -11,7 +11,7 @@ import { Button } from "~/components/buttons/button"; /** * Renders an icon within a button. */ -const IconButton = forwardRef< +export const IconButton = forwardRef< HTMLButtonElement, { icon?: ReactNode; @@ -41,5 +41,3 @@ const IconButton = forwardRef< ); }); - -export default IconButton; diff --git a/packages/web/components/buttons/link-button.tsx b/packages/web/components/buttons/link-button.tsx index 49d2e4e8c3..5257240d89 100644 --- a/packages/web/components/buttons/link-button.tsx +++ b/packages/web/components/buttons/link-button.tsx @@ -4,7 +4,7 @@ import { ReactElement } from "react"; import { Button } from "~/components/ui/button"; -export default function LinkButton({ +export function LinkButton({ label, icon, ariaLabel, diff --git a/packages/web/components/cards/my-position/expanded.tsx b/packages/web/components/cards/my-position/expanded.tsx index 2ea673fda8..0270ce3f06 100644 --- a/packages/web/components/cards/my-position/expanded.tsx +++ b/packages/web/components/cards/my-position/expanded.tsx @@ -41,11 +41,17 @@ import { formatPretty, getPriceExtendedFormatOptions } from "~/utils/formatter"; import { RouterOutputs } from "~/utils/trpc"; const ConcentratedLiquidityDepthChart = dynamic( - () => import("~/components/chart/concentrated-liquidity-depth"), + () => + import("~/components/chart/concentrated-liquidity-depth").then( + (module) => module.ConcentratedLiquidityDepthChart + ), { ssr: false } ); const HistoricalPriceChart = dynamic( - () => import("~/components/chart/price-historical"), + () => + import("~/components/chart/price-historical").then( + (module) => module.HistoricalPriceChart + ), { ssr: false } ); diff --git a/packages/web/components/cards/my-position/index.tsx b/packages/web/components/cards/my-position/index.tsx index 074c6db4ec..c669eff97a 100644 --- a/packages/web/components/cards/my-position/index.tsx +++ b/packages/web/components/cards/my-position/index.tsx @@ -7,7 +7,7 @@ import { FunctionComponent, ReactNode, useState } from "react"; import { Icon, PoolAssetsIcon, PoolAssetsName } from "~/components/assets"; import { MyPositionCardExpandedSection } from "~/components/cards/my-position/expanded"; import { MyPositionStatus } from "~/components/cards/my-position/status"; -import SkeletonLoader from "~/components/loaders/skeleton-loader"; +import { SkeletonLoader } from "~/components/loaders/skeleton-loader"; import { EventName } from "~/config"; import { useFeatureFlags, useTranslation } from "~/hooks"; import { useAmplitudeAnalytics } from "~/hooks"; diff --git a/packages/web/components/cards/osmoverse-card.tsx b/packages/web/components/cards/osmoverse-card.tsx index 57e4ea1b85..b41cec82dc 100644 --- a/packages/web/components/cards/osmoverse-card.tsx +++ b/packages/web/components/cards/osmoverse-card.tsx @@ -24,5 +24,3 @@ export const OsmoverseCard: React.FC = ({ ); - -export default OsmoverseCard; diff --git a/packages/web/components/cards/validator-squad-card.tsx b/packages/web/components/cards/validator-squad-card.tsx index e224f433e3..0c9c16fa3a 100644 --- a/packages/web/components/cards/validator-squad-card.tsx +++ b/packages/web/components/cards/validator-squad-card.tsx @@ -6,7 +6,7 @@ import React from "react"; import { useCallback, useMemo } from "react"; import { FallbackImg } from "~/components/assets"; -import OsmoverseCard from "~/components/cards/osmoverse-card"; +import { OsmoverseCard } from "~/components/cards/osmoverse-card"; import { Tooltip } from "~/components/tooltip"; import { Button } from "~/components/ui/button"; import { Breakpoint, useTranslation, useWindowSize } from "~/hooks"; @@ -172,5 +172,3 @@ const AvatarIcon: React.FC<{ extraValidators?: number }> = ({ ); }; - -export default AvatarIcon; diff --git a/packages/web/components/chart/concentrated-liquidity-depth.tsx b/packages/web/components/chart/concentrated-liquidity-depth.tsx index cbfeb069dc..a926349d2a 100644 --- a/packages/web/components/chart/concentrated-liquidity-depth.tsx +++ b/packages/web/components/chart/concentrated-liquidity-depth.tsx @@ -19,7 +19,7 @@ export type DepthData = { depth: number; }; -const ConcentratedLiquidityDepthChart: FunctionComponent<{ +export const ConcentratedLiquidityDepthChart: FunctionComponent<{ min?: number; max?: number; yRange: [number, number]; @@ -252,6 +252,3 @@ const DragContainer: FunctionComponent<{ /> ); - -// needed for next/dynamic to avoid including visx in main bundle -export default ConcentratedLiquidityDepthChart; diff --git a/packages/web/components/chart/light-weight-charts/line-chart.ts b/packages/web/components/chart/light-weight-charts/line-chart.ts deleted file mode 100644 index 58fdcf2c8b..0000000000 --- a/packages/web/components/chart/light-weight-charts/line-chart.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { - DeepPartial, - ISeriesApi, - LineData, - LineSeriesOptions, - LineStyleOptions, - SeriesOptionsCommon, - Time, - TimeChartOptions, - WhitespaceData, -} from "lightweight-charts"; - -import { ChartController, ChartControllerParams } from "./chart-controller"; - -export class LineChartController< - T = TimeChartOptions, - K = Time -> extends ChartController { - series: ISeriesApi< - "Line", - Time, - LineData