From f0cde9cbb03236aba237940fa727988259b7af24 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Wed, 17 Jun 2026 16:11:35 +0200 Subject: [PATCH 1/3] feat(widget): filter eligible tokens by yield category --- .../widget/src/common/get-token-balances.ts | 11 +- packages/widget/src/domain/types/action.ts | 2 + packages/widget/src/domain/types/kyc.ts | 5 +- .../widget/src/domain/types/token-balance.ts | 8 +- packages/widget/src/generated/api/legacy.ts | 220 +++++++++- packages/widget/src/generated/api/yield.ts | 387 +++++++++++++++++- .../src/hooks/api/use-default-tokens.ts | 239 +++++++++-- .../select-token-section/select-token.tsx | 6 + .../earn-page/state/earn-page-context.tsx | 65 ++- .../state/earn-page-state-context.tsx | 119 +++--- .../pages/details/earn-page/state/types.ts | 8 + .../earn-page/state/use-get-init-yield.ts | 8 +- .../details/earn-page/state/use-init-yield.ts | 1 + .../earn-page/state/use-token-balance.ts | 7 +- .../earn-page/state/use-token-balances-map.ts | 11 +- .../widget/src/providers/api/api-client.ts | 4 + .../src/translation/English/translations.json | 8 + .../src/translation/French/translations.json | 8 + packages/widget/tests/domain/kyc.test.ts | 17 +- .../widget/tests/hooks/default-tokens.test.ts | 158 +++++++ .../widget/tests/mocks/yield-api-handlers.ts | 32 ++ .../earn-details-model.test.tsx | 2 +- .../tests/providers/api-client.test.tsx | 1 + .../tests/use-cases/rwa-kyc-flow.test.tsx | 9 + 24 files changed, 1197 insertions(+), 139 deletions(-) create mode 100644 packages/widget/tests/hooks/default-tokens.test.ts diff --git a/packages/widget/src/common/get-token-balances.ts b/packages/widget/src/common/get-token-balances.ts index bb266589..f526b639 100644 --- a/packages/widget/src/common/get-token-balances.ts +++ b/packages/widget/src/common/get-token-balances.ts @@ -1,7 +1,11 @@ import type { QueryClient } from "@tanstack/react-query"; import { EitherAsync, Right } from "purify-ts"; import type { SKWallet } from "../domain/types/wallet"; -import { getDefaultTokens } from "../hooks/api/use-default-tokens"; +import type { DashboardYieldCategory } from "../domain/types/yields"; +import { + getDefaultTokens, + getYieldTypesForDashboardCategory, +} from "../hooks/api/use-default-tokens"; import { getTokenBalancesScan } from "../hooks/api/use-token-balances-scan"; import type { ApiClient } from "../providers/api/api-client"; import type { SettingsProps } from "../providers/settings/types"; @@ -11,6 +15,7 @@ export const getTokenBalances = ({ address, apiClient, network, + selectedDashboardYieldCategory, queryClient, tokensForEnabledYieldsOnly, }: { @@ -19,6 +24,7 @@ export const getTokenBalances = ({ apiClient: ApiClient; queryClient: QueryClient; network: SKWallet["network"]; + selectedDashboardYieldCategory?: DashboardYieldCategory | null; tokensForEnabledYieldsOnly: SettingsProps["tokensForEnabledYieldsOnly"]; }) => EitherAsync.fromPromise(() => @@ -28,6 +34,9 @@ export const getTokenBalances = ({ queryClient, network: network ?? undefined, enabledYieldsOnly: tokensForEnabledYieldsOnly, + yieldTypes: getYieldTypesForDashboardCategory( + selectedDashboardYieldCategory + ), }), EitherAsync.liftEither( Right({ additionalAddresses, address, network }) diff --git a/packages/widget/src/domain/types/action.ts b/packages/widget/src/domain/types/action.ts index 24aa53c3..380597ae 100644 --- a/packages/widget/src/domain/types/action.ts +++ b/packages/widget/src/domain/types/action.ts @@ -28,6 +28,8 @@ type TransactionGasEstimate = { export const ActionTypes = { STAKE: "STAKE", UNSTAKE: "UNSTAKE", + WITHDRAW_REQUEST: "WITHDRAW_REQUEST", + INSTANT_WITHDRAW: "INSTANT_WITHDRAW", CLAIM_REWARDS: "CLAIM_REWARDS", AUTO_SWEEP_UNSTAKE_REWARDS: "AUTO_SWEEP_UNSTAKE_REWARDS", AUTO_SWEEP_WITHDRAW_REWARDS: "AUTO_SWEEP_WITHDRAW_REWARDS", diff --git a/packages/widget/src/domain/types/kyc.ts b/packages/widget/src/domain/types/kyc.ts index 2c559128..63345323 100644 --- a/packages/widget/src/domain/types/kyc.ts +++ b/packages/widget/src/domain/types/kyc.ts @@ -14,7 +14,7 @@ export type KycGate = }; type KycUrlSource = { - readonly status?: Pick | null; + readonly status?: Pick | null; readonly yieldDto?: Yield | null; }; @@ -22,9 +22,8 @@ export const getKycProviderName = (yieldDto: Yield | null | undefined) => yieldDto?.provider?.name ?? null; export const getKycUrl = ({ status, yieldDto }: KycUrlSource) => - status?.kycUrl ?? + status?.authorizeUrl ?? yieldDto?.mechanics.requirements?.kyc?.authorizeUrl ?? - yieldDto?.mechanics.requirements?.kycUrl ?? yieldDto?.provider?.website; const getKycGateUrlFields = ({ diff --git a/packages/widget/src/domain/types/token-balance.ts b/packages/widget/src/domain/types/token-balance.ts index c06b22bf..72afea64 100644 --- a/packages/widget/src/domain/types/token-balance.ts +++ b/packages/widget/src/domain/types/token-balance.ts @@ -3,7 +3,13 @@ import type { TokenBalanceScanResponseDto as LegacyTokenBalanceScanResponseDto, YieldBalanceLabelDto as LegacyYieldBalanceLabelDto, } from "../../generated/api/legacy"; +import type { TokenDto } from "./tokens"; export type TokenBalanceScanDto = LegacyTokenBalanceScanDto; -export type TokenBalanceScanResponseDto = LegacyTokenBalanceScanResponseDto; +export type TokenBalanceScanResponseDto = Omit< + LegacyTokenBalanceScanResponseDto, + "token" +> & { + readonly token: TokenDto; +}; export type YieldBalanceLabelDto = LegacyYieldBalanceLabelDto; diff --git a/packages/widget/src/generated/api/legacy.ts b/packages/widget/src/generated/api/legacy.ts index 1af91b2a..dbbd7095 100644 --- a/packages/widget/src/generated/api/legacy.ts +++ b/packages/widget/src/generated/api/legacy.ts @@ -563,6 +563,61 @@ export type UpdatePayoutAddressDto = { readonly note?: string; }; export type ReferralDto = { readonly id: string; readonly code: string }; +export type IntegrationFreshness = + | "real_time" + | "daily" + | "weekly" + | "monthly" + | "coming_soon"; +export type KpiSummaryResponseDto = { + readonly total_earned_revenue_usd: { + readonly value: string | null; + readonly coverage: boolean; + readonly last_updated_at: string; + readonly delta_30d_usd?: string | null; + readonly delta_30d_pct?: string | null; + readonly delta_30d?: string | null; + }; + readonly volume_inflow_usd: { + readonly value: string | null; + readonly coverage: boolean; + readonly last_updated_at: string; + readonly delta_30d_usd?: string | null; + readonly delta_30d_pct?: string | null; + readonly delta_30d?: string | null; + }; + readonly volume_outflow_usd: { + readonly value: string | null; + readonly coverage: boolean; + readonly last_updated_at: string; + readonly delta_30d_usd?: string | null; + readonly delta_30d_pct?: string | null; + readonly delta_30d?: string | null; + }; + readonly tvl_usd: { + readonly value: string | null; + readonly coverage: boolean; + readonly last_updated_at: string; + readonly delta_30d_usd?: string | null; + readonly delta_30d_pct?: string | null; + readonly delta_30d?: string | null; + }; + readonly active_users_unique_addresses: { + readonly value: string | null; + readonly coverage: boolean; + readonly last_updated_at: string; + readonly delta_30d_usd?: string | null; + readonly delta_30d_pct?: string | null; + readonly delta_30d?: string | null; + }; + readonly total_actions_count: number; +}; +export type TrendDataPointDto = { + readonly month: string; + readonly tvl_usd: string | null; + readonly revenue_usd: string | null; + readonly active_users: string | null; +}; export type ActionStatus = | "CANCELED" | "CREATED" @@ -574,6 +629,8 @@ export type ActionStatus = export type ActionTypes = | "STAKE" | "UNSTAKE" + | "WITHDRAW_REQUEST" + | "INSTANT_WITHDRAW" | "CLAIM_REWARDS" | "AUTO_SWEEP_UNSTAKE_REWARDS" | "AUTO_SWEEP_WITHDRAW_REWARDS" @@ -608,6 +665,7 @@ export type TransactionType = | "DEPOSIT" | "APPROVAL" | "STAKE" + | "SET_OPERATOR" | "CLAIM_UNSTAKED" | "CLAIM_REWARDS" | "RESTAKE_REWARDS" @@ -728,7 +786,12 @@ export type YieldProviders = | "veda" | "lista" | "dolomite" - | "midas"; + | "midas" + | "dinari" + | "ondo" + | "superstate" + | "securitize" + | "nest"; export type YieldType = | "staking" | "liquid-staking" @@ -1403,6 +1466,7 @@ export type CreateFeeConfigurationDtoV2 = { readonly managementFeeBps?: number; readonly performanceFeeBps?: number; readonly depositFeeBps?: number; + readonly chargeOnFirstDepositOnly?: boolean; readonly layerzeroOVaultConfig?: {}; }; export type FailureViewDto = { @@ -1533,12 +1597,14 @@ export type CreateFeeConfigurationDto = { readonly managementFeeBps?: number; readonly performanceFeeBps?: number; readonly depositFeeBps?: number; + readonly chargeOnFirstDepositOnly?: boolean; readonly layerzeroOVaultConfig?: {}; }; export type UpdateFeeConfigurationDto = { readonly managementFeeBps?: number; readonly performanceFeeBps?: number; readonly depositFeeBps?: number; + readonly chargeOnFirstDepositOnly?: boolean | null; readonly layerzeroOVaultConfig?: {} | null; }; export type InitiateSsoDto = { @@ -2062,6 +2128,28 @@ export type UpdateTeamDto = { readonly name?: string; readonly isMfaEnforced?: boolean; }; +export type IntegrationRevenueRowDto = { + readonly integration_id: string; + readonly integration_name: string | null; + readonly revenue_usd: string | null; + readonly revenue_type: "estimated" | "actual" | null; + readonly tvl_usd: string | null; + readonly data_freshness: IntegrationFreshness; + readonly coverage: boolean; + readonly volume_inflow_usd: string | null; + readonly volume_outflow_usd: string | null; + readonly estimated_rewards_usd: string | null; +}; +export type TopIntegrationDto = { + readonly integration_id: string; + readonly integration_name: string | null; + readonly revenue_usd: string | null; + readonly tvl_usd: string | null; + readonly data_freshness: IntegrationFreshness; +}; +export type KpiTrendsResponseDto = { + readonly data_points: ReadonlyArray; +}; export type TransactionStatusResponseDto = { readonly status: TransactionStatus; readonly url: string; @@ -2140,8 +2228,10 @@ export type FeeConfigurationWithApyDto = { readonly managementFeeBps: number; readonly performanceFeeBps: number; readonly depositFeeBps: number; + readonly chargeOnFirstDepositOnly: boolean; readonly allocatorVaultContractAddress: string | null; readonly feeWrapperContractAddress: string | null; + readonly feeRecipientAddress: string | null; readonly status: FeeConfigurationStatus; readonly layerzeroOVaultConfig?: {} | null; readonly computedRewardRate: number; @@ -2153,8 +2243,10 @@ export type FeeConfigurationDto = { readonly managementFeeBps: number; readonly performanceFeeBps: number; readonly depositFeeBps: number; + readonly chargeOnFirstDepositOnly: boolean; readonly allocatorVaultContractAddress: string | null; readonly feeWrapperContractAddress: string | null; + readonly feeRecipientAddress: string | null; readonly status: FeeConfigurationStatus; readonly layerzeroOVaultConfig?: {} | null; }; @@ -2829,6 +2921,10 @@ export type PaginatedCampaignConfigurationRequestDto = { readonly limit: number; readonly items: ReadonlyArray; }; +export type TopIntegrationsDto = { + readonly by_revenue: ReadonlyArray; + readonly by_tvl: ReadonlyArray; +}; export type YieldMetadataDto = { readonly name: string; readonly logoURI: string; @@ -3262,6 +3358,13 @@ export type ProgrammaticPayoutBatchDetailDto = { readonly safeTransaction: SafeTransactionDetailDto; readonly recipients: PaginatedProgrammaticPayoutItemDto; }; +export type RevenueBreakdownResponseDto = { + readonly total_earned_revenue_usd: string | null; + readonly coverage: boolean; + readonly last_updated_at: string; + readonly integrations: ReadonlyArray; + readonly top_integrations: TopIntegrationsDto; +}; export type GasForNetworkResponseDto = { readonly customisable: boolean; readonly modes: GasModesDto; @@ -3760,6 +3863,25 @@ export type ReferralControllerGetByCodeParams = { readonly "X-API-KEY"?: string; }; export type ReferralControllerGetByCode200 = ReferralDto; +export type RevenueBreakdownControllerGetRevenueSummaryParams = { + readonly month?: string; + readonly date_from?: string; + readonly date_to?: string; + readonly project_ids?: ReadonlyArray; +}; +export type RevenueBreakdownControllerGetRevenueSummary200 = + RevenueBreakdownResponseDto; +export type KpiSummaryControllerGetSummaryParams = { + readonly month?: string; + readonly date_from?: string; + readonly date_to?: string; + readonly project_ids?: ReadonlyArray; +}; +export type KpiSummaryControllerGetSummary200 = KpiSummaryResponseDto; +export type KpiTrendsControllerGetTrendsParams = { + readonly project_ids?: ReadonlyArray; +}; +export type KpiTrendsControllerGetTrends200 = KpiTrendsResponseDto; export type ReportEntryControllerListParams = { readonly limit?: number; readonly page?: number; @@ -4191,6 +4313,8 @@ export type ActionControllerListParams = { readonly type?: | "STAKE" | "UNSTAKE" + | "WITHDRAW_REQUEST" + | "INSTANT_WITHDRAW" | "CLAIM_REWARDS" | "AUTO_SWEEP_UNSTAKE_REWARDS" | "AUTO_SWEEP_WITHDRAW_REWARDS" @@ -5043,7 +5167,12 @@ export type YieldV2ControllerYieldsParams = { | "veda" | "lista" | "dolomite" - | "midas"; + | "midas" + | "dinari" + | "ondo" + | "superstate" + | "securitize" + | "nest"; readonly inputToken?: string; readonly enterStatus?: boolean; readonly preferredValidatorsOnly?: boolean; @@ -5267,8 +5396,10 @@ export type YieldV2ControllerGetFeeConfigurations200 = { readonly managementFeeBps: number; readonly performanceFeeBps: number; readonly depositFeeBps: number; + readonly chargeOnFirstDepositOnly: boolean; readonly allocatorVaultContractAddress: string | null; readonly feeWrapperContractAddress: string | null; + readonly feeRecipientAddress: string | null; readonly status: FeeConfigurationStatus; readonly layerzeroOVaultConfig?: {} | null; }>; @@ -5320,8 +5451,10 @@ export type FeeConfigurationControllerGet200 = { readonly managementFeeBps: number; readonly performanceFeeBps: number; readonly depositFeeBps: number; + readonly chargeOnFirstDepositOnly: boolean; readonly allocatorVaultContractAddress: string | null; readonly feeWrapperContractAddress: string | null; + readonly feeRecipientAddress: string | null; readonly status: FeeConfigurationStatus; readonly layerzeroOVaultConfig?: {} | null; }>; @@ -5347,8 +5480,10 @@ export type ProgrammaticFeeConfigurationControllerGet200 = { readonly managementFeeBps: number; readonly performanceFeeBps: number; readonly depositFeeBps: number; + readonly chargeOnFirstDepositOnly: boolean; readonly allocatorVaultContractAddress: string | null; readonly feeWrapperContractAddress: string | null; + readonly feeRecipientAddress: string | null; readonly status: FeeConfigurationStatus; readonly layerzeroOVaultConfig?: {} | null; }>; @@ -6726,6 +6861,35 @@ export const make = ( }), onRequest(options?.config)(["2xx"]) ), + RevenueBreakdownControllerGetRevenueSummary: (teamId, options) => + HttpClientRequest.get( + `/v1/teams/${teamId}/reporting/revenue/summary` + ).pipe( + HttpClientRequest.setUrlParams({ + month: options?.params?.["month"] as any, + date_from: options?.params?.["date_from"] as any, + date_to: options?.params?.["date_to"] as any, + project_ids: options?.params?.["project_ids"] as any, + }), + onRequest(options?.config)(["2xx"]) + ), + KpiSummaryControllerGetSummary: (teamId, options) => + HttpClientRequest.get(`/v1/teams/${teamId}/reporting/summary`).pipe( + HttpClientRequest.setUrlParams({ + month: options?.params?.["month"] as any, + date_from: options?.params?.["date_from"] as any, + date_to: options?.params?.["date_to"] as any, + project_ids: options?.params?.["project_ids"] as any, + }), + onRequest(options?.config)(["2xx"]) + ), + KpiTrendsControllerGetTrends: (teamId, options) => + HttpClientRequest.get(`/v1/teams/${teamId}/reporting/trends`).pipe( + HttpClientRequest.setUrlParams({ + project_ids: options?.params?.["project_ids"] as any, + }), + onRequest(options?.config)(["2xx"]) + ), ReportEntryControllerList: (teamId, options) => HttpClientRequest.get(`/v1/teams/${teamId}/report-entries`).pipe( HttpClientRequest.setUrlParams({ @@ -9857,6 +10021,58 @@ export interface LegacyApi { WithOptionalResponse, HttpClientError.HttpClientError >; + /** + * Aggregate revenue, per-integration breakdown, and top integrations for the selected period. Supports monthly or explicit date-range filtering. + */ + readonly RevenueBreakdownControllerGetRevenueSummary: < + Config extends OperationConfig, + >( + teamId: string, + options: + | { + readonly params?: + | RevenueBreakdownControllerGetRevenueSummaryParams + | undefined; + readonly config?: Config | undefined; + } + | undefined + ) => Effect.Effect< + WithOptionalResponse< + RevenueBreakdownControllerGetRevenueSummary200, + Config + >, + HttpClientError.HttpClientError + >; + /** + * Four headline KPIs (revenue, volume, TVL, active users) for the selected period. Metrics without pipeline support return coverage: false and value: null. + */ + readonly KpiSummaryControllerGetSummary: ( + teamId: string, + options: + | { + readonly params?: KpiSummaryControllerGetSummaryParams | undefined; + readonly config?: Config | undefined; + } + | undefined + ) => Effect.Effect< + WithOptionalResponse, + HttpClientError.HttpClientError + >; + /** + * Returns 12 monthly data points ordered oldest to newest, each containing TVL, revenue, and active-user counts. Null values indicate missing pipeline data for that month. + */ + readonly KpiTrendsControllerGetTrends: ( + teamId: string, + options: + | { + readonly params?: KpiTrendsControllerGetTrendsParams | undefined; + readonly config?: Config | undefined; + } + | undefined + ) => Effect.Effect< + WithOptionalResponse, + HttpClientError.HttpClientError + >; readonly ReportEntryControllerList: ( teamId: string, options: diff --git a/packages/widget/src/generated/api/yield.ts b/packages/widget/src/generated/api/yield.ts index eb4e6305..7c8e5dfc 100644 --- a/packages/widget/src/generated/api/yield.ts +++ b/packages/widget/src/generated/api/yield.ts @@ -268,11 +268,32 @@ export type RewardSchedule = | "epoch" | "campaign"; export type RewardClaiming = "auto" | "manual"; -export type KycEligibilityDto = { - readonly countries?: ReadonlyArray; - readonly usPersonAllowed?: boolean; - readonly accreditation?: "retail" | "qualified_purchaser" | "accredited"; - readonly subjectType?: "KYC" | "KYB"; +export type InvestorEligibilityEntryDto = { + readonly jurisdiction: string; + readonly tier: + | "us_retail" + | "us_accredited" + | "us_qualified_purchaser" + | "eu_retail" + | "eu_professional" + | "eu_professional_optup" + | "eu_eligible_counterparty" + | "uk_retail" + | "uk_professional" + | "ch_qualified" + | "sg_ai" + | "sg_ii" + | "hk_pi" + | "my_sophisticated" + | "br_qi" + | "br_pi" + | "ae_professional"; + readonly verificationLevel: + | "self_attested" + | "verified_documentation" + | "letter" + | "third_party_attestation"; + readonly expiresAfterDays?: number; }; export type ArgumentFieldDto = { readonly name: @@ -298,6 +319,7 @@ export type ArgumentFieldDto = { | "ledgerWalletApiCompatible" | "useMaxAmount" | "useInstantExecution" + | "useAutoClaim" | "rangeMin" | "rangeMax" | "percentage" @@ -690,6 +712,7 @@ export type TransactionDto = { | "DEPOSIT" | "APPROVAL" | "STAKE" + | "SET_OPERATOR" | "CLAIM_UNSTAKED" | "CLAIM_REWARDS" | "RESTAKE_REWARDS" @@ -789,6 +812,120 @@ export type CampaignPayoutFrequency = | "daily" | "six_hourly" | "end_of_campaign"; +export type TokenWithAvailableYieldsDto = { + readonly token: { + readonly symbol: string; + readonly name: string; + readonly decimals: number; + readonly network: + | "ethereum" + | "ethereum-goerli" + | "ethereum-holesky" + | "ethereum-sepolia" + | "ethereum-hoodi" + | "arbitrum" + | "base" + | "base-sepolia" + | "gnosis" + | "optimism" + | "polygon" + | "polygon-amoy" + | "starknet" + | "zksync" + | "linea" + | "unichain" + | "monad-testnet" + | "monad" + | "robinhood-testnet" + | "avalanche-c" + | "avalanche-c-atomic" + | "avalanche-p" + | "binance" + | "celo" + | "fantom" + | "harmony" + | "moonriver" + | "okc" + | "viction" + | "core" + | "sonic" + | "plasma" + | "katana" + | "hyperevm" + | "tempo" + | "agoric" + | "akash" + | "axelar" + | "band-protocol" + | "bitsong" + | "canto" + | "chihuahua" + | "comdex" + | "coreum" + | "cosmos" + | "crescent" + | "cronos" + | "cudos" + | "desmos" + | "dydx" + | "evmos" + | "fetch-ai" + | "gravity-bridge" + | "injective" + | "irisnet" + | "juno" + | "kava" + | "ki-network" + | "mars-protocol" + | "nym" + | "okex-chain" + | "onomy" + | "osmosis" + | "persistence" + | "quicksilver" + | "regen" + | "secret" + | "sentinel" + | "sommelier" + | "stafi" + | "stargaze" + | "stride" + | "teritori" + | "tgrade" + | "umee" + | "sei" + | "mantra" + | "celestia" + | "saga" + | "zetachain" + | "dymension" + | "humansai" + | "neutron" + | "polkadot" + | "kusama" + | "westend" + | "bittensor" + | "aptos" + | "binancebeacon" + | "cardano" + | "near" + | "solana" + | "solana-devnet" + | "stellar" + | "stellar-testnet" + | "sui" + | "tezos" + | "tron" + | "ton" + | "ton-testnet" + | "hyperliquid"; + readonly address?: string; + readonly logoURI?: string; + readonly isPoints?: boolean; + readonly coinGeckoId?: string; + }; + readonly availableYields: ReadonlyArray; +}; export type CreateActionDto = { readonly yieldId: string; readonly address: string; @@ -1016,6 +1153,7 @@ export type CreateActionDto = { readonly ledgerWalletApiCompatible?: boolean; readonly useMaxAmount?: boolean; readonly useInstantExecution?: boolean; + readonly useAutoClaim?: boolean; readonly skipPrechecks?: boolean; readonly useMaxAllowance?: boolean; readonly feePayerAddress?: string; @@ -1253,6 +1391,7 @@ export type CreateManageActionDto = { readonly ledgerWalletApiCompatible?: boolean; readonly useMaxAmount?: boolean; readonly useInstantExecution?: boolean; + readonly useAutoClaim?: boolean; readonly skipPrechecks?: boolean; readonly useMaxAllowance?: boolean; readonly feePayerAddress?: string; @@ -1265,6 +1404,8 @@ export type CreateManageActionDto = { readonly action: | "STAKE" | "UNSTAKE" + | "WITHDRAW_REQUEST" + | "INSTANT_WITHDRAW" | "CLAIM_REWARDS" | "AUTO_SWEEP_UNSTAKE_REWARDS" | "AUTO_SWEEP_WITHDRAW_REWARDS" @@ -1294,7 +1435,7 @@ export type KycStatusResponseDto = { | "pending" | "approved" | "rejected"; - readonly kycUrl?: string; + readonly authorizeUrl?: string; }; export type NetworkDto = { readonly id: @@ -1476,6 +1617,15 @@ export type ValidatorDto = { readonly pricePerShare?: string; }; }; +export type KycEligibilityDto = { + readonly defaultPolicy: "deny" | "allow"; + readonly countries: ReadonlyArray; + readonly blockedCountries: ReadonlyArray; + readonly blockedSubdivisions: ReadonlyArray; + readonly usPersonAllowed: boolean; + readonly investorEligibility: ReadonlyArray; + readonly subjectTypes: ReadonlyArray<"KYC" | "KYB">; +}; export type ArgumentSchemaDto = { readonly fields: ReadonlyArray; readonly notes?: string; @@ -1485,6 +1635,8 @@ export type PendingActionDto = { readonly type: | "STAKE" | "UNSTAKE" + | "WITHDRAW_REQUEST" + | "INSTANT_WITHDRAW" | "CLAIM_REWARDS" | "AUTO_SWEEP_UNSTAKE_REWARDS" | "AUTO_SWEEP_WITHDRAW_REWARDS" @@ -1519,6 +1671,8 @@ export type ActionDto = { readonly type: | "STAKE" | "UNSTAKE" + | "WITHDRAW_REQUEST" + | "INSTANT_WITHDRAW" | "CLAIM_REWARDS" | "AUTO_SWEEP_UNSTAKE_REWARDS" | "AUTO_SWEEP_WITHDRAW_REWARDS" @@ -1768,6 +1922,7 @@ export type ActionDto = { readonly ledgerWalletApiCompatible?: boolean; readonly useMaxAmount?: boolean; readonly useInstantExecution?: boolean; + readonly useAutoClaim?: boolean; readonly skipPrechecks?: boolean; readonly useMaxAllowance?: boolean; readonly feePayerAddress?: string; @@ -2436,16 +2591,23 @@ export type YieldDto = { readonly entryLimits?: { readonly minimum: string | null; readonly maximum: string | null; + readonly subsequentMinimum: string | null; }; readonly requirements?: { readonly kycRequired: boolean; - readonly kycUrl?: string; readonly kyc?: { - readonly kycMode: "oauth_redirect"; + readonly kycMode: + | "none" + | "oauth_redirect" + | "external_redirect" + | "iframe" + | "deeplink" + | "native_sdk"; readonly iframeAllowed: boolean; readonly authorizeUrl?: string; readonly notes?: string; - readonly eligibility?: KycEligibilityDto; + readonly eligibility: KycEligibilityDto; + readonly mandatoryDisclosureUrl?: string; }; }; readonly supportsLedgerWalletApi?: boolean; @@ -2469,6 +2631,7 @@ export type YieldDto = { }; }; readonly providerId: string; + readonly prime: boolean; readonly curator?: { readonly name?: {} | null; readonly description?: {} | null; @@ -3827,6 +3990,7 @@ export type YieldsControllerGetYieldsParams = { readonly provider?: string; readonly providers?: ReadonlyArray; readonly search?: string; + readonly prime?: boolean; readonly sort?: | "statusEnterAsc" | "statusEnterDesc" @@ -4151,6 +4315,150 @@ export type YieldsControllerGetYieldCampaigns500 = { readonly error?: string; readonly statusCode?: number; }; +export type TokensControllerGetTokensParams = { + readonly networks?: ReadonlyArray< + | "ethereum" + | "ethereum-goerli" + | "ethereum-holesky" + | "ethereum-sepolia" + | "ethereum-hoodi" + | "arbitrum" + | "base" + | "base-sepolia" + | "gnosis" + | "optimism" + | "polygon" + | "polygon-amoy" + | "starknet" + | "zksync" + | "linea" + | "unichain" + | "monad-testnet" + | "monad" + | "robinhood-testnet" + | "avalanche-c" + | "avalanche-c-atomic" + | "avalanche-p" + | "binance" + | "celo" + | "fantom" + | "harmony" + | "moonriver" + | "okc" + | "viction" + | "core" + | "sonic" + | "plasma" + | "katana" + | "hyperevm" + | "tempo" + | "agoric" + | "akash" + | "axelar" + | "band-protocol" + | "bitsong" + | "canto" + | "chihuahua" + | "comdex" + | "coreum" + | "cosmos" + | "crescent" + | "cronos" + | "cudos" + | "desmos" + | "dydx" + | "evmos" + | "fetch-ai" + | "gravity-bridge" + | "injective" + | "irisnet" + | "juno" + | "kava" + | "ki-network" + | "mars-protocol" + | "nym" + | "okex-chain" + | "onomy" + | "osmosis" + | "persistence" + | "quicksilver" + | "regen" + | "secret" + | "sentinel" + | "sommelier" + | "stafi" + | "stargaze" + | "stride" + | "teritori" + | "tgrade" + | "umee" + | "sei" + | "mantra" + | "celestia" + | "saga" + | "zetachain" + | "dymension" + | "humansai" + | "neutron" + | "polkadot" + | "kusama" + | "westend" + | "bittensor" + | "aptos" + | "binancebeacon" + | "cardano" + | "near" + | "solana" + | "solana-devnet" + | "stellar" + | "stellar-testnet" + | "sui" + | "tezos" + | "tron" + | "ton" + | "ton-testnet" + | "hyperliquid" + >; + readonly yieldTypes?: ReadonlyArray< + | "staking" + | "restaking" + | "lending" + | "vault" + | "fixed_yield" + | "real_world_asset" + | "concentrated_liquidity_pool" + | "liquidity_pool" + >; + readonly offset?: number; + readonly limit?: number; +}; +export type TokensControllerGetTokens200 = { + readonly total: number; + readonly offset: number; + readonly limit: number; + readonly items?: ReadonlyArray; +}; +export type TokensControllerGetTokens400 = { + readonly message?: string; + readonly error?: string; + readonly statusCode?: number; +}; +export type TokensControllerGetTokens401 = { + readonly message?: string; + readonly error?: string; + readonly statusCode?: number; +}; +export type TokensControllerGetTokens429 = { + readonly message?: string; + readonly error?: string; + readonly statusCode?: number; + readonly retryAfter?: number; +}; +export type TokensControllerGetTokens500 = { + readonly message?: string; + readonly error?: string; + readonly statusCode?: number; +}; export type ActionsControllerGetActionsParams = { readonly offset?: number; readonly limit?: number; @@ -4176,6 +4484,8 @@ export type ActionsControllerGetActionsParams = { readonly type?: | "STAKE" | "UNSTAKE" + | "WITHDRAW_REQUEST" + | "INSTANT_WITHDRAW" | "CLAIM_REWARDS" | "AUTO_SWEEP_UNSTAKE_REWARDS" | "AUTO_SWEEP_WITHDRAW_REWARDS" @@ -4195,6 +4505,16 @@ export type ActionsControllerGetActionsParams = { | "VERIFY_WITHDRAW_CREDENTIALS" | "DELEGATE"; readonly yieldId?: string; + readonly yieldTypes?: ReadonlyArray< + | "staking" + | "restaking" + | "lending" + | "vault" + | "fixed_yield" + | "real_world_asset" + | "concentrated_liquidity_pool" + | "liquidity_pool" + >; readonly network?: | "ethereum" | "ethereum-goerli" @@ -4719,6 +5039,7 @@ export const make = ( provider: options?.params?.["provider"] as any, providers: options?.params?.["providers"] as any, search: options?.params?.["search"] as any, + prime: options?.params?.["prime"] as any, sort: options?.params?.["sort"] as any, }), onRequest(options?.config)(["2xx"], { @@ -4869,6 +5190,21 @@ export const make = ( "500": "YieldsControllerGetYieldCampaigns500", }) ), + TokensControllerGetTokens: (options) => + HttpClientRequest.get(`/v1/tokens`).pipe( + HttpClientRequest.setUrlParams({ + networks: options?.params?.["networks"] as any, + yieldTypes: options?.params?.["yieldTypes"] as any, + offset: options?.params?.["offset"] as any, + limit: options?.params?.["limit"] as any, + }), + onRequest(options?.config)(["2xx"], { + "400": "TokensControllerGetTokens400", + "401": "TokensControllerGetTokens401", + "429": "TokensControllerGetTokens429", + "500": "TokensControllerGetTokens500", + }) + ), ActionsControllerGetActions: (options) => HttpClientRequest.get(`/v1/actions`).pipe( HttpClientRequest.setUrlParams({ @@ -4880,6 +5216,7 @@ export const make = ( intent: options.params["intent"] as any, type: options.params["type"] as any, yieldId: options.params["yieldId"] as any, + yieldTypes: options.params["yieldTypes"] as any, network: options.params["network"] as any, }), onRequest(options.config)(["2xx"], { @@ -5327,6 +5664,36 @@ export interface YieldApi { YieldsControllerGetYieldCampaigns500 > >; + /** + * Retrieve tokens that have at least one enabled yield available for this project. Optionally filter by one or more networks and yield types. Returns the full list by default; callers should respect `total` and use `offset`/`limit`, as a default page size may be introduced in future. + */ + readonly TokensControllerGetTokens: ( + options: + | { + readonly params?: TokensControllerGetTokensParams | undefined; + readonly config?: Config | undefined; + } + | undefined + ) => Effect.Effect< + WithOptionalResponse, + | HttpClientError.HttpClientError + | YieldApiError< + "TokensControllerGetTokens400", + TokensControllerGetTokens400 + > + | YieldApiError< + "TokensControllerGetTokens401", + TokensControllerGetTokens401 + > + | YieldApiError< + "TokensControllerGetTokens429", + TokensControllerGetTokens429 + > + | YieldApiError< + "TokensControllerGetTokens500", + TokensControllerGetTokens500 + > + >; /** * Retrieve all actions performed by a user, with optional filtering by yield, status, category, etc. In the future, this may include personalized action recommendations. */ @@ -5578,7 +5945,7 @@ export interface YieldApi { | YieldApiError<"KycControllerGetStatus429", KycControllerGetStatus429> >; /** - * Retrieve a list of all supported networks that can be used for filtering yields and other operations. + * Retrieve networks with enabled yield opportunities for the authenticated project. */ readonly NetworksControllerGetNetworks: ( options: { readonly config?: Config | undefined } | undefined diff --git a/packages/widget/src/hooks/api/use-default-tokens.ts b/packages/widget/src/hooks/api/use-default-tokens.ts index 831bb68f..57d69d8f 100644 --- a/packages/widget/src/hooks/api/use-default-tokens.ts +++ b/packages/widget/src/hooks/api/use-default-tokens.ts @@ -1,68 +1,217 @@ -import type { QueryClient } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; +import { + type InfiniteData, + type QueryClient, + useInfiniteQuery, +} from "@tanstack/react-query"; import { EitherAsync } from "purify-ts"; import type { TokenBalanceScanResponseDto } from "../../domain/types/token-balance"; -import type { TokenGetTokensParams } from "../../domain/types/tokens"; +import type { TokenDto } from "../../domain/types/tokens"; +import { + type DashboardYieldCategory, + getApiYieldTypesForDashboardCategory, +} from "../../domain/types/yields"; +import type { + TokenControllerGetTokensParams as LegacyTokenGetTokensParams, + TokenWithAvailableYieldsDto as LegacyTokenWithAvailableYieldsDto, +} from "../../generated/api/legacy"; +import type { + TokensControllerGetTokensParams as YieldTokenGetTokensParams, + TokenWithAvailableYieldsDto as YieldTokenWithAvailableYieldsDto, +} from "../../generated/api/yield"; import type { ApiClient } from "../../providers/api/api-client"; import { useApiClient } from "../../providers/api/api-client-provider"; import { useSettings } from "../../providers/settings"; import { useSKWallet } from "../../providers/sk-wallet"; -const getTokenGetTokensQueryKey = (params?: TokenGetTokensParams) => +const DEFAULT_TOKENS_PAGE_LIMIT = 100; + +type YieldTokenTypes = YieldTokenGetTokensParams["yieldTypes"]; +type DefaultTokensQueryParams = { + enabledYieldsOnly?: boolean; + network?: TokenDto["network"]; + yieldTypes?: YieldTokenTypes; +}; +type DefaultTokensPage = { + nextOffset?: number; + tokens: TokenBalanceScanResponseDto[]; +}; +type DefaultTokensPages = { + pages: DefaultTokensPage[]; + pageParams: number[]; +}; +type FetchDefaultTokensPageParams = DefaultTokensQueryParams & { + apiClient: ApiClient; + limit?: number; + offset?: number; + signal?: AbortSignal; +}; + +const getTokenGetTokensQueryKey = (params?: DefaultTokensQueryParams) => ["/v1/tokens", ...(params ? [params] : [])] as const; -export const useDefaultTokens = () => { +const getAllDefaultTokensQueryKey = (params?: DefaultTokensQueryParams) => + ["/v1/tokens/all-pages", ...(params ? [params] : [])] as const; + +const getNextOffset = ({ + limit, + offset, + total, +}: { + limit: number; + offset: number; + total: number; +}) => { + const nextOffset = offset + limit; + + return nextOffset < total ? nextOffset : undefined; +}; + +const shouldUseYieldTokensApi = ({ + enabledYieldsOnly, + yieldTypes, +}: Pick) => + !!enabledYieldsOnly || !!yieldTypes?.length; + +const toTokenBalanceScanResponse = ( + tokenWithYields: + | LegacyTokenWithAvailableYieldsDto + | YieldTokenWithAvailableYieldsDto +): TokenBalanceScanResponseDto => ({ + token: tokenWithYields.token, + availableYields: tokenWithYields.availableYields, + amount: "0", +}); + +export const getYieldTypesForDashboardCategory = ( + yieldCategory?: DashboardYieldCategory | null +): YieldTokenTypes => + yieldCategory + ? getApiYieldTypesForDashboardCategory(yieldCategory) + : undefined; + +export const fetchDefaultTokensPage = async ({ + apiClient, + enabledYieldsOnly, + limit = DEFAULT_TOKENS_PAGE_LIMIT, + network, + offset = 0, + signal, + yieldTypes, +}: FetchDefaultTokensPageParams): Promise => { + if (shouldUseYieldTokensApi({ enabledYieldsOnly, yieldTypes })) { + const page = await apiClient + .withOptions({ signal }) + .yield.TokensControllerGetTokens({ + params: { + networks: network + ? [ + network as NonNullable< + YieldTokenGetTokensParams["networks"] + >[number], + ] + : undefined, + yieldTypes, + offset, + limit, + }, + }); + + return { + tokens: (page.items ?? []).map(toTokenBalanceScanResponse), + nextOffset: getNextOffset(page), + }; + } + + const tokens = await apiClient + .withOptions({ signal }) + .legacy.TokenControllerGetTokens({ + params: { + enabledYieldsOnly: enabledYieldsOnly || undefined, + network: network as LegacyTokenGetTokensParams["network"], + }, + }); + + return { + tokens: tokens.map(toTokenBalanceScanResponse), + }; +}; + +const fetchDefaultTokenPages = async ( + params: FetchDefaultTokensPageParams +): Promise => { + const pages: DefaultTokensPage[] = []; + const pageParams: number[] = []; + let nextOffset: number | undefined = params.offset ?? 0; + + while (nextOffset !== undefined) { + const offset = nextOffset; + pageParams.push(offset); + + const page = await fetchDefaultTokensPage({ ...params, offset }); + pages.push(page); + + nextOffset = page.nextOffset; + } + + return { pages, pageParams }; +}; + +export const useDefaultTokens = ({ + yieldCategory, +}: { + yieldCategory?: DashboardYieldCategory | null; +} = {}) => { const { network } = useSKWallet(); const { tokensForEnabledYieldsOnly } = useSettings(); const apiClient = useApiClient(); + const queryParams: DefaultTokensQueryParams = { + enabledYieldsOnly: !!tokensForEnabledYieldsOnly, + network: network ?? undefined, + yieldTypes: getYieldTypesForDashboardCategory(yieldCategory), + }; - return useQuery({ - queryKey: getTokenGetTokensQueryKey({ network: network ?? undefined }), - queryFn: async () => - ( - await queryFn({ - apiClient, - network: network ?? undefined, - enabledYieldsOnly: !!tokensForEnabledYieldsOnly, - }) - ).unsafeCoerce(), + return useInfiniteQuery({ + queryKey: getTokenGetTokensQueryKey(queryParams), + initialPageParam: 0, + queryFn: ({ pageParam, signal }) => + fetchDefaultTokensPage({ + ...queryParams, + apiClient, + offset: pageParam, + signal, + }), + getNextPageParam: (lastPage) => lastPage.nextOffset, + select: (data) => data.pages.flatMap((page) => page.tokens), staleTime: 1000 * 60 * 5, }); }; export const getDefaultTokens = ( - params: Parameters[0] & { queryClient: QueryClient } -) => - EitherAsync(() => + params: Omit & { + queryClient: QueryClient; + } +) => { + const queryParams: DefaultTokensQueryParams = { + enabledYieldsOnly: params.enabledYieldsOnly, + network: params.network, + yieldTypes: params.yieldTypes, + }; + + return EitherAsync(() => params.queryClient.fetchQuery({ - queryKey: getTokenGetTokensQueryKey({ - network: params.network ?? undefined, - }), - queryFn: async () => (await queryFn(params)).unsafeCoerce(), + queryKey: getAllDefaultTokensQueryKey(queryParams), + queryFn: async () => { + const data = await fetchDefaultTokenPages(params); + params.queryClient.setQueryData< + InfiniteData + >(getTokenGetTokensQueryKey(queryParams), data); + + return data.pages.flatMap((page) => page.tokens); + }, + staleTime: 1000 * 60 * 5, }) ).mapLeft((e) => { console.log(e); - return new Error("could not get multi yields"); + return new Error("could not get default tokens"); }); - -const queryFn = ({ - apiClient, - network, - enabledYieldsOnly, -}: Pick & { - apiClient: ApiClient; -}) => - EitherAsync(() => - apiClient.legacy.TokenControllerGetTokens({ - params: { - network, - enabledYieldsOnly: enabledYieldsOnly || undefined, - }, - }) - ).map((val) => - val.map((v) => ({ - token: v.token, - availableYields: v.availableYields, - amount: "0", - })) - ); +}; diff --git a/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token.tsx b/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token.tsx index f5c85d9e..1e481287 100644 --- a/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token.tsx +++ b/packages/widget/src/pages/details/earn-page/components/select-token-section/select-token.tsx @@ -29,6 +29,9 @@ export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { selectedToken, onTokenSearch, tokenSearch, + hasMoreTokens, + isLoadingMoreTokens, + onLoadMoreTokens, } = useEarnPageContext(); const { variant } = useSettings(); @@ -123,6 +126,9 @@ export const SelectToken = ({ canSelect = true }: { canSelect?: boolean }) => { className={validatorVirtuosoContainer} data={data.tokenBalances} estimateSize={() => 60} + hasNextPage={hasMoreTokens} + isFetchingNextPage={isLoadingMoreTokens} + fetchNextPage={onLoadMoreTokens} itemContent={(_index, item) => { return ( @@ -220,24 +224,36 @@ export const EarnPageContextProvider = ({ tb: Maybe.fromNullable(tokenBalancesScan.data).alt(Maybe.of([])), }) .map((val) => { - const { tbWithAmount, tbWithoutAmount, tbSet } = val.tb.reduce( - (acc, b) => { - acc.tbSet.add(tokenString(b.token)); - - if (new BigNumber(b.amount).isGreaterThan(0)) { - acc.tbWithAmount.push(b); - } else { - acc.tbWithoutAmount.push(b); - } + const categoryTokenSet = + dashboardYieldCategoryGroupingEnabled && + selectedDashboardYieldCategory + ? new Set(val.defTb.map((item) => tokenString(item.token))) + : null; + const tokenBalancesScanData = categoryTokenSet + ? val.tb.filter((item) => + categoryTokenSet.has(tokenString(item.token)) + ) + : val.tb; - return acc; - }, - { - tbSet: new Set(), - tbWithAmount: [] as TokenBalanceScanResponseDto[], - tbWithoutAmount: [] as TokenBalanceScanResponseDto[], - } - ); + const { tbWithAmount, tbWithoutAmount, tbSet } = + tokenBalancesScanData.reduce( + (acc, b) => { + acc.tbSet.add(tokenString(b.token)); + + if (new BigNumber(b.amount).isGreaterThan(0)) { + acc.tbWithAmount.push(b); + } else { + acc.tbWithoutAmount.push(b); + } + + return acc; + }, + { + tbSet: new Set(), + tbWithAmount: [] as TokenBalanceScanResponseDto[], + tbWithoutAmount: [] as TokenBalanceScanResponseDto[], + } + ); return [ ...tbWithAmount, @@ -260,7 +276,13 @@ export const EarnPageContextProvider = ({ })) .alt(Maybe.of({ all: tb, filtered: tb })) ), - [defaultTokens.data, deferredTokenSearch, tokenBalancesScan.data] + [ + dashboardYieldCategoryGroupingEnabled, + defaultTokens.data, + deferredTokenSearch, + selectedDashboardYieldCategory, + tokenBalancesScan.data, + ] ); const selectedStakeData = useMemo>( @@ -475,7 +497,7 @@ export const EarnPageContextProvider = ({ const onTokenBalanceSelect = useCallback( (tokenBalance: TokenBalanceScanResponseDto) => - dispatch({ type: "token/select", data: tokenBalance.token }), + dispatch({ type: "tokenBalance/select", data: tokenBalance }), [dispatch] ); @@ -831,7 +853,10 @@ export const EarnPageContextProvider = ({ tokenSearch, stakeSearch, defaultTokensIsLoading, + hasMoreTokens: !!defaultTokens.hasNextPage, isLedgerLiveAccountPlaceholder, + isLoadingMoreTokens: defaultTokens.isFetchingNextPage, + onLoadMoreTokens: defaultTokens.fetchNextPage, tronResource, onTronResourceSelect, validation, diff --git a/packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx b/packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx index 4dc353c9..30417ae9 100644 --- a/packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx +++ b/packages/widget/src/pages/details/earn-page/state/earn-page-state-context.tsx @@ -12,6 +12,7 @@ import { import { equalTokens } from "../../../../domain"; import type { Networks } from "../../../../domain/types/chains/networks"; import { isNetworkWithEnterMinBasedOnPosition } from "../../../../domain/types/stake"; +import type { TokenBalanceScanResponseDto } from "../../../../domain/types/token-balance"; import type { TokenDto } from "../../../../domain/types/tokens"; import { type DashboardYieldCategory, @@ -80,51 +81,66 @@ export const EarnPageStateProvider = ({ children }: PropsWithChildren) => { }); const reducer = (state: State, action: Actions): State => { - switch (action.type) { - case "token/select": { - return Maybe.fromFalsy( - state.selectedToken - .map((v) => !equalTokens(v, action.data)) - .orDefault(true) - ) - .chain(() => - getInitYield({ + const onTokenSelectState = ({ + availableYields, + token, + }: { + availableYields?: TokenBalanceScanResponseDto["availableYields"]; + token: TokenDto; + }) => + Maybe.fromFalsy( + state.selectedToken.map((v) => !equalTokens(v, token)).orDefault(true) + ) + .chain(() => + getInitYield({ + availableYields, + selectedDashboardYieldCategory: + dashboardYieldCategorySelectionEnabled + ? state.selectedDashboardYieldCategory + : null, + selectedToken: token, + }) + .map<{ + selectedDashboardYieldCategory: DashboardYieldCategory | null; + yieldState: ReturnType | null; + }>((yieldDto) => ({ selectedDashboardYieldCategory: dashboardYieldCategorySelectionEnabled ? state.selectedDashboardYieldCategory : null, - selectedToken: action.data, - }) - .map<{ - selectedDashboardYieldCategory: DashboardYieldCategory | null; - yieldState: ReturnType | null; - }>((yieldDto) => ({ + yieldState: onYieldSelectState({ + yieldDto, + positionsData: positionsData.data, + }), + })) + .alt( + Maybe.of({ selectedDashboardYieldCategory: dashboardYieldCategorySelectionEnabled ? state.selectedDashboardYieldCategory : null, - yieldState: onYieldSelectState({ - yieldDto, - positionsData: positionsData.data, - }), - })) - .alt( - Maybe.of({ - selectedDashboardYieldCategory: - dashboardYieldCategorySelectionEnabled - ? state.selectedDashboardYieldCategory - : null, - yieldState: null, - }) - ) - ) - .map(({ selectedDashboardYieldCategory, yieldState }) => ({ - ...getInitialState(), - selectedToken: Maybe.of(action.data), - selectedDashboardYieldCategory, - ...yieldState, - })) - .orDefault(state); + yieldState: null, + }) + ) + ) + .map(({ selectedDashboardYieldCategory, yieldState }) => ({ + ...getInitialState(), + selectedToken: Maybe.of(token), + selectedDashboardYieldCategory, + ...yieldState, + })) + .orDefault(state); + + switch (action.type) { + case "token/select": { + return onTokenSelectState({ token: action.data }); + } + + case "tokenBalance/select": { + return onTokenSelectState({ + availableYields: action.data.availableYields, + token: action.data.token, + }); } case "dashboard/yield-category/select": { @@ -305,7 +321,7 @@ export const EarnPageStateProvider = ({ children }: PropsWithChildren) => { ); const initYieldRes = useInitYield({ - selectedDashboardYieldCategory: dashboardYieldCategorySelectionEnabled + selectedDashboardYieldCategory: dashboardYieldCategoryGroupingEnabled ? selectedDashboardYieldCategoryFallback : null, selectedToken, @@ -315,20 +331,8 @@ export const EarnPageStateProvider = ({ children }: PropsWithChildren) => { [initYieldRes.data] ); - const { availableAmount, availableYields } = useTokenBalance({ - selectedToken, - }); - const yieldOpportunity = useYieldOpportunity(selectedStakeId.extract()); - const { minEnterOrExitAmount, maxEnterOrExitAmount, isForceMax } = - useMaxMinYieldAmount({ - type: "enter", - yieldOpportunity: Maybe.fromNullable(yieldOpportunity.data), - availableAmount, - positionsData: positionsData.data, - }); - const selectedStake = useMemo( () => Maybe.fromNullable(yieldOpportunity.data), [yieldOpportunity.data] @@ -341,6 +345,21 @@ export const EarnPageStateProvider = ({ children }: PropsWithChildren) => { selectedDashboardYieldCategoryFallback) : null; + const { availableAmount, availableYields } = useTokenBalance({ + selectedDashboardYieldCategory: dashboardYieldCategoryGroupingEnabled + ? selectedDashboardYieldCategory + : null, + selectedToken, + }); + + const { minEnterOrExitAmount, maxEnterOrExitAmount, isForceMax } = + useMaxMinYieldAmount({ + type: "enter", + yieldOpportunity: Maybe.fromNullable(yieldOpportunity.data), + availableAmount, + positionsData: positionsData.data, + }); + /** * If stake amount is less then min, use min */ diff --git a/packages/widget/src/pages/details/earn-page/state/types.ts b/packages/widget/src/pages/details/earn-page/state/types.ts index 47df6847..ce84ee57 100644 --- a/packages/widget/src/pages/details/earn-page/state/types.ts +++ b/packages/widget/src/pages/details/earn-page/state/types.ts @@ -31,6 +31,10 @@ export type State = { }; type TokenBalanceSelectAction = Action<"token/select", TokenDto>; +type TokenBalanceWithYieldsSelectAction = Action< + "tokenBalance/select", + TokenBalanceScanResponseDto +>; type DashboardYieldCategorySelectAction = Action< "dashboard/yield-category/select", DashboardYieldCategory @@ -58,6 +62,7 @@ type ProviderYieldIdSelectAction = Action< export type Actions = | TokenBalanceSelectAction + | TokenBalanceWithYieldsSelectAction | DashboardYieldCategorySelectAction | PositionDetailsStakeInitializeAction | YieldSelectAction @@ -162,7 +167,10 @@ export type EarnPageContextType = { stakeMinAmount: Maybe; validatorsData: Maybe; hasMoreValidators: boolean; + hasMoreTokens: boolean; isLoadingMoreValidators: boolean; + isLoadingMoreTokens: boolean; onLoadMoreValidators: () => void; + onLoadMoreTokens: () => void; isStakeTokenSameAsGasToken: boolean; }; diff --git a/packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts b/packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts index 56315b5d..8a9e0c7f 100644 --- a/packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts +++ b/packages/widget/src/pages/details/earn-page/state/use-get-init-yield.ts @@ -1,6 +1,7 @@ import { Maybe } from "purify-ts"; import { useCallback } from "react"; import { tokenString } from "../../../../domain"; +import type { TokenBalanceScanResponseDto } from "../../../../domain/types/token-balance"; import type { TokenDto } from "../../../../domain/types/tokens"; import type { DashboardYieldCategory } from "../../../../domain/types/yields"; import { getCachedFirstEligibleYield } from "../../../../hooks/api/use-multi-yields"; @@ -13,14 +14,17 @@ export const useGetInitYield = () => { return useCallback( ({ + availableYields, selectedDashboardYieldCategory, selectedToken, }: { + availableYields?: TokenBalanceScanResponseDto["availableYields"]; selectedDashboardYieldCategory?: DashboardYieldCategory | null; selectedToken: TokenDto; }) => - Maybe.fromNullable( - tokenBalancesMap.get(tokenString(selectedToken)) + (availableYields + ? Maybe.of({ availableYields }) + : Maybe.fromNullable(tokenBalancesMap.get(tokenString(selectedToken))) ).chain((val) => getCachedFirstEligibleYield({ dashboardYieldCategory: selectedDashboardYieldCategory, diff --git a/packages/widget/src/pages/details/earn-page/state/use-init-yield.ts b/packages/widget/src/pages/details/earn-page/state/use-init-yield.ts index 2a650a8d..9f44f0b3 100644 --- a/packages/widget/src/pages/details/earn-page/state/use-init-yield.ts +++ b/packages/widget/src/pages/details/earn-page/state/use-init-yield.ts @@ -65,6 +65,7 @@ export const useInitYield = ({ apiClient, network, queryClient, + selectedDashboardYieldCategory, tokensForEnabledYieldsOnly, }) .chain((val) => diff --git a/packages/widget/src/pages/details/earn-page/state/use-token-balance.ts b/packages/widget/src/pages/details/earn-page/state/use-token-balance.ts index bc183714..107ee6e4 100644 --- a/packages/widget/src/pages/details/earn-page/state/use-token-balance.ts +++ b/packages/widget/src/pages/details/earn-page/state/use-token-balance.ts @@ -3,14 +3,19 @@ import type { Maybe } from "purify-ts"; import { useMemo } from "react"; import { tokenString } from "../../../../domain"; import type { TokenDto } from "../../../../domain/types/tokens"; +import type { DashboardYieldCategory } from "../../../../domain/types/yields"; import { useTokenBalancesMap } from "./use-token-balances-map"; export const useTokenBalance = ({ + selectedDashboardYieldCategory, selectedToken, }: { + selectedDashboardYieldCategory?: DashboardYieldCategory | null; selectedToken: Maybe; }) => { - const tokenBalancesMap = useTokenBalancesMap(); + const tokenBalancesMap = useTokenBalancesMap({ + selectedDashboardYieldCategory, + }); const tokenBalance = useMemo( () => diff --git a/packages/widget/src/pages/details/earn-page/state/use-token-balances-map.ts b/packages/widget/src/pages/details/earn-page/state/use-token-balances-map.ts index 170e17e0..4e7afeaa 100644 --- a/packages/widget/src/pages/details/earn-page/state/use-token-balances-map.ts +++ b/packages/widget/src/pages/details/earn-page/state/use-token-balances-map.ts @@ -1,10 +1,17 @@ +import type { DashboardYieldCategory } from "../../../../domain/types/yields"; import { useDefaultTokens } from "../../../../hooks/api/use-default-tokens"; import { useTokenBalancesScan } from "../../../../hooks/api/use-token-balances-scan"; import { useGetTokenBalancesMap } from "./use-get-token-balances-map"; -export const useTokenBalancesMap = () => { +export const useTokenBalancesMap = ({ + selectedDashboardYieldCategory, +}: { + selectedDashboardYieldCategory?: DashboardYieldCategory | null; +} = {}) => { const tokenBalancesScan = useTokenBalancesScan(); - const defaultTokens = useDefaultTokens(); + const defaultTokens = useDefaultTokens({ + yieldCategory: selectedDashboardYieldCategory, + }); return useGetTokenBalancesMap()({ defaultTokens: defaultTokens.data ?? [], diff --git a/packages/widget/src/providers/api/api-client.ts b/packages/widget/src/providers/api/api-client.ts index 5e1cd74f..ee44b0cf 100644 --- a/packages/widget/src/providers/api/api-client.ts +++ b/packages/widget/src/providers/api/api-client.ts @@ -170,6 +170,10 @@ const bindYieldApi = ({ api.TransactionsControllerSubmitTransactionHash, options ), + TokensControllerGetTokens: bindOperation( + api.TokensControllerGetTokens, + options + ), YieldsControllerGetAggregateBalances: bindOperation( api.YieldsControllerGetAggregateBalances, options diff --git a/packages/widget/src/translation/English/translations.json b/packages/widget/src/translation/English/translations.json index e6e11cf2..35f795ac 100644 --- a/packages/widget/src/translation/English/translations.json +++ b/packages/widget/src/translation/English/translations.json @@ -493,6 +493,8 @@ "pending_action": { "stake": "staked", "unstake": "unstaked", + "withdraw_request": "requested withdrawal", + "instant_withdraw": "withdrew instantly", "claim_rewards": "claimed rewards", "auto_sweep_unstake_rewards": "unstaked rewards", "auto_sweep_withdraw_rewards": "withdrew rewards", @@ -691,6 +693,8 @@ "pending_action": { "stake": "Stake", "unstake": "Unstake", + "withdraw_request": "Request withdrawal", + "instant_withdraw": "Instant withdraw", "claim_rewards": "Claim rewards", "auto_sweep_unstake_rewards": "Unstake rewards", "auto_sweep_withdraw_rewards": "Withdraw rewards", @@ -721,6 +725,8 @@ "pending_action_button": { "stake": "Stake", "unstake": "Unstake", + "withdraw_request": "Request withdrawal", + "instant_withdraw": "Instant withdraw", "claim_rewards": "Claim", "auto_sweep_unstake_rewards": "Unstake rewards", "auto_sweep_withdraw_rewards": "Withdraw rewards", @@ -754,6 +760,8 @@ "pending_action_type": { "stake": "Stake with {{providerName}}", "unstake": "Unstake from {{providerName}}", + "withdraw_request": "Request withdrawal from {{providerName}}", + "instant_withdraw": "Instant withdraw from {{providerName}}", "claim_rewards": "Claim rewards from {{providerName}}", "auto_sweep_unstake_rewards": "Unstake rewards from {{providerName}}", "auto_sweep_withdraw_rewards": "Withdraw rewards from {{providerName}}", diff --git a/packages/widget/src/translation/French/translations.json b/packages/widget/src/translation/French/translations.json index 1c059a89..b62baf28 100644 --- a/packages/widget/src/translation/French/translations.json +++ b/packages/widget/src/translation/French/translations.json @@ -366,6 +366,8 @@ "pending_action": { "stake": "staké", "unstake": "déstaké", + "withdraw_request": "retrait demandé", + "instant_withdraw": "retiré instantanément", "claim_rewards": "récompenses réclamées", "auto_sweep_unstake_rewards": "récompenses retirées", "auto_sweep_withdraw_rewards": "récompenses retirées", @@ -564,6 +566,8 @@ "pending_action": { "stake": "Staker", "unstake": "Déstaker", + "withdraw_request": "Demander le retrait", + "instant_withdraw": "Retrait instantané", "claim_rewards": "Réclamer les récompenses", "auto_sweep_unstake_rewards": "Retirer les récompenses", "auto_sweep_withdraw_rewards": "Retirer les récompenses", @@ -594,6 +598,8 @@ "pending_action_button": { "stake": "Staker", "unstake": "Déstaker", + "withdraw_request": "Demander le retrait", + "instant_withdraw": "Retrait instantané", "claim_rewards": "Réclamer", "auto_sweep_unstake_rewards": "Retirer les récompenses", "auto_sweep_withdraw_rewards": "Retirer les récompenses", @@ -627,6 +633,8 @@ "pending_action_type": { "stake": "Staker avec {{providerName}}", "unstake": "Déstaker de {{providerName}}", + "withdraw_request": "Demander le retrait de {{providerName}}", + "instant_withdraw": "Retrait instantané de {{providerName}}", "claim_rewards": "Réclamer les récompenses de {{providerName}}", "auto_sweep_unstake_rewards": "Retirer les récompenses de {{providerName}}", "auto_sweep_withdraw_rewards": "Retirer les récompenses de {{providerName}}", diff --git a/packages/widget/tests/domain/kyc.test.ts b/packages/widget/tests/domain/kyc.test.ts index 01cfe1c5..f6ca6aaf 100644 --- a/packages/widget/tests/domain/kyc.test.ts +++ b/packages/widget/tests/domain/kyc.test.ts @@ -7,6 +7,16 @@ import { import type { Yield } from "../../src/domain/types/yields"; import { yieldApiProviderFixture, yieldApiYieldFixture } from "../fixtures"; +const kycEligibility = { + defaultPolicy: "allow", + countries: [], + blockedCountries: [], + blockedSubdivisions: [], + usPersonAllowed: true, + investorEligibility: [], + subjectTypes: ["KYC"], +} as const; + const createYield = (overrides?: Partial): Yield => ({ ...yieldApiYieldFixture(), @@ -44,6 +54,7 @@ describe("KYC gate mapping", () => { kycMode: "oauth_redirect", iframeAllowed: false, authorizeUrl: "https://issuer.example/verify", + eligibility: kycEligibility, }, }, }, @@ -61,7 +72,10 @@ describe("KYC gate mapping", () => { expect( mapKycStatusToGate({ - status: { kycStatus: "pending", kycUrl: "https://status.example" }, + status: { + kycStatus: "pending", + authorizeUrl: "https://status.example", + }, yieldDto, }) ).toEqual({ state: "pending", kycUrl: "https://status.example" }); @@ -87,6 +101,7 @@ describe("KYC gate mapping", () => { kycMode: "oauth_redirect", iframeAllowed: true, authorizeUrl: "https://issuer.example/verify", + eligibility: kycEligibility, }, }, }, diff --git a/packages/widget/tests/hooks/default-tokens.test.ts b/packages/widget/tests/hooks/default-tokens.test.ts new file mode 100644 index 00000000..104704c8 --- /dev/null +++ b/packages/widget/tests/hooks/default-tokens.test.ts @@ -0,0 +1,158 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import type { TokenBalanceScanResponseDto } from "../../src/domain/types/token-balance"; +import type { TokenDto } from "../../src/domain/types/tokens"; +import type { TokenWithAvailableYieldsDto as LegacyTokenWithAvailableYieldsDto } from "../../src/generated/api/legacy"; +import type { TokenWithAvailableYieldsDto as YieldTokenWithAvailableYieldsDto } from "../../src/generated/api/yield"; +import { + fetchDefaultTokensPage, + getDefaultTokens, +} from "../../src/hooks/api/use-default-tokens"; +import type { ApiClient } from "../../src/providers/api/api-client"; + +const createToken = (symbol: string): TokenDto => ({ + name: symbol, + symbol, + decimals: 18, + network: "ethereum", + logoURI: `https://assets.stakek.it/tokens/${symbol.toLowerCase()}.svg`, +}); + +const createTokenWithYields = ( + symbol: string, + yieldId = `${symbol.toLowerCase()}-yield` +): YieldTokenWithAvailableYieldsDto => ({ + token: createToken(symbol) as YieldTokenWithAvailableYieldsDto["token"], + availableYields: [yieldId], +}); + +const createApiClient = ({ + getLegacyTokens = vi.fn(), + getYieldTokens = vi.fn(), +}: { + getLegacyTokens?: ReturnType; + getYieldTokens?: ReturnType; +}) => + ({ + withOptions: () => ({ + legacy: { + TokenControllerGetTokens: getLegacyTokens, + }, + yield: { + TokensControllerGetTokens: getYieldTokens, + }, + }), + }) as unknown as ApiClient; + +describe("fetchDefaultTokensPage", () => { + it("uses the yield API with network and yield type filters", async () => { + const tokens = [ + createTokenWithYields("ETH", "eth-staking"), + createTokenWithYields("SOL", "sol-staking"), + ]; + const getLegacyTokens = vi.fn(); + const getYieldTokens = vi.fn(async () => ({ + items: tokens, + total: 3, + offset: 0, + limit: 2, + })); + const apiClient = createApiClient({ getLegacyTokens, getYieldTokens }); + + const page = await fetchDefaultTokensPage({ + apiClient, + limit: 2, + network: "ethereum", + offset: 0, + yieldTypes: ["staking"], + }); + + expect(page).toEqual({ + tokens: tokens.map( + (tokenWithYields): TokenBalanceScanResponseDto => ({ + ...tokenWithYields, + amount: "0", + }) + ), + nextOffset: 2, + }); + expect(getLegacyTokens).not.toHaveBeenCalled(); + expect(getYieldTokens).toHaveBeenCalledWith({ + params: { + networks: ["ethereum"], + yieldTypes: ["staking"], + offset: 0, + limit: 2, + }, + }); + }); + + it("uses the legacy API when no enabled-yield filter is requested", async () => { + const token = { + token: createToken("ETH") as LegacyTokenWithAvailableYieldsDto["token"], + availableYields: ["eth-staking"], + }; + const getLegacyTokens = vi.fn(async () => [token]); + const getYieldTokens = vi.fn(); + const apiClient = createApiClient({ getLegacyTokens, getYieldTokens }); + + const page = await fetchDefaultTokensPage({ + apiClient, + network: "ethereum", + }); + + expect(page.tokens).toEqual([{ ...token, amount: "0" }]); + expect(page.nextOffset).toBeUndefined(); + expect(getYieldTokens).not.toHaveBeenCalled(); + expect(getLegacyTokens).toHaveBeenCalledWith({ + params: { + enabledYieldsOnly: undefined, + network: "ethereum", + }, + }); + }); +}); + +describe("getDefaultTokens", () => { + it("fetches every page from the paginated yield API", async () => { + const tokens = [ + createTokenWithYields("ETH"), + createTokenWithYields("SOL"), + createTokenWithYields("USDC"), + ]; + const getYieldTokens = vi.fn( + async ({ params }: { params: { offset?: number; limit?: number } }) => { + const offset = params.offset ?? 0; + const limit = params.limit ?? 2; + + return { + items: tokens.slice(offset, offset + limit), + total: tokens.length, + offset, + limit, + }; + } + ); + const apiClient = createApiClient({ getYieldTokens }); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const result = await getDefaultTokens({ + apiClient, + enabledYieldsOnly: true, + limit: 2, + network: "ethereum", + queryClient, + }).run(); + + expect(result.unsafeCoerce().map((item) => item.token.symbol)).toEqual([ + "ETH", + "SOL", + "USDC", + ]); + expect(getYieldTokens.mock.calls.map(([arg]) => arg.params.offset)).toEqual( + [0, 2] + ); + }); +}); diff --git a/packages/widget/tests/mocks/yield-api-handlers.ts b/packages/widget/tests/mocks/yield-api-handlers.ts index 11acdd49..d4a0a72c 100644 --- a/packages/widget/tests/mocks/yield-api-handlers.ts +++ b/packages/widget/tests/mocks/yield-api-handlers.ts @@ -77,6 +77,38 @@ export const getYieldApiMock = () => [ }); }), + http.get(yieldApiRoute("/v1/tokens"), async ({ request }) => { + await delay(); + + const url = new URL(request.url); + const offset = Number(url.searchParams.get("offset") ?? 0); + const limit = Number(url.searchParams.get("limit") ?? 100); + const networks = url.searchParams + .getAll("networks") + .flatMap((value) => value.split(",")); + const yieldTypes = url.searchParams + .getAll("yieldTypes") + .flatMap((value) => value.split(",")); + const items = + (networks.length === 0 || networks.includes(defaultToken.network)) && + (yieldTypes.length === 0 || + yieldTypes.includes(defaultYield.mechanics.type)) + ? [ + { + token: defaultToken, + availableYields: [defaultYield.id], + }, + ] + : []; + + return HttpResponse.json({ + items: items.slice(offset, offset + limit), + total: items.length, + limit, + offset, + }); + }), + http.get(yieldApiRoute("/v1/yields/:yieldId"), async ({ params }) => { await delay(); diff --git a/packages/widget/tests/pages-dashboard/earn-details-model.test.tsx b/packages/widget/tests/pages-dashboard/earn-details-model.test.tsx index 41794182..3ad3d1ca 100644 --- a/packages/widget/tests/pages-dashboard/earn-details-model.test.tsx +++ b/packages/widget/tests/pages-dashboard/earn-details-model.test.tsx @@ -20,7 +20,7 @@ const t = (key: string, options?: Record): string => { const minStakeMechanics = { ...yieldApiYieldFixture().mechanics, - entryLimits: { minimum: "1", maximum: null }, + entryLimits: { minimum: "1", maximum: null, subsequentMinimum: null }, }; const makeYield = (overrides?: Partial): Yield => diff --git a/packages/widget/tests/providers/api-client.test.tsx b/packages/widget/tests/providers/api-client.test.tsx index fe90e905..e2ee772e 100644 --- a/packages/widget/tests/providers/api-client.test.tsx +++ b/packages/widget/tests/providers/api-client.test.tsx @@ -64,6 +64,7 @@ describe("API client", () => { expect("TokenControllerGetTokens" in client.legacy).toBe(true); expect("AuthControllerMe" in client.legacy).toBe(false); + expect("TokensControllerGetTokens" in client.yield).toBe(true); expect("YieldsControllerGetAggregateBalances" in client.yield).toBe(true); expect("ProvidersControllerGetProvider" in client.yield).toBe(true); expect("ProvidersControllerGetProviders" in client.yield).toBe(false); diff --git a/packages/widget/tests/use-cases/rwa-kyc-flow.test.tsx b/packages/widget/tests/use-cases/rwa-kyc-flow.test.tsx index a29acbc7..ff460614 100644 --- a/packages/widget/tests/use-cases/rwa-kyc-flow.test.tsx +++ b/packages/widget/tests/use-cases/rwa-kyc-flow.test.tsx @@ -72,6 +72,15 @@ const mockKycRequiredDefaultYield = () => { kycMode: "oauth_redirect", iframeAllowed: false, authorizeUrl: "https://issuer.example/verify", + eligibility: { + defaultPolicy: "allow", + countries: [], + blockedCountries: [], + blockedSubdivisions: [], + usPersonAllowed: true, + investorEligibility: [], + subjectTypes: ["KYC"], + }, }, }, }, From 208fc99b96477b5489c61a90503cca86b3125134 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Wed, 17 Jun 2026 22:31:32 +0200 Subject: [PATCH 2/3] fix(widget): load all category tokens for selection --- .../src/hooks/api/use-default-tokens.ts | 197 +++++++++++++----- .../widget/tests/hooks/default-tokens.test.ts | 68 ++++-- 2 files changed, 192 insertions(+), 73 deletions(-) diff --git a/packages/widget/src/hooks/api/use-default-tokens.ts b/packages/widget/src/hooks/api/use-default-tokens.ts index 57d69d8f..d6f28f80 100644 --- a/packages/widget/src/hooks/api/use-default-tokens.ts +++ b/packages/widget/src/hooks/api/use-default-tokens.ts @@ -2,6 +2,7 @@ import { type InfiniteData, type QueryClient, useInfiniteQuery, + useQuery, } from "@tanstack/react-query"; import { EitherAsync } from "purify-ts"; import type { TokenBalanceScanResponseDto } from "../../domain/types/token-balance"; @@ -24,6 +25,7 @@ import { useSettings } from "../../providers/settings"; import { useSKWallet } from "../../providers/sk-wallet"; const DEFAULT_TOKENS_PAGE_LIMIT = 100; +const DEFAULT_TOKENS_PAGE_CONCURRENCY = 5; type YieldTokenTypes = YieldTokenGetTokensParams["yieldTypes"]; type DefaultTokensQueryParams = { @@ -32,8 +34,11 @@ type DefaultTokensQueryParams = { yieldTypes?: YieldTokenTypes; }; type DefaultTokensPage = { + limit?: number; nextOffset?: number; + offset?: number; tokens: TokenBalanceScanResponseDto[]; + total?: number; }; type DefaultTokensPages = { pages: DefaultTokensPage[]; @@ -46,6 +51,8 @@ type FetchDefaultTokensPageParams = DefaultTokensQueryParams & { signal?: AbortSignal; }; +const noopFetchNextPage = () => undefined; + const getTokenGetTokensQueryKey = (params?: DefaultTokensQueryParams) => ["/v1/tokens", ...(params ? [params] : [])] as const; @@ -89,37 +96,81 @@ export const getYieldTypesForDashboardCategory = ( ? getApiYieldTypesForDashboardCategory(yieldCategory) : undefined; -export const fetchDefaultTokensPage = async ({ +export const fetchDefaultTokens = async ({ apiClient, enabledYieldsOnly, limit = DEFAULT_TOKENS_PAGE_LIMIT, network, - offset = 0, + offset: firstOffset = 0, signal, yieldTypes, -}: FetchDefaultTokensPageParams): Promise => { +}: FetchDefaultTokensPageParams): Promise => { if (shouldUseYieldTokensApi({ enabledYieldsOnly, yieldTypes })) { - const page = await apiClient - .withOptions({ signal }) - .yield.TokensControllerGetTokens({ - params: { - networks: network - ? [ - network as NonNullable< - YieldTokenGetTokensParams["networks"] - >[number], - ] - : undefined, - yieldTypes, - offset, - limit, - }, - }); + const yieldTokenParams = { + networks: network + ? [ + network as NonNullable< + YieldTokenGetTokensParams["networks"] + >[number], + ] + : undefined, + yieldTypes, + limit, + }; - return { - tokens: (page.items ?? []).map(toTokenBalanceScanResponse), - nextOffset: getNextOffset(page), + const fetchYieldTokensPage = async ( + offset: number + ): Promise => { + const page = await apiClient + .withOptions({ signal }) + .yield.TokensControllerGetTokens({ + params: { ...yieldTokenParams, offset }, + }); + + return { + limit: page.limit, + tokens: (page.items ?? []).map(toTokenBalanceScanResponse), + nextOffset: getNextOffset(page), + offset: page.offset, + total: page.total, + }; }; + + const firstPage = await fetchYieldTokensPage(firstOffset); + + if (firstPage.nextOffset === undefined) { + return { pages: [firstPage], pageParams: [firstOffset] }; + } + + const remainingOffsets: number[] = []; + for ( + let offset = firstPage.offset! + firstPage.limit!; + offset < firstPage.total!; + offset += firstPage.limit! + ) { + remainingOffsets.push(offset); + } + + const pages = [firstPage]; + const pageParams = [firstOffset, ...remainingOffsets]; + + for ( + let i = 0; + i < remainingOffsets.length; + i += DEFAULT_TOKENS_PAGE_CONCURRENCY + ) { + const chunk = remainingOffsets.slice( + i, + i + DEFAULT_TOKENS_PAGE_CONCURRENCY + ); + const chunkPages = await Promise.all( + chunk.map((offset) => fetchYieldTokensPage(offset)) + ); + + pages.push(...chunkPages); + } + + return { pages, pageParams }; } const tokens = await apiClient @@ -132,30 +183,11 @@ export const fetchDefaultTokensPage = async ({ }); return { - tokens: tokens.map(toTokenBalanceScanResponse), + pages: [{ tokens: tokens.map(toTokenBalanceScanResponse) }], + pageParams: [firstOffset], }; }; -const fetchDefaultTokenPages = async ( - params: FetchDefaultTokensPageParams -): Promise => { - const pages: DefaultTokensPage[] = []; - const pageParams: number[] = []; - let nextOffset: number | undefined = params.offset ?? 0; - - while (nextOffset !== undefined) { - const offset = nextOffset; - pageParams.push(offset); - - const page = await fetchDefaultTokensPage({ ...params, offset }); - pages.push(page); - - nextOffset = page.nextOffset; - } - - return { pages, pageParams }; -}; - export const useDefaultTokens = ({ yieldCategory, }: { @@ -169,21 +201,84 @@ export const useDefaultTokens = ({ network: network ?? undefined, yieldTypes: getYieldTypesForDashboardCategory(yieldCategory), }; + const shouldFetchAllPages = !!queryParams.yieldTypes?.length; - return useInfiniteQuery({ - queryKey: getTokenGetTokensQueryKey(queryParams), - initialPageParam: 0, - queryFn: ({ pageParam, signal }) => - fetchDefaultTokensPage({ + const allPagesQuery = useQuery({ + queryKey: getAllDefaultTokensQueryKey(queryParams), + enabled: shouldFetchAllPages, + queryFn: async ({ signal }) => { + const data = await fetchDefaultTokens({ ...queryParams, apiClient, - offset: pageParam, signal, - }), + }); + + return data.pages.flatMap((page) => page.tokens); + }, + staleTime: 1000 * 60 * 5, + }); + + const infiniteQuery = useInfiniteQuery({ + queryKey: getTokenGetTokensQueryKey(queryParams), + enabled: !shouldFetchAllPages, + initialPageParam: 0, + queryFn: async ({ pageParam, signal }) => { + if (shouldUseYieldTokensApi(queryParams)) { + const page = await apiClient + .withOptions({ signal }) + .yield.TokensControllerGetTokens({ + params: { + networks: queryParams.network + ? [ + queryParams.network as NonNullable< + YieldTokenGetTokensParams["networks"] + >[number], + ] + : undefined, + yieldTypes: queryParams.yieldTypes, + offset: pageParam, + limit: DEFAULT_TOKENS_PAGE_LIMIT, + }, + }); + + return { + limit: page.limit, + tokens: (page.items ?? []).map(toTokenBalanceScanResponse), + nextOffset: getNextOffset(page), + offset: page.offset, + total: page.total, + }; + } + + const tokens = await apiClient + .withOptions({ signal }) + .legacy.TokenControllerGetTokens({ + params: { + enabledYieldsOnly: queryParams.enabledYieldsOnly || undefined, + network: + queryParams.network as LegacyTokenGetTokensParams["network"], + }, + }); + + return { + tokens: tokens.map(toTokenBalanceScanResponse), + }; + }, getNextPageParam: (lastPage) => lastPage.nextOffset, select: (data) => data.pages.flatMap((page) => page.tokens), staleTime: 1000 * 60 * 5, }); + + if (shouldFetchAllPages) { + return { + ...allPagesQuery, + fetchNextPage: noopFetchNextPage, + hasNextPage: false, + isFetchingNextPage: false, + }; + } + + return infiniteQuery; }; export const getDefaultTokens = ( @@ -201,7 +296,7 @@ export const getDefaultTokens = ( params.queryClient.fetchQuery({ queryKey: getAllDefaultTokensQueryKey(queryParams), queryFn: async () => { - const data = await fetchDefaultTokenPages(params); + const data = await fetchDefaultTokens(params); params.queryClient.setQueryData< InfiniteData >(getTokenGetTokensQueryKey(queryParams), data); diff --git a/packages/widget/tests/hooks/default-tokens.test.ts b/packages/widget/tests/hooks/default-tokens.test.ts index 104704c8..91c7fbdd 100644 --- a/packages/widget/tests/hooks/default-tokens.test.ts +++ b/packages/widget/tests/hooks/default-tokens.test.ts @@ -5,7 +5,7 @@ import type { TokenDto } from "../../src/domain/types/tokens"; import type { TokenWithAvailableYieldsDto as LegacyTokenWithAvailableYieldsDto } from "../../src/generated/api/legacy"; import type { TokenWithAvailableYieldsDto as YieldTokenWithAvailableYieldsDto } from "../../src/generated/api/yield"; import { - fetchDefaultTokensPage, + fetchDefaultTokens, getDefaultTokens, } from "../../src/hooks/api/use-default-tokens"; import type { ApiClient } from "../../src/providers/api/api-client"; @@ -44,22 +44,23 @@ const createApiClient = ({ }), }) as unknown as ApiClient; -describe("fetchDefaultTokensPage", () => { +describe("fetchDefaultTokens", () => { it("uses the yield API with network and yield type filters", async () => { const tokens = [ createTokenWithYields("ETH", "eth-staking"), createTokenWithYields("SOL", "sol-staking"), + createTokenWithYields("USDC", "usdc-staking"), ]; const getLegacyTokens = vi.fn(); - const getYieldTokens = vi.fn(async () => ({ - items: tokens, - total: 3, - offset: 0, - limit: 2, + const getYieldTokens = vi.fn(async ({ params }) => ({ + items: tokens.slice(params.offset, params.offset + params.limit), + total: tokens.length, + offset: params.offset, + limit: params.limit, })); const apiClient = createApiClient({ getLegacyTokens, getYieldTokens }); - const page = await fetchDefaultTokensPage({ + const { pages } = await fetchDefaultTokens({ apiClient, limit: 2, network: "ethereum", @@ -67,24 +68,29 @@ describe("fetchDefaultTokensPage", () => { yieldTypes: ["staking"], }); - expect(page).toEqual({ - tokens: tokens.map( + expect(pages).toHaveLength(2); + expect(pages[0]).toMatchObject({ + limit: 2, + tokens: tokens.slice(0, 2).map( (tokenWithYields): TokenBalanceScanResponseDto => ({ ...tokenWithYields, amount: "0", }) ), nextOffset: 2, + offset: 0, + total: 3, }); + expect(pages[1].tokens).toEqual( + [tokens[2]].map((tokenWithYields) => ({ + ...tokenWithYields, + amount: "0", + })) + ); expect(getLegacyTokens).not.toHaveBeenCalled(); - expect(getYieldTokens).toHaveBeenCalledWith({ - params: { - networks: ["ethereum"], - yieldTypes: ["staking"], - offset: 0, - limit: 2, - }, - }); + expect(getYieldTokens.mock.calls.map(([arg]) => arg.params.offset)).toEqual( + [0, 2] + ); }); it("uses the legacy API when no enabled-yield filter is requested", async () => { @@ -96,13 +102,14 @@ describe("fetchDefaultTokensPage", () => { const getYieldTokens = vi.fn(); const apiClient = createApiClient({ getLegacyTokens, getYieldTokens }); - const page = await fetchDefaultTokensPage({ + const { pages } = await fetchDefaultTokens({ apiClient, network: "ethereum", }); - expect(page.tokens).toEqual([{ ...token, amount: "0" }]); - expect(page.nextOffset).toBeUndefined(); + expect(pages).toHaveLength(1); + expect(pages[0].tokens).toEqual([{ ...token, amount: "0" }]); + expect(pages[0].nextOffset).toBeUndefined(); expect(getYieldTokens).not.toHaveBeenCalled(); expect(getLegacyTokens).toHaveBeenCalledWith({ params: { @@ -119,12 +126,26 @@ describe("getDefaultTokens", () => { createTokenWithYields("ETH"), createTokenWithYields("SOL"), createTokenWithYields("USDC"), + createTokenWithYields("ATOM"), + createTokenWithYields("OSMO"), ]; + let activePageRequests = 0; + let maxActivePageRequests = 0; const getYieldTokens = vi.fn( async ({ params }: { params: { offset?: number; limit?: number } }) => { const offset = params.offset ?? 0; const limit = params.limit ?? 2; + if (offset > 0) { + activePageRequests += 1; + maxActivePageRequests = Math.max( + maxActivePageRequests, + activePageRequests + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + activePageRequests -= 1; + } + return { items: tokens.slice(offset, offset + limit), total: tokens.length, @@ -150,9 +171,12 @@ describe("getDefaultTokens", () => { "ETH", "SOL", "USDC", + "ATOM", + "OSMO", ]); expect(getYieldTokens.mock.calls.map(([arg]) => arg.params.offset)).toEqual( - [0, 2] + [0, 2, 4] ); + expect(maxActivePageRequests).toBe(2); }); }); From 00e3633095d5513987d07b1816e4256cccfee1f0 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Thu, 18 Jun 2026 10:24:08 +0200 Subject: [PATCH 3/3] refactor(widget): resolve yieldGrouping default in settings provider Split settings input props from resolved context type so yieldGrouping is always defined after initialization. Default unset values to "category" to preserve prior implicit behavior. --- packages/widget/src/providers/settings/index.tsx | 10 +++++++--- packages/widget/src/providers/settings/types.ts | 10 ++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/widget/src/providers/settings/index.tsx b/packages/widget/src/providers/settings/index.tsx index da80a509..f7a73fdb 100644 --- a/packages/widget/src/providers/settings/index.tsx +++ b/packages/widget/src/providers/settings/index.tsx @@ -4,7 +4,7 @@ import { createContext, useContext, useLayoutEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { config } from "../../config"; import utilaTranslations from "../../translation/English/utila-variant.json"; -import type { SettingsContextType } from "./types"; +import type { SettingsContextType, SettingsProps, VariantProps } from "./types"; export const SettingsContext = createContext( undefined @@ -13,7 +13,7 @@ export const SettingsContext = createContext( export const SettingsContextProvider = ({ children, ...rest -}: PropsWithChildren) => { +}: PropsWithChildren) => { if (!config.env.isTestMode && rest.wagmi?.__customConnectors__) { rest.wagmi.__customConnectors__ = undefined; } @@ -67,7 +67,11 @@ export const SettingsContextProvider = ({ return ( {children} diff --git a/packages/widget/src/providers/settings/types.ts b/packages/widget/src/providers/settings/types.ts index 8e425bf5..9a51968e 100644 --- a/packages/widget/src/providers/settings/types.ts +++ b/packages/widget/src/providers/settings/types.ts @@ -17,6 +17,8 @@ import type { TrackPageVal, } from "../tracking/types"; +export type YieldGrouping = "flat" | "category"; + export type VariantProps = | { variant: "zerion"; @@ -84,7 +86,7 @@ export type SettingsProps = { | Record | ((chain: SupportedSKChains) => string); dashboardVariant?: boolean; - yieldGrouping?: "flat" | "category"; + yieldGrouping?: YieldGrouping; institutionalWallets?: boolean; hideChainSelector?: boolean; hideAccountAndChainSelector?: boolean; @@ -94,4 +96,8 @@ export type SettingsProps = { initialChain?: SupportedSKChainIds; }; -export type SettingsContextType = SettingsProps & VariantProps; +export type ResolvedSettingsProps = Omit & { + yieldGrouping: YieldGrouping; +}; + +export type SettingsContextType = ResolvedSettingsProps & VariantProps;