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..d6f28f80 100644 --- a/packages/widget/src/hooks/api/use-default-tokens.ts +++ b/packages/widget/src/hooks/api/use-default-tokens.ts @@ -1,68 +1,312 @@ -import type { QueryClient } from "@tanstack/react-query"; -import { useQuery } from "@tanstack/react-query"; +import { + type InfiniteData, + type QueryClient, + useInfiniteQuery, + useQuery, +} 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; +const DEFAULT_TOKENS_PAGE_CONCURRENCY = 5; + +type YieldTokenTypes = YieldTokenGetTokensParams["yieldTypes"]; +type DefaultTokensQueryParams = { + enabledYieldsOnly?: boolean; + network?: TokenDto["network"]; + yieldTypes?: YieldTokenTypes; +}; +type DefaultTokensPage = { + limit?: number; + nextOffset?: number; + offset?: number; + tokens: TokenBalanceScanResponseDto[]; + total?: number; +}; +type DefaultTokensPages = { + pages: DefaultTokensPage[]; + pageParams: number[]; +}; +type FetchDefaultTokensPageParams = DefaultTokensQueryParams & { + apiClient: ApiClient; + limit?: number; + offset?: number; + signal?: AbortSignal; +}; + +const noopFetchNextPage = () => undefined; + +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 fetchDefaultTokens = async ({ + apiClient, + enabledYieldsOnly, + limit = DEFAULT_TOKENS_PAGE_LIMIT, + network, + offset: firstOffset = 0, + signal, + yieldTypes, +}: FetchDefaultTokensPageParams): Promise => { + if (shouldUseYieldTokensApi({ enabledYieldsOnly, yieldTypes })) { + const yieldTokenParams = { + networks: network + ? [ + network as NonNullable< + YieldTokenGetTokensParams["networks"] + >[number], + ] + : undefined, + yieldTypes, + limit, + }; + + 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 + .withOptions({ signal }) + .legacy.TokenControllerGetTokens({ + params: { + enabledYieldsOnly: enabledYieldsOnly || undefined, + network: network as LegacyTokenGetTokensParams["network"], + }, + }); + + return { + pages: [{ tokens: tokens.map(toTokenBalanceScanResponse) }], + pageParams: [firstOffset], + }; +}; + +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), + }; + const shouldFetchAllPages = !!queryParams.yieldTypes?.length; + + const allPagesQuery = useQuery({ + queryKey: getAllDefaultTokensQueryKey(queryParams), + enabled: shouldFetchAllPages, + queryFn: async ({ signal }) => { + const data = await fetchDefaultTokens({ + ...queryParams, + apiClient, + signal, + }); - return useQuery({ - queryKey: getTokenGetTokensQueryKey({ network: network ?? undefined }), - queryFn: async () => - ( - await queryFn({ - apiClient, - network: network ?? undefined, - enabledYieldsOnly: !!tokensForEnabledYieldsOnly, - }) - ).unsafeCoerce(), + 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 = ( - 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 fetchDefaultTokens(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..91c7fbdd --- /dev/null +++ b/packages/widget/tests/hooks/default-tokens.test.ts @@ -0,0 +1,182 @@ +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 { + fetchDefaultTokens, + 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("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 ({ 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 { pages } = await fetchDefaultTokens({ + apiClient, + limit: 2, + network: "ethereum", + offset: 0, + yieldTypes: ["staking"], + }); + + 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.mock.calls.map(([arg]) => arg.params.offset)).toEqual( + [0, 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 { pages } = await fetchDefaultTokens({ + apiClient, + network: "ethereum", + }); + + 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: { + enabledYieldsOnly: undefined, + network: "ethereum", + }, + }); + }); +}); + +describe("getDefaultTokens", () => { + it("fetches every page from the paginated yield API", async () => { + const tokens = [ + 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, + 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", + "ATOM", + "OSMO", + ]); + expect(getYieldTokens.mock.calls.map(([arg]) => arg.params.offset)).toEqual( + [0, 2, 4] + ); + expect(maxActivePageRequests).toBe(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"], + }, }, }, },