From f7cbb2a1554e187ef89455597a892f15577f5e8d Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Wed, 28 Oct 2026 14:50:29 +0100 Subject: [PATCH 1/8] refactor(widget): keep branded ids and exact values past decoding Decoded ids, keys, hashes and amounts were widened back to string or number in helpers, feature state and service interfaces, so mixing id kinds or renaming a literal compiled silently. - Replace the ValidatorKey alias with EarnValidatorKey. This fixes subnet validators never showing as selected in the pending-action modal. - Carry BalanceDataKey from position routes and init params through the position-details flow and the positions resource. - Keep borrow ActionId, TransactionId, MarketId, IntegrationId and TokenAddress through API operations, flow state and activity lookups. - Classify borrow action and workflow transaction statuses exhaustively; decode init params pendingaction and validator to their domain types. - Type the dashboard page key and yield flow route group as tab | WidgetPath. - Add TransactionHash, WalletSignature, SignedTransactionPayload, DirectTransactionId and BorrowPositionActionKey brands, and remove the casts between them. - Decode confidential balance shares, fees, entry limits, TVL and price per share to exact values; a malformed field drops only itself. --- .../layouts/dashboard/dashboard-routes.tsx | 14 +- .../dashboard/dashboard-tabs/index.tsx | 10 +- .../src/app/runtime/deep-link-coordinator.ts | 16 +- .../src/domain/action/action-command.ts | 2 +- .../src/domain/action/direct-transaction.ts | 13 +- packages/widget/src/domain/action/models.ts | 29 +- .../src/domain/borrow/execution/action.ts | 22 +- .../domain/borrow/execution/transaction.ts | 31 ++- .../borrow/positions/position-action-key.ts | 19 ++ .../widget/src/domain/earn/confidential.ts | 139 +++++----- packages/widget/src/domain/earn/models.ts | 261 +++++++++++++++--- .../widget/src/domain/earn/reward-rate.ts | 11 +- packages/widget/src/domain/earn/stake.ts | 40 ++- packages/widget/src/domain/earn/validator.ts | 1 - packages/widget/src/domain/earn/yield.ts | 66 ++--- .../src/domain/finance/funded-balance.ts | 10 +- packages/widget/src/domain/finance/price.ts | 19 +- .../widget/src/domain/identity/identifiers.ts | 18 ++ .../widget/src/domain/portfolio/models.ts | 6 +- .../widget/src/domain/portfolio/positions.ts | 55 ++-- .../activity/model/activity-action-details.ts | 5 +- .../activity/model/activity-enrichment.ts | 16 +- .../activity/react/activity-action-route.tsx | 6 +- .../src/features/activity/state/enrichment.ts | 5 +- .../model/borrow-completion.ts | 5 +- .../model/borrow-transaction-flow.ts | 4 +- .../model/borrow-transaction-workflow.ts | 9 +- .../state/execution-page.ts | 7 +- .../borrow/borrow-entry/model/borrow-entry.ts | 12 +- .../borrow-entry/model/market-groups.ts | 8 +- .../borrow/borrow-entry/state/borrow-entry.ts | 4 +- .../ui/components/market-select-modal.tsx | 3 +- .../market-position/model/action-form.ts | 5 +- .../borrow/market-position/model/details.ts | 3 +- .../market-position/state/action-form.ts | 5 +- .../positions/react/use-borrow-positions.ts | 3 +- .../borrow/positions/state/positions.ts | 2 +- .../earn/model/pending-action-deep-link.ts | 13 +- .../state/earn-selection/catalog/catalog.ts | 10 +- .../earn/state/earn-selection/model/form.ts | 9 +- .../earn/state/earn-selection/types.ts | 3 +- .../features/earn/state/token-selection.ts | 8 +- .../components/select-provider/index.tsx | 8 +- .../select-validator-trigger.tsx | 2 +- .../select-yield-reward-details.tsx | 7 +- .../components/provider-selection-card.tsx | 5 +- .../portfolio/state/read-models/positions.ts | 3 +- .../portfolio/state/read-models/summary.ts | 2 +- .../positions/hooks/use-position-list-item.ts | 10 +- .../position-details/model/flow-actions.ts | 67 +++-- .../model/position-overview.ts | 7 +- .../react/use-pending-actions.ts | 3 +- .../react/use-validator-addresses-handling.ts | 6 +- .../state/actions/pending-action.ts | 8 +- .../position-details/state/actions/reveal.ts | 2 +- .../position-details/state/details-view.ts | 31 +-- .../position-details/state/stake-entry.ts | 3 +- .../position-details/state/stake-view.ts | 4 +- .../position-details/state/workflow-view.ts | 7 +- .../position-details/state/workflow.ts | 7 +- .../position-details-validator-modal.tsx | 12 +- .../position-details-stake-actions.tsx | 32 ++- .../hooks/use-position-details-stake.ts | 15 +- .../features/yield-entry/model/yield-entry.ts | 15 +- .../ui/components/select-validator/index.tsx | 4 +- .../components/select-validator/meta-info.tsx | 2 +- .../select-validator-list.tsx | 4 +- .../yield-summary/model/estimated-rewards.ts | 4 +- .../model/yield-details-formatters.ts | 37 +-- .../yield-summary/model/yield-summary.ts | 10 +- .../ui/components/meta-info/index.tsx | 4 +- .../model/yield-transaction-flow.ts | 19 +- .../state/yield-summary.ts | 18 +- .../yield-positions/yield-positions.ts | 3 +- .../src/services/api/borrow-operations.ts | 10 +- .../widget/src/services/api/operations.ts | 12 +- .../src/services/api/resource-sources.ts | 7 +- .../src/services/navigation/widget-routes.ts | 9 +- .../internal/confirmation.ts | 8 +- .../internal/held-hash-registration.ts | 3 +- .../transaction-workflow/internal/model.ts | 39 ++- .../internal/preparation.ts | 12 +- .../transaction-workflow/internal/signing.ts | 23 +- .../internal/submission.ts | 160 +++++++---- .../transaction-workflow-model.ts | 49 +++- .../src/services/wallet/external-provider.ts | 39 ++- .../widget/src/services/wallet/init-params.ts | 12 +- .../internal/adapters/cardano/driver.ts | 7 +- .../wallet/internal/adapters/cosmos/driver.ts | 3 +- .../adapters/external-provider/driver.ts | 7 +- .../wallet/internal/adapters/ledger/driver.ts | 11 +- .../wallet/internal/adapters/safe/driver.ts | 7 +- .../wallet/internal/adapters/solana/driver.ts | 7 +- .../internal/adapters/stellar/driver.ts | 7 +- .../internal/adapters/substrate/driver.ts | 3 +- .../wallet/internal/adapters/ton/driver.ts | 7 +- .../wallet/internal/adapters/tron/driver.ts | 7 +- .../wallet/internal/runtime/wagmi-actions.ts | 21 +- .../services/wallet/wallet-transactions.ts | 50 +++- .../src/services/zama/revealed-balances.ts | 2 +- .../widget/src/services/zama/zama-service.ts | 57 +++- packages/widget/src/shared/lib/formatters.ts | 2 +- .../param-validation.dom.test.tsx | 10 + .../app/journeys/deep-links-flow/setup.ts | 7 +- .../tests/app/journeys/staking-flow/setup.ts | 7 +- ...ct-position-details-redirects.dom.test.tsx | 26 +- .../pending-action-deep-link-route.test.ts | 22 +- ...nsaction-workflow-event-projection.test.ts | 16 +- .../widget/tests/domain/confidential.test.ts | 45 +-- .../tests/domain/earn/earn-models.test.ts | 196 +++++++++++++ .../tests/domain/earn/reward-rate.test.ts | 6 +- .../domain/finance/funded-balance.test.ts | 45 ++- .../widget/tests/domain/finance/price.test.ts | 3 +- .../domain/portfolio/dashboard-models.test.ts | 1 - .../portfolio/position-derivations.test.ts | 8 +- .../activity/activity-action-details.test.ts | 3 +- .../activity/activity-details.dom.test.tsx | 3 +- .../borrow-execution-flow.browser.test.tsx | 10 +- .../borrow-flow-operations.test.ts | 15 +- .../borrow/borrow-entry/market-groups.test.ts | 8 +- .../market-position/action-form.test.ts | 5 +- .../features/earn/earn-details-model.test.tsx | 17 +- .../features/earn/earn-entry-reset.test.ts | 3 +- .../tests/features/earn/earn-facade.test.ts | 6 +- .../earn/earn-initial-validator.test.ts | 7 +- .../earn/earn-refetch-failure.test.ts | 8 +- .../features/earn/earn-selection.test.ts | 7 +- .../earn/history-chart-points.test.ts | 1 - .../earn/validator-selection-limit.test.ts | 2 +- .../positions-list-item.browser.test.tsx | 2 +- .../page-workflow-state.dom.test.tsx | 2 +- .../position-details-action-model.test.ts | 88 +++++- ...ition-details-action-tabs.browser.test.tsx | 17 +- .../position-details-exit-command.test.ts | 6 +- .../position-details-stake-token.test.ts | 2 +- .../position-details-stake-validators.test.ts | 2 +- .../position-details-workflow.test.ts | 4 +- ...ition-details-yield-entry.browser.test.tsx | 10 +- .../position-overview-model.test.tsx | 29 +- .../position-source-details.browser.test.tsx | 6 +- .../features/yield-flow-confidential.test.ts | 36 ++- .../yield-completion-urls.test.ts | 9 +- .../yield-flow-operations.test.ts | 8 +- .../yield-flow-review-page.test.ts | 2 +- .../yield-flow-route.dom.test.tsx | 2 +- .../yield-steps-view.test.ts | 18 +- .../yield-transaction-flow-interface.test.ts | 60 ++-- packages/widget/tests/fixtures/index.ts | 12 +- .../tests/services/api/api-retries.test.ts | 18 +- .../tests/services/api/api-service.test.ts | 12 +- .../services/navigation/widget-routes.test.ts | 51 +++- .../services/semantic-invalidation.test.ts | 20 +- .../confidential-transaction-workflow.test.ts | 99 ++++--- .../transaction-workflow-model.test.ts | 13 +- .../transaction-workflow-runtime.test.ts | 20 +- .../services/wallet/evm-wallet-driver.test.ts | 4 +- .../wallet/external-provider-contract.test.ts | 3 +- .../external-provider-wallet-driver.test.ts | 3 +- .../services/wallet/wallet-atoms.test.ts | 2 +- .../services/wallet/wallet-router.test.ts | 16 +- .../tests/services/zama-service.test.ts | 31 ++- .../transaction-workflow-test-kit.test.ts | 8 +- 162 files changed, 1986 insertions(+), 985 deletions(-) create mode 100644 packages/widget/src/domain/borrow/positions/position-action-key.ts diff --git a/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx index 0521b9f73..2435e6115 100644 --- a/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx +++ b/packages/widget/src/app/layouts/dashboard/dashboard-routes.tsx @@ -24,7 +24,10 @@ import { WalletScopeRouteGuard } from "../../../features/wallet/composition"; import { GlobalModals } from "../../../features/widget-shell/composition"; // import { RewardsTabPage } from "../../../domain/portfolio/rewards"; import { createYieldFlowRoutes } from "../../../features/yield-transaction-flow/composition"; -import { widgetRoutes } from "../../../services/navigation/widget-routes"; +import { + type WidgetPath, + widgetRoutes, +} from "../../../services/navigation/widget-routes"; import { BorrowFeatureRoute } from "../../routes/ui/borrow-feature-route"; import { dashboardEarnRoute } from "./dashboard-earn-route"; import { @@ -33,7 +36,10 @@ import { DashboardOverview, } from "./dashboard-overview"; import { DashboardShell } from "./dashboard-shell"; -import { resolveDashboardRouteTab } from "./dashboard-tabs"; +import { + type DashboardRouteTab, + resolveDashboardRouteTab, +} from "./dashboard-tabs"; const borrowRouteComposition = { WalletScopeRouteGuard, @@ -48,7 +54,9 @@ export const shouldRegisterDashboardEarnFooterButton = (pathname: string) => * transition (exit, then enter); steps inside one page, such as the Earn form * into its Review or a position into its unstake flow, keep the same key. */ -const resolveDashboardPageKey = (pathname: string): string => { +const resolveDashboardPageKey = ( + pathname: string +): Exclude | WidgetPath => { const tab = resolveDashboardRouteTab(pathname); if (tab !== "manage") return tab; // A position's pages share its key: a market position, a yield position, diff --git a/packages/widget/src/app/layouts/dashboard/dashboard-tabs/index.tsx b/packages/widget/src/app/layouts/dashboard/dashboard-tabs/index.tsx index f8bf3a561..caff17a40 100644 --- a/packages/widget/src/app/layouts/dashboard/dashboard-tabs/index.tsx +++ b/packages/widget/src/app/layouts/dashboard/dashboard-tabs/index.tsx @@ -21,10 +21,12 @@ import { } from "./styles.css"; import { DashboardTab } from "./tab"; -type RouteTab = "earn" | "borrow" | "manage" | "activity"; +export type DashboardRouteTab = "earn" | "borrow" | "manage" | "activity"; /** The route tab a dashboard pathname belongs to. */ -export const resolveDashboardRouteTab = (pathname: string): RouteTab => { +export const resolveDashboardRouteTab = ( + pathname: string +): DashboardRouteTab => { if (Option.isSome(widgetRoutes.activity.matchPrefix(pathname))) { return "activity"; } @@ -37,7 +39,7 @@ export const resolveDashboardRouteTab = (pathname: string): RouteTab => { return "earn"; }; -const tabsMap: Record = { +const tabsMap: Record = { activity: widgetRoutes.activity.build(), borrow: widgetRoutes.borrow.build("Dashboard"), earn: widgetRoutes.earn.build(), @@ -58,7 +60,7 @@ export const DashboardTabs = ({ pendingYieldCategory ?? yieldSelection.selectedCategory; const selectedTab = resolveDashboardRouteTab(useLocation().pathname); - const onRouteTabPress = (selected: RouteTab) => { + const onRouteTabPress = (selected: DashboardRouteTab) => { if (selectedTab === selected) return; trackEvent("tabClicked", { selected }); navigation.push(tabsMap[selected]); diff --git a/packages/widget/src/app/runtime/deep-link-coordinator.ts b/packages/widget/src/app/runtime/deep-link-coordinator.ts index 622227d6c..939f82310 100644 --- a/packages/widget/src/app/runtime/deep-link-coordinator.ts +++ b/packages/widget/src/app/runtime/deep-link-coordinator.ts @@ -1,6 +1,10 @@ import { Context, Effect, Layer, Ref, Stream } from "effect"; import type { YieldPendingActionType } from "../../domain/action/pending-action"; -import type { YieldId } from "../../domain/identity/identifiers"; +import type { + ValidatorAddress, + YieldId, +} from "../../domain/identity/identifiers"; +import type { BalanceDataKey } from "../../domain/portfolio/positions"; import { sameWalletScopeOwner, type WalletScopeKey, @@ -24,15 +28,15 @@ type YieldFlowStartInput = Parameters< type PendingActionIntent = Readonly<{ readonly address: WalletScopeKey["address"]; readonly network: WalletScopeKey["network"]; - readonly pendingAction: string; - readonly validator: string | null; - readonly yieldId: string; + readonly pendingAction: YieldPendingActionType; + readonly validator: ValidatorAddress | null; + readonly yieldId: YieldId; }>; export type PendingActionDeepLinkObservation = | Readonly<{ readonly _tag: "OpenValidatorSelection"; - readonly balanceId: string; + readonly balanceId: BalanceDataKey; readonly intent: PendingActionIntent; readonly pendingActionType: YieldPendingActionType; readonly walletScope: WalletScopeKey; @@ -49,7 +53,7 @@ export type DeepLinkRouteObservation = Readonly<{ readonly maintenance: boolean; readonly pendingAction: PendingActionDeepLinkObservation | null; readonly position: { - readonly balanceId: string; + readonly balanceId: BalanceDataKey; readonly yieldId: YieldId; } | null; readonly ready: boolean; diff --git a/packages/widget/src/domain/action/action-command.ts b/packages/widget/src/domain/action/action-command.ts index a4c746b5b..6eed0dab2 100644 --- a/packages/widget/src/domain/action/action-command.ts +++ b/packages/widget/src/domain/action/action-command.ts @@ -64,7 +64,7 @@ export const preparePendingActionCommand = ({ pendingAction: PendingAction; yieldBalance: PendingActionBalance; integration: EarnYieldWithProvider; - selectedValidators: ValidatorDto["address"][]; + selectedValidators: ReadonlyArray; }): Result.Result => { if (!address) return Result.fail(new Error("missing address")); diff --git a/packages/widget/src/domain/action/direct-transaction.ts b/packages/widget/src/domain/action/direct-transaction.ts index f5d2cc4ab..60a77def4 100644 --- a/packages/widget/src/domain/action/direct-transaction.ts +++ b/packages/widget/src/domain/action/direct-transaction.ts @@ -1,8 +1,15 @@ +import { Schema } from "effect"; import { encodeFunctionData, erc20Abi } from "viem"; import type { Network } from "../network/network"; type Address = `0x${string}`; +/** Names a Direct Transaction for one Flow Session; no API knows it. */ +export const DirectTransactionId = Schema.NonEmptyString.pipe( + Schema.brand("DirectTransactionId") +); +export type DirectTransactionId = typeof DirectTransactionId.Type; + /** An ERC-20 allowance an approval sets: `owner` lets `spender` move `amount` of `token`. */ export type Erc20Approval = Readonly<{ readonly amount: bigint; @@ -24,7 +31,7 @@ export type DirectTransaction = Readonly<{ readonly chainId: number; readonly data: Address; /** Stable for one Flow Session, so a retry finds it again. */ - readonly id: string; + readonly id: DirectTransactionId; readonly network: Network; readonly to: Address; readonly type: "APPROVAL" | "RESET_APPROVAL" | "WRAP"; @@ -44,7 +51,7 @@ export const makeErc20Approval = ({ ...approval }: Erc20Approval & { readonly chainId: number; - readonly id: string; + readonly id: DirectTransactionId; readonly network: Network; readonly type: "APPROVAL" | "RESET_APPROVAL"; }): DirectTransaction => ({ @@ -55,7 +62,7 @@ export const makeErc20Approval = ({ args: [approval.spender, type === "APPROVAL" ? approval.amount : 0n], functionName: "approve", }), - id: type === "APPROVAL" ? id : `${id}-reset`, + id: type === "APPROVAL" ? id : DirectTransactionId.make(`${id}-reset`), network, to: approval.token, type, diff --git a/packages/widget/src/domain/action/models.ts b/packages/widget/src/domain/action/models.ts index bc6c81777..60704d567 100644 --- a/packages/widget/src/domain/action/models.ts +++ b/packages/widget/src/domain/action/models.ts @@ -1,5 +1,6 @@ import { Schema, Struct } from "effect"; import * as YieldApi from "../../generated/api/yield-schema"; +import { TolerantNullOr } from "../decoding/response-schema"; import { ExactBaseUnitAmount, ExactDecimal, @@ -9,9 +10,12 @@ import { import { ActionId, ProviderOption, + SignedTransactionPayload, + TransactionHash, TransactionId, ValidatorAddress, WalletAddress, + WalletSignature, YieldId, } from "../identity/identifiers"; import { Token } from "../token/token"; @@ -54,7 +58,6 @@ export const ActionTransaction = Schema.Struct({ "network", "status", "type", - "hash", "unsignedTransaction", "annotatedTransaction", "structuredTransaction", @@ -69,15 +72,23 @@ export const ActionTransaction = Schema.Struct({ field: "broadcastedAt", }), createdAt: UtcDateTimeFromString, + hash: TolerantNullOr(TransactionHash, { + operation: "yield-action-transaction", + field: "hash", + }), id: TransactionId, }); export type ActionTransaction = typeof ActionTransaction.Type; // Status polling and submission only read these fields; validating the rest // would let unrelated additions fail an in-flight transaction. -export const ActionTransactionReceipt = Schema.Struct( - Struct.pick(YieldApi.TransactionDto.fields, ["status", "hash", "explorerUrl"]) -); +export const ActionTransactionReceipt = Schema.Struct({ + ...Struct.pick(YieldApi.TransactionDto.fields, ["status", "explorerUrl"]), + hash: TolerantNullOr(TransactionHash, { + operation: "yield-transaction-receipt", + field: "hash", + }), +}); export type ActionTransactionReceipt = typeof ActionTransactionReceipt.Type; // `executionPattern` and `events` are unread, so they are not decoded. @@ -139,14 +150,20 @@ export const PendingAction = Schema.Struct({ export type PendingAction = typeof PendingAction.Type; export const SubmitTransactionHashCommand = Schema.Struct({ - payload: YieldApi.SubmitHashDto, + payload: Schema.Struct({ hash: TransactionHash }), transactionId: TransactionId, }); export type SubmitTransactionHashCommand = typeof SubmitTransactionHashCommand.Type; +/** A yield message is submitted as its signature, a transaction as its payload. */ export const SubmitSignedTransactionCommand = Schema.Struct({ - payload: YieldApi.SubmitTransactionDto, + payload: Schema.Struct({ + signedTransaction: Schema.Union([ + SignedTransactionPayload, + WalletSignature, + ]), + }), transactionId: TransactionId, }); export type SubmitSignedTransactionCommand = diff --git a/packages/widget/src/domain/borrow/execution/action.ts b/packages/widget/src/domain/borrow/execution/action.ts index 6d8093f1c..8ae746721 100644 --- a/packages/widget/src/domain/borrow/execution/action.ts +++ b/packages/widget/src/domain/borrow/execution/action.ts @@ -61,8 +61,22 @@ export const Action = Schema.Struct({ }); export type Action = typeof Action.Type; -export const isUnsuccessfulBorrowActionStatus = (status: string) => - status === "FAILED" || status === "CANCELED" || status === "STALE"; +/** Every action status is classified, so a new one must be placed here. */ +const borrowActionStatusOutcome = { + CREATED: "Running", + WAITING_FOR_NEXT: "Running", + PROCESSING: "Running", + SUCCESS: "Succeeded", + FAILED: "Unsuccessful", + CANCELED: "Unsuccessful", + STALE: "Unsuccessful", +} as const satisfies Record< + Action["status"], + "Running" | "Succeeded" | "Unsuccessful" +>; -export const isTerminalBorrowActionStatus = (status: string) => - status === "SUCCESS" || isUnsuccessfulBorrowActionStatus(status); +export const isUnsuccessfulBorrowActionStatus = (status: Action["status"]) => + borrowActionStatusOutcome[status] === "Unsuccessful"; + +export const isTerminalBorrowActionStatus = (status: Action["status"]) => + borrowActionStatusOutcome[status] !== "Running"; diff --git a/packages/widget/src/domain/borrow/execution/transaction.ts b/packages/widget/src/domain/borrow/execution/transaction.ts index f8d0b859e..f5bc7c7cf 100644 --- a/packages/widget/src/domain/borrow/execution/transaction.ts +++ b/packages/widget/src/domain/borrow/execution/transaction.ts @@ -1,6 +1,12 @@ import { Effect, Schema, SchemaTransformation, Struct } from "effect"; import * as BorrowApi from "../../../generated/api/borrow"; +import { TolerantNullOr } from "../../decoding/response-schema"; import { ExactBaseUnitAmount } from "../../finance/scalars"; +import { + SignedTransactionPayload, + TransactionHash, + WalletSignature, +} from "../../identity/identifiers"; import { TransactionId, WalletAddress } from "../ids"; import { BorrowNetwork } from "../network"; @@ -135,16 +141,25 @@ export const decodeBorrowTypedDataForWallet = (transaction: Transaction) => }) ); -export const SubmitTransactionCommand = Schema.Struct( - BorrowApi.SubmitTransactionDto.fields -); +/** A typed-data step is submitted as its signature, a transaction as its payload. */ +export const SubmitTransactionCommand = Schema.Struct({ + signedPayload: Schema.optionalKey( + Schema.Union([SignedTransactionPayload, WalletSignature]) + ), + transactionHash: Schema.optionalKey(TransactionHash), +}); export type SubmitTransactionCommand = typeof SubmitTransactionCommand.Type; -export const SubmitTransactionResult = Schema.Struct( - Struct.pick(BorrowApi.SubmitTransactionResponseDto.fields, [ - "transactionHash", +export const SubmitTransactionResult = Schema.Struct({ + ...Struct.pick(BorrowApi.SubmitTransactionResponseDto.fields, [ "link", "status", - ]) -); + ]), + transactionHash: Schema.optionalKey( + TolerantNullOr(TransactionHash, { + operation: "borrow-transaction-submit", + field: "transactionHash", + }) + ), +}); export type SubmitTransactionResult = typeof SubmitTransactionResult.Type; diff --git a/packages/widget/src/domain/borrow/positions/position-action-key.ts b/packages/widget/src/domain/borrow/positions/position-action-key.ts new file mode 100644 index 000000000..e183d7d0b --- /dev/null +++ b/packages/widget/src/domain/borrow/positions/position-action-key.ts @@ -0,0 +1,19 @@ +import { Schema } from "effect"; +import { TokenAddress } from "../../identity/identifiers"; + +const TokenScopedPositionActionKey = Schema.TemplateLiteral([ + Schema.Literals(["repay", "enableCollateral", "disableCollateral"]), + Schema.Literal("-"), + TokenAddress, +]); + +/** + * Identifies a configurable action a borrow market position offers: withdraw + * is one action across collateral tokens, every other action is per token. + * Not the API action id; it keys the action's route and form. + */ +export const BorrowPositionActionKey = Schema.Union([ + Schema.Literal("withdraw"), + TokenScopedPositionActionKey, +]); +export type BorrowPositionActionKey = typeof BorrowPositionActionKey.Type; diff --git a/packages/widget/src/domain/earn/confidential.ts b/packages/widget/src/domain/earn/confidential.ts index 0db075f48..54305b79e 100644 --- a/packages/widget/src/domain/earn/confidential.ts +++ b/packages/widget/src/domain/earn/confidential.ts @@ -1,20 +1,25 @@ import type BigNumber from "bignumber.js"; -import { - Data, - type HashMap, - Option, - Schema, - SchemaTransformation, - Struct, -} from "effect"; +import { Data, type HashMap, Option, Schema, Struct } from "effect"; import type { YieldAction } from "../action/models"; import type { YieldPendingActionType } from "../action/pending-action"; -import { exactDecimal, truncateToTokenDecimals } from "../finance/exact"; +import { + exactDecimal, + exactZero, + truncateToTokenDecimals, +} from "../finance/exact"; import type { Network } from "../network/network"; import type { Token } from "../token/token"; import { getWalletRoutingId } from "../wallet/network"; import type { WalletScopeOwner } from "../wallet/wallet-scope"; -import type { EarnBalance, EarnYieldWithProvider } from "./models"; +import { + type BalanceShares, + ConfidentialHandle, + type EarnBalance, + type EarnBalanceShares, + type EarnYieldWithProvider, + getEncryptedHandle, + getVisibleShares, +} from "./models"; import { getYieldActionArg } from "./yield"; type ConfidentialIntent = "enter" | "exit"; @@ -241,11 +246,11 @@ export const planConfidentialEncryption = ({ export type ConfidentialEncryption = Readonly<{ readonly amount: Readonly<{ - readonly handle: string; + readonly handle: ConfidentialHandle; readonly proof: string; }> | null; readonly legs: Readonly<{ - readonly handles: ReadonlyArray; + readonly handles: ReadonlyArray; readonly proof: string; }> | null; }>; @@ -292,16 +297,6 @@ export const toBaseUnits = (amount: BigNumber, decimals: number): bigint => truncateToTokenDecimals(amount, decimals).shiftedBy(decimals).toFixed(0) ); -export const ConfidentialHandle = Schema.String.pipe( - Schema.decode(SchemaTransformation.toLowerCase()), - Schema.decodeTo( - Schema.String.check(Schema.isPattern(/^0x[0-9a-f]{64}$/)).pipe( - Schema.brand("ConfidentialHandle") - ) - ) -); -export type ConfidentialHandle = typeof ConfidentialHandle.Type; - /** A token balance its owner revealed; EVM addresses compare lowercased. */ export class ConfidentialTokenBalanceKey extends Data.Class<{ readonly network: Network; @@ -344,31 +339,17 @@ const decodeHandle = (value: string | null | undefined) => ? Option.getOrNull(Schema.decodeOption(ConfidentialHandle)(value)) : null; -// The API puts an encrypted balance's handle in `shareAmountRaw`. -export const getEncryptedHandle = ( - balance: Readonly<{ - readonly isEncrypted?: boolean; - readonly shareAmountRaw?: string; - }> -): ConfidentialHandle | null => - balance.isEncrypted ? decodeHandle(balance.shareAmountRaw) : null; - /** * Whether a balance's pending position, hidden or revealed, has a cancel in * flight. */ export const isCancellingBalance = ( - balance: Readonly<{ - readonly isEncrypted?: boolean; - readonly revealedHandle?: string; - readonly shareAmountRaw?: string; - }>, + balance: BalanceShares, cancellingHandles: ReadonlySet -): boolean => { - const handle = - getEncryptedHandle(balance) ?? decodeHandle(balance.revealedHandle); - return handle !== null && cancellingHandles.has(handle); -}; +): boolean => + (balance.shares?._tag === "Encrypted" || + balance.shares?._tag === "Revealed") && + cancellingHandles.has(balance.shares.handle); /** * Pending positions with a cancel broadcast but not yet confirmed; the cancel @@ -394,9 +375,7 @@ type ConfidentialBalance = Readonly<{ readonly amountRaw: bigint; readonly amountUsd?: BigNumber | null; readonly isEncrypted?: boolean; - readonly revealedHandle?: string; - readonly shareAmount?: string; - readonly shareAmountRaw?: string; + readonly shares?: EarnBalanceShares; readonly shareToken?: Pick; readonly token: Pick; }>; @@ -406,13 +385,11 @@ const getConfidentialPricePerShare = ( shareToken: Pick ): BigNumber => { const state = yieldDto.state?.pricePerShareState; - const price = state ? exactDecimal(state.price) : null; - return price?.isFinite() && - price.isGreaterThan(0) && - state?.shareToken.address && + return state?.price.isGreaterThan(0) && + state.shareToken.address && shareToken.address && sameAddress(state.shareToken.address, shareToken.address) - ? price + ? state.price : exactDecimal(1); }; @@ -465,7 +442,7 @@ export const isUnfinishedInstantExit = ( pendingActionType === "WITHDRAW"; export const getPendingActionDecryptHandle = ( - balance: Pick, + balance: Pick, pendingActionType: YieldPendingActionType ): ConfidentialHandle | null => isUnfinishedInstantExit(balance, pendingActionType) @@ -518,7 +495,7 @@ export const getCancelReturn = ( pendingActionType: YieldPendingActionType, balance: Readonly<{ readonly amount: BigNumber; - readonly shareAmount?: string; + readonly shares?: EarnBalanceShares; readonly shareToken?: Token; readonly type: BalanceType; }> @@ -528,7 +505,7 @@ export const getCancelReturn = ( // A withdrawal is valued in the underlying but holds vault shares. const amount = balance.type === "exiting" - ? exactDecimal(balance.shareAmount || 0) + ? (getVisibleShares(balance)?.amount ?? exactZero()) : balance.amount; return { amount, token }; }; @@ -557,7 +534,7 @@ export const getBalanceRowDisplayAmount = ( type ConfidentialExitSource = Readonly<{ readonly amount: BigNumber; readonly amountUsd?: BigNumber | null; - readonly shareAmount?: string; + readonly shares?: EarnBalanceShares; readonly shareToken?: Token; readonly token: Token; }>; @@ -597,7 +574,7 @@ export const getConfidentialExitBalance = ( } return activeBalances.reduce( (sum, balance) => ({ - amount: sum.amount.plus(exactDecimal(balance.shareAmount || 0)), + amount: sum.amount.plus(getVisibleShares(balance)?.amount ?? exactZero()), amountUsd: sum.amountUsd.plus(balance.amountUsd ?? 0), token: shareToken, underlying: { @@ -614,21 +591,35 @@ export const getConfidentialExitBalance = ( ); }; -// The API sends empty amounts for encrypted balances. +// The API sends empty amounts for encrypted balances; their shares are a handle. export const zeroEncryptedBalance = < Balance extends Readonly<{ readonly isEncrypted?: boolean }>, >( balance: Balance ): Balance => - balance.isEncrypted - ? { ...balance, amount: "0", amountRaw: "0", shareAmount: "0" } - : balance; + balance.isEncrypted ? { ...balance, amount: "0", amountRaw: "0" } : balance; + +/** + * An encrypted balance after its handle is revealed: no longer encrypted, + * valued from the decrypted share count, and still carrying the handle so a + * cancel of its pending position stays recognisable. + */ +export type RevealedBalance = Omit< + Balance, + "amount" | "amountRaw" | "amountUsd" | "isEncrypted" | "shares" +> & + Readonly<{ + readonly amount: BigNumber; + readonly amountRaw: bigint; + readonly amountUsd?: BigNumber | null; + readonly shares: typeof EarnBalanceShares.cases.Revealed.Type; + }>; export const fillConfidentialBalance = ( balance: Balance, handles: RevealedHandles, valuation: ConfidentialValuation | null = null -): Balance => { +): Balance | RevealedBalance => { const handle = getEncryptedHandle(balance); if (handle === null) return balance; const revealed = handles.get(handle); @@ -646,17 +637,27 @@ export const fillConfidentialBalance = ( shareAmount.times(pricePerShare), balance.token.decimals ); + const amountUsd = valuation?.usdPrice + ? amount.times(valuation.usdPrice) + : balance.amountUsd; return { - ...Struct.omit(balance, ["isEncrypted"]), + ...Struct.omit(balance, [ + "amount", + "amountRaw", + "amountUsd", + "isEncrypted", + "shares", + ]), amount, - amountRaw: BigInt(amount.shiftedBy(balance.token.decimals).toFixed(0)), - ...(valuation?.usdPrice - ? { amountUsd: amount.times(valuation.usdPrice) } - : {}), - revealedHandle: handle as string, - shareAmount: shareAmount.toFixed(), - shareAmountRaw: revealed.toString(), - } as Balance; + amountRaw: toBaseUnits(amount, balance.token.decimals), + ...(amountUsd === undefined ? {} : { amountUsd }), + shares: { + _tag: "Revealed", + amount: shareAmount, + amountRaw: revealed, + handle, + }, + }; }; export const fillConfidentialPosition = < @@ -666,7 +667,7 @@ export const fillConfidentialPosition = < position: Position, handles: RevealedHandles, valuation: ConfidentialValuation | null = null -): Position => ({ +) => ({ ...position, balances: position.balances.map((balance) => fillConfidentialBalance(balance, handles, valuation) diff --git a/packages/widget/src/domain/earn/models.ts b/packages/widget/src/domain/earn/models.ts index 5098860b8..839c08b9a 100644 --- a/packages/widget/src/domain/earn/models.ts +++ b/packages/widget/src/domain/earn/models.ts @@ -3,15 +3,16 @@ import { Option, Predicate, Schema, + type SchemaAST, SchemaGetter, SchemaParser, + SchemaTransformation, Struct, } from "effect"; import * as YieldApi from "../../generated/api/yield-schema"; import { PendingAction } from "../action/models"; import { TronResource } from "../action/tron-resource"; import { TolerantArray, TolerantNullOr } from "../decoding/response-schema"; -import { exactDecimal } from "../finance/exact"; import { ExactBaseUnitAmount, ExactDecimal, @@ -27,6 +28,13 @@ import { } from "../identity/identifiers"; import { Token } from "../token/token"; +/** + * An optional decimal the widget reads: a malformed value becomes `null` so + * only that field is lost, never the model holding it. + */ +const TolerantExactDecimal = (operation: string, field: string) => + TolerantNullOr(ExactDecimal, { operation, field }); + // `yieldSource` stays open: reward-rate breakdowns already fall back for // sources they do not group. const EarnReward = Schema.Struct({ @@ -122,18 +130,12 @@ const hasCoherentAmountBounds = ( }; const normalizeAmountMaximum = ( - minimumValue: string | number, - maximumValue: string | number | null -) => { - const minimum = exactDecimal(minimumValue); - const maximum = maximumValue === null ? null : exactDecimal(maximumValue); - - return maximum !== null && - minimum.isGreaterThanOrEqualTo(0) && - maximum.isEqualTo(-1) + minimum: ExactDecimal, + maximum: ExactDecimal | null +) => + maximum !== null && minimum.isGreaterThanOrEqualTo(0) && maximum.isEqualTo(-1) ? null - : maximumValue; -}; + : maximum; const decodeApiArgument = < const Name extends ApiArgumentName, @@ -161,10 +163,15 @@ const decodeApiArgument = < }) ); -const AmountArgumentDomain = Schema.Struct({ - required: Schema.Boolean, +const AmountArgumentBounds = Schema.Struct({ minimum: ExactDecimal, maximum: Schema.NullOr(ExactDecimal), +}); + +const AmountArgumentDomain = Schema.Struct({ + required: Schema.Boolean, + minimum: Schema.toType(ExactDecimal), + maximum: Schema.NullOr(Schema.toType(ExactDecimal)), }).check( Schema.makeFilter((field) => hasCoherentAmountBounds(field.minimum, field.maximum) @@ -177,16 +184,20 @@ const AmountArgument = decodeApiArgument( "amount", "string", AmountArgumentDomain, - SchemaGetter.transform( - (field) => { - const minimum = field.minimum ?? "0"; - - return { - maximum: normalizeAmountMaximum(minimum, field.maximum ?? null), + SchemaGetter.transformEffect< + typeof AmountArgumentDomain.Encoded, + ApiArgumentField + >((field, options) => + SchemaParser.decodeEffect(AmountArgumentBounds)( + { maximum: field.maximum ?? null, minimum: field.minimum ?? "0" }, + options + ).pipe( + Effect.map(({ maximum, minimum }) => ({ + maximum: normalizeAmountMaximum(minimum, maximum), minimum, required: field.required ?? false, - }; - } + })) + ) ) ); @@ -395,13 +406,9 @@ const EarnYieldArguments = Schema.Struct({ // Provider `type` is not picked, so new provider kinds stay decodable. export const EarnProvider = Schema.Struct({ - ...Struct.pick(YieldApi.ProviderDto.fields, [ - "name", - "logoURI", - "website", - "tvlUsd", - ]), + ...Struct.pick(YieldApi.ProviderDto.fields, ["name", "logoURI", "website"]), id: ProviderId, + tvlUsd: TolerantExactDecimal("yield-provider", "tvlUsd"), }); export type EarnProvider = typeof EarnProvider.Type; @@ -412,14 +419,13 @@ export type EarnValidatorKey = typeof EarnValidatorKey.Type; const makeEarnValidatorKey = Schema.decodeSync(EarnValidatorKey); +// `tvlRaw` is not decoded: TVL is displayed in token units from `tvl`. const EarnValidatorWire = Schema.Struct({ ...Struct.pick(YieldApi.ValidatorDto.fields, [ "name", "logoURI", "website", "commission", - "tvl", - "tvlRaw", "votingPower", "preferred", "nominatorCount", @@ -429,6 +435,7 @@ const EarnValidatorWire = Schema.Struct({ address: ValidatorAddress, providerId: Schema.optionalKey(ProviderId), rewardRate: Schema.optionalKey(EarnRewardRate), + tvl: Schema.optionalKey(TolerantExactDecimal("yield-validator", "tvl")), }); export const EarnValidator = EarnValidatorWire.pipe( @@ -463,9 +470,55 @@ const EarnYieldRequirements = Schema.Struct({ ), }); -const EarnYieldState = YieldApi.YieldStateDto.mapFields( - Struct.pick(["pricePerShareState"]) -); +const EarnPricePerShareState = Schema.Struct({ + price: ExactDecimal, + shareToken: Token, +}); + +// A malformed price-per-share state only drops share pricing. +const EarnYieldState = Schema.Struct({ + pricePerShareState: Schema.optionalKey( + TolerantNullOr(EarnPricePerShareState, { + operation: "yield-state", + field: "pricePerShareState", + }) + ), +}); + +// Only the statistics the widget reads are decoded; `tvl`/`tvlRaw` are unread. +const EarnYieldStatistics = Schema.Struct({ + ...Struct.pick(YieldApi.YieldStatisticsDto.fields, ["uniqueUsers"]), + averagePositionSizeUsd: Schema.optionalKey( + TolerantExactDecimal("yield-statistics", "averagePositionSizeUsd") + ), + tvlUsd: Schema.optionalKey( + TolerantExactDecimal("yield-statistics", "tvlUsd") + ), +}); + +// Fees are percentages; a malformed one drops only itself. +const EarnYieldFee = Schema.Struct({ + deposit: Schema.optionalKey(TolerantExactDecimal("yield-fee", "deposit")), + management: Schema.optionalKey( + TolerantExactDecimal("yield-fee", "management") + ), + performance: Schema.optionalKey( + TolerantExactDecimal("yield-fee", "performance") + ), + withdrawal: Schema.optionalKey( + TolerantExactDecimal("yield-fee", "withdrawal") + ), +}); + +// Token-unit limits; a malformed limit reads as no limit. +const EarnYieldEntryLimits = Schema.Struct({ + maximum: TolerantExactDecimal("yield-entry-limits", "maximum"), + minimum: TolerantExactDecimal("yield-entry-limits", "minimum"), + subsequentMinimum: TolerantExactDecimal( + "yield-entry-limits", + "subsequentMinimum" + ), +}); const EarnYieldRisk = Schema.Struct({ ratings: Schema.Array( @@ -506,7 +559,8 @@ const EarnInvestmentSchedule = Schema.Struct({ export type EarnInvestmentSchedule = typeof EarnInvestmentSchedule.Type; export const EarnYield = Schema.Struct({ - ...Struct.pick(YieldApi.YieldDto.fields, ["network", "statistics", "status"]), + ...Struct.pick(YieldApi.YieldDto.fields, ["network", "status"]), + statistics: Schema.optionalKey(EarnYieldStatistics), // Malformed contracts leave the yield non-confidential. executionContracts: Schema.optionalKey( TolerantNullOr(YieldApi.ExecutionContractsDto, { @@ -533,9 +587,9 @@ export const EarnYield = Schema.Struct({ "lockupPeriod", "cooldownPeriod", "warmupPeriod", - "fee", - "entryLimits", ]), + entryLimits: Schema.optionalKey(EarnYieldEntryLimits), + fee: Schema.optionalKey(EarnYieldFee), arguments: Schema.optionalKey(EarnYieldArguments), gasFeeToken: Token, requirements: Schema.optionalKey(EarnYieldRequirements), @@ -568,14 +622,74 @@ const OptionalKeyFromNullOr = (schema: S) => }) ); -export const EarnBalance = Schema.Struct({ +/** The ciphertext handle a confidential balance's shares are hidden behind. */ +export const ConfidentialHandle = Schema.String.pipe( + Schema.decode(SchemaTransformation.toLowerCase()), + Schema.decodeTo( + Schema.String.check(Schema.isPattern(/^0x[0-9a-f]{64}$/)).pipe( + Schema.brand("ConfidentialHandle") + ) + ) +); +export type ConfidentialHandle = typeof ConfidentialHandle.Type; + +/** + * A balance's shares. The API sends an encrypted balance's handle where a + * plain balance has its base-unit share count; a revealed balance holds the + * decrypted count beside the handle it was decrypted from. + */ +export const EarnBalanceShares = Schema.TaggedUnion({ + Plain: { + amount: Schema.optionalKey(Schema.toType(ExactDecimal)), + amountRaw: Schema.optionalKey(Schema.BigInt), + }, + Encrypted: { handle: Schema.toType(ConfidentialHandle) }, + Revealed: { + amount: Schema.toType(ExactDecimal), + amountRaw: Schema.BigInt, + handle: Schema.toType(ConfidentialHandle), + }, +}); +export type EarnBalanceShares = typeof EarnBalanceShares.Type; + +export type BalanceShares = Readonly<{ + readonly shares?: EarnBalanceShares; +}>; + +/** The handle a still-encrypted balance's shares are hidden behind. */ +export const getEncryptedHandle = ( + balance: BalanceShares +): ConfidentialHandle | null => + balance.shares?._tag === "Encrypted" ? balance.shares.handle : null; + +/** The share count a balance shows: plain from the API, or revealed. */ +export const getVisibleShares = (balance: BalanceShares) => + balance.shares === undefined || balance.shares._tag === "Encrypted" + ? null + : balance.shares; + +const PlainShareAmountRaw = TolerantNullOr(ExactBaseUnitAmount, { + operation: "yield-balance", + field: "shareAmountRaw", +}); + +const EncryptedShareHandle = TolerantNullOr(ConfidentialHandle, { + operation: "yield-balance", + field: "shareAmountRaw", +}); + +const EarnBalanceWire = Schema.Struct({ ...Struct.pick(YieldApi.BalanceDto.fields, [ "address", "type", "isEncrypted", - "shareAmount", - "shareAmountRaw", ]), + shareAmount: Schema.optionalKey( + TolerantExactDecimal("yield-balance", "shareAmount") + ), + // Its meaning depends on `isEncrypted`, so the tolerant decoders below + // check it; any wire value reaches them. + shareAmountRaw: Schema.optionalKey(Schema.Unknown), shareToken: Schema.optionalKey(Token), amount: ExactDecimal, amountRaw: ExactBaseUnitAmount, @@ -595,13 +709,68 @@ export const EarnBalance = Schema.Struct({ TolerantArray(EarnValidator, { operation: "balance-validators" }) ), }); -/** - * `revealedHandle` is set by the widget, never by the API: the handle a - * revealed balance was decrypted from, which its `shareAmountRaw` no longer holds. - */ -export type EarnBalance = typeof EarnBalance.Type & { - readonly revealedHandle?: string; -}; + +const EarnBalanceDomain = Schema.Struct({ + ...Struct.map( + Struct.omit(EarnBalanceWire.fields, ["shareAmount", "shareAmountRaw"]), + Schema.toType + ), + shares: Schema.optionalKey(EarnBalanceShares), +}); + +type EarnBalanceWire = typeof EarnBalanceWire.Type; + +const decodeBalanceShares = ( + { + isEncrypted, + shareAmount, + shareAmountRaw, + }: Pick, + options: SchemaAST.ParseOptions +) => + isEncrypted + ? SchemaParser.decodeEffect(EncryptedShareHandle)( + shareAmountRaw ?? null, + options + ).pipe( + Effect.map((handle): EarnBalanceShares | null => + handle === null ? null : { _tag: "Encrypted", handle } + ) + ) + : SchemaParser.decodeEffect(PlainShareAmountRaw)( + shareAmountRaw ?? null, + options + ).pipe( + Effect.map((amountRaw): EarnBalanceShares | null => + amountRaw === null && shareAmount == null + ? null + : { + _tag: "Plain", + ...(shareAmount == null ? {} : { amount: shareAmount }), + ...(amountRaw === null ? {} : { amountRaw }), + } + ) + ); + +export const EarnBalance = EarnBalanceWire.pipe( + Schema.decodeTo(EarnBalanceDomain, { + decode: SchemaGetter.transformEffect( + ({ shareAmount, shareAmountRaw, ...balance }, options) => + decodeBalanceShares( + { isEncrypted: balance.isEncrypted, shareAmount, shareAmountRaw }, + options + ).pipe( + Effect.map((shares) => + shares === null ? balance : { ...balance, shares } + ) + ) + ), + encode: SchemaGetter.forbidden( + () => "Decoded Earn balances are decode-only" + ), + }) +); +export type EarnBalance = typeof EarnBalance.Type; export const EarnPosition = Schema.Struct({ yieldId: YieldId, diff --git a/packages/widget/src/domain/earn/reward-rate.ts b/packages/widget/src/domain/earn/reward-rate.ts index 8db51c674..c142d1e7e 100644 --- a/packages/widget/src/domain/earn/reward-rate.ts +++ b/packages/widget/src/domain/earn/reward-rate.ts @@ -1,8 +1,11 @@ import type BigNumber from "bignumber.js"; import { Array as EArray, Match } from "effect"; import { exactZero } from "../finance/exact"; -import type { EarnValidator, EarnYieldWithProvider } from "./models"; -import type { ValidatorKey } from "./validator"; +import type { + EarnValidator, + EarnValidatorKey, + EarnYieldWithProvider, +} from "./models"; export type YieldRewardRate = NonNullable; type YieldRewardDto = YieldRewardRate["components"][number]; @@ -10,13 +13,13 @@ type YieldWithRewardRate = Pick; type ValidatorRewardRateDto = NonNullable; export type SelectedValidators = | ReadonlyArray - | ReadonlyMap; + | ReadonlyMap; type RewardRateBreakdownKey = "native" | "protocol_incentive" | "campaign"; const isSelectedValidatorMap = ( validators: SelectedValidators -): validators is ReadonlyMap => +): validators is ReadonlyMap => validators instanceof Map; const getSelectedValidators = ( diff --git a/packages/widget/src/domain/earn/stake.ts b/packages/widget/src/domain/earn/stake.ts index cb06c2ef9..c09a4eb03 100644 --- a/packages/widget/src/domain/earn/stake.ts +++ b/packages/widget/src/domain/earn/stake.ts @@ -34,16 +34,12 @@ export const getMaxAmount = ({ export const isForceMaxAmount = ( args: | { - readonly minimum?: string | number | BigNumber | null; - readonly maximum?: string | number | BigNumber | null; + readonly minimum?: BigNumber | null; + readonly maximum?: BigNumber | null; } | null | undefined -) => - args?.minimum != null && - args?.maximum != null && - exactDecimal(args.minimum).isEqualTo(-1) && - exactDecimal(args.maximum).isEqualTo(-1); +) => (args?.minimum?.isEqualTo(-1) && args.maximum?.isEqualTo(-1)) === true; export const isYieldActionAmountEditable = ( yieldDto: EarnYieldWithProvider, @@ -75,7 +71,7 @@ export const getEnterAmountConstraint = ( return { type: "force-max" }; } - const maximum = exactDecimal(amountArgument?.maximum ?? 0); + const maximum = amountArgument?.maximum ?? exactZero(); return { maximum: maximum.isGreaterThan(0) ? maximum : null, @@ -103,18 +99,17 @@ export const getMinStakeAmount = ( selectedYieldHasActivePosition: boolean ) => { const entryLimits = yieldDto.mechanics.entryLimits; - const firstEntryMin = exactDecimal( + const firstEntryMin = entryLimits?.minimum ?? - getYieldActionArg(yieldDto, "enter", "amount")?.minimum ?? - 0 - ); + getYieldActionArg(yieldDto, "enter", "amount")?.minimum ?? + exactZero(); if (!selectedYieldHasActivePosition) { return firstEntryMin; } if (entryLimits?.subsequentMinimum != null) { - return exactDecimal(entryLimits.subsequentMinimum); + return entryLimits.subsequentMinimum; } if (isYieldWithEnterMinBasedOnPosition(yieldDto)) { @@ -126,17 +121,18 @@ export const getMinStakeAmount = ( export const getMinUnstakeAmount = ( yieldDto: EarnYieldWithProvider, - pricePerShare: string | null + pricePerShare: BigNumber | null ) => { - const integrationMin = exactDecimal( - getYieldActionArg(yieldDto, "exit", "amount")?.minimum ?? 0 - ); - - const pricePerShareBN = exactDecimal(pricePerShare ?? 0); - - if (pricePerShareBN.isZero() || !isBittensorStaking(yieldDto.id)) { + const integrationMin = + getYieldActionArg(yieldDto, "exit", "amount")?.minimum ?? exactZero(); + + if ( + !pricePerShare || + pricePerShare.isZero() || + !isBittensorStaking(yieldDto.id) + ) { return integrationMin; } - return integrationMin.dividedBy(pricePerShareBN).decimalPlaces(16); + return integrationMin.dividedBy(pricePerShare).decimalPlaces(16); }; diff --git a/packages/widget/src/domain/earn/validator.ts b/packages/widget/src/domain/earn/validator.ts index 19ff6a8a0..defcee465 100644 --- a/packages/widget/src/domain/earn/validator.ts +++ b/packages/widget/src/domain/earn/validator.ts @@ -2,7 +2,6 @@ import { addressIdentity } from "../identity/identifiers"; import type { Network } from "../network/network"; import type { EarnValidator } from "./models"; -export type ValidatorKey = string; export type ValidatorInput = Omit; export const validatorAddressIdentity = addressIdentity; diff --git a/packages/widget/src/domain/earn/yield.ts b/packages/widget/src/domain/earn/yield.ts index 0e81aeebe..3cca050d2 100644 --- a/packages/widget/src/domain/earn/yield.ts +++ b/packages/widget/src/domain/earn/yield.ts @@ -1,6 +1,7 @@ +import type BigNumber from "bignumber.js"; import { Array as EArray, pipe } from "effect"; import type { TFunction } from "i18next"; -import { exactDecimal } from "../finance/exact"; +import { sumExact } from "../finance/exact"; import type { ProviderOption, YieldId } from "../identity/identifiers"; import type { Network } from "../network/network"; import { equalTokens, type HostTokenKey, tokenString } from "../token/token"; @@ -196,15 +197,6 @@ export const filterValidators = ({ return filtered; }; -const toNumber = (value: number | string | null | undefined) => { - if (value === null || value === undefined) { - return undefined; - } - - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : undefined; -}; - const secondsToDays = (seconds: number | undefined) => { if (seconds === undefined) return undefined; @@ -281,7 +273,7 @@ export const getYieldRiskSourceLabel = ( export const hasYieldFeeConfigurationEnabled = ( yieldDto: EarnYieldWithProvider -) => Object.values(yieldDto.mechanics.fee ?? {}).some(Boolean); +) => Object.values(yieldDto.mechanics.fee ?? {}).some((fee) => fee != null); export const getYieldCooldownPeriod = (yieldDto: EarnYieldWithProvider) => secondsToDays(yieldDto.mechanics.cooldownPeriod?.seconds); @@ -289,30 +281,22 @@ export const getYieldCooldownPeriod = (yieldDto: EarnYieldWithProvider) => export const getYieldWarmupPeriod = (yieldDto: EarnYieldWithProvider) => secondsToDays(yieldDto.mechanics.warmupPeriod?.seconds); -export const getYieldTvlUsd = (yieldDto: EarnYieldWithProvider) => { - const tvlUsd = yieldDto.statistics?.tvlUsd; - - if (tvlUsd == null || tvlUsd === "") return null; - - return tvlUsd; -}; +export const getYieldTvlUsd = (yieldDto: EarnYieldWithProvider) => + yieldDto.statistics?.tvlUsd ?? null; +/** + * The yield's total fee as a decimal ratio (0.02 = 2%), summed exactly from + * the API's percentages; null when it charges none. + */ export const getYieldFeePercent = ( yieldDto: EarnYieldWithProvider -): number | null => { - const fee = yieldDto.mechanics.fee; - - if (!fee) return null; - - const total = Object.values(fee).reduce((acc, value) => { - const parsed = toNumber(value); - - return parsed !== undefined ? acc + parsed : acc; - }, 0); - - if (total <= 0) return null; +): BigNumber | null => { + const fees = Object.values(yieldDto.mechanics.fee ?? {}).filter( + (fee) => fee != null + ); + const total = sumExact(fees); - return total / 100; + return total.isGreaterThan(0) ? total.dividedBy(100) : null; }; export const getYieldLockupPeriod = (yieldDto: EarnYieldWithProvider) => @@ -344,15 +328,8 @@ export const getYieldOutputToken = (yieldDto: EarnYieldWithProvider) => ? yieldDto.outputToken : null; -const hasPositivePricePerShare = (yieldDto: EarnYieldWithProvider) => { - const price = yieldDto.state?.pricePerShareState?.price; - - if (price === null || price === undefined) return false; - - const amount = exactDecimal(price); - - return amount.isFinite() && amount.isGreaterThan(0); -}; +const hasPositivePricePerShare = (yieldDto: EarnYieldWithProvider) => + yieldDto.state?.pricePerShareState?.price.isGreaterThan(0) ?? false; export const hasYieldBearingOutputToken = (yieldDto: EarnYieldWithProvider) => getYieldOutputToken(yieldDto) !== null && hasPositivePricePerShare(yieldDto); @@ -487,11 +464,10 @@ const isNativeStaking = (yieldDto: EarnYieldWithProvider) => { return false; } - const minimum = getYieldActionArg(yieldDto, "enter", "amount")?.minimum; - - return minimum !== null && minimum !== undefined - ? exactDecimal(minimum).isEqualTo(32) - : false; + return ( + getYieldActionArg(yieldDto, "enter", "amount")?.minimum.isEqualTo(32) ?? + false + ); }; const isPooledStaking = (yieldDto: EarnYieldWithProvider) => diff --git a/packages/widget/src/domain/finance/funded-balance.ts b/packages/widget/src/domain/finance/funded-balance.ts index 6adec6117..d17e517ea 100644 --- a/packages/widget/src/domain/finance/funded-balance.ts +++ b/packages/widget/src/domain/finance/funded-balance.ts @@ -1,5 +1,4 @@ import type BigNumber from "bignumber.js"; -import { exactDecimal } from "./exact"; const minimumFundedUsdValue = 0.01; const minimumFundedUnitAmount = 0.000001; @@ -13,13 +12,12 @@ export const isFundedBalance = ({ amount, priceUsd, }: { - readonly amount: string | BigNumber; + readonly amount: BigNumber; readonly priceUsd: BigNumber | null; }): boolean => { - const exactAmount = exactDecimal(amount); - if (!exactAmount.isGreaterThan(0)) return false; + if (!amount.isGreaterThan(0)) return false; return priceUsd?.isGreaterThan(0) - ? exactAmount.times(priceUsd).isGreaterThanOrEqualTo(minimumFundedUsdValue) - : exactAmount.isGreaterThanOrEqualTo(minimumFundedUnitAmount); + ? amount.times(priceUsd).isGreaterThanOrEqualTo(minimumFundedUsdValue) + : amount.isGreaterThanOrEqualTo(minimumFundedUnitAmount); }; diff --git a/packages/widget/src/domain/finance/price.ts b/packages/widget/src/domain/finance/price.ts index b129f7ba7..81c291ba0 100644 --- a/packages/widget/src/domain/finance/price.ts +++ b/packages/widget/src/domain/finance/price.ts @@ -1,6 +1,6 @@ import type BigNumber from "bignumber.js"; import type { Prices } from "../health/models"; -import { exactDecimal } from "./exact"; +import { exactZero } from "./exact"; type PriceLookupToken = { readonly symbol: string; @@ -17,22 +17,17 @@ export const getTokenPriceInUSD = ({ }: { token: PriceLookupToken; baseToken: PriceLookupToken | null; - amount: string | BigNumber; - pricePerShare: string | null; + amount: BigNumber; + pricePerShare: BigNumber | null; prices: Prices; }): BigNumber => { - const amountBN = exactDecimal(amount); - if (pricePerShare && baseToken) { - const baseTokenPrice = exactDecimal( - prices.getByToken(baseToken)?.price ?? 0 - ); - const pricePerShareBN = exactDecimal(pricePerShare); + const baseTokenPrice = prices.getByToken(baseToken)?.price ?? exactZero(); - return amountBN.times(baseTokenPrice).times(pricePerShareBN); + return amount.times(baseTokenPrice).times(pricePerShare); } - const tokenPrice = exactDecimal(prices.getByToken(token)?.price ?? 0); + const tokenPrice = prices.getByToken(token)?.price ?? exactZero(); - return amountBN.times(tokenPrice); + return amount.times(tokenPrice); }; diff --git a/packages/widget/src/domain/identity/identifiers.ts b/packages/widget/src/domain/identity/identifiers.ts index 360ca6f7c..2280e2008 100644 --- a/packages/widget/src/domain/identity/identifiers.ts +++ b/packages/widget/src/domain/identity/identifiers.ts @@ -27,6 +27,24 @@ export const TransactionWorkflowId = Schema.NonEmptyString.pipe( ); export type TransactionWorkflowId = typeof TransactionWorkflowId.Type; +/** A transaction's hash on its chain, in that chain's own encoding. */ +export const TransactionHash = Schema.NonEmptyString.pipe( + Schema.brand("TransactionHash") +); +export type TransactionHash = typeof TransactionHash.Type; + +/** A signature a wallet produced over a message or typed data. */ +export const WalletSignature = Schema.NonEmptyString.pipe( + Schema.brand("WalletSignature") +); +export type WalletSignature = typeof WalletSignature.Type; + +/** A transaction a wallet signed but did not broadcast, in its chain's encoding. */ +export const SignedTransactionPayload = Schema.NonEmptyString.pipe( + Schema.brand("SignedTransactionPayload") +); +export type SignedTransactionPayload = typeof SignedTransactionPayload.Type; + export const WalletAddress = Schema.NonEmptyString.pipe( Schema.brand("WalletAddress") ); diff --git a/packages/widget/src/domain/portfolio/models.ts b/packages/widget/src/domain/portfolio/models.ts index c063ab8e3..75bf002ed 100644 --- a/packages/widget/src/domain/portfolio/models.ts +++ b/packages/widget/src/domain/portfolio/models.ts @@ -1,5 +1,4 @@ import { Schema, Struct } from "effect"; -import * as LegacyApi from "../../generated/api/legacy-schema"; import * as YieldApi from "../../generated/api/yield-schema"; import { TolerantArray, TolerantRecord } from "../decoding/response-schema"; import { ExactDecimal, UtcDateTimeFromString } from "../finance/scalars"; @@ -27,10 +26,10 @@ const RewardRateHistoryItem = Schema.Struct({ }); export type RewardRateHistoryItem = typeof RewardRateHistoryItem.Type; +// `tvlRaw` is unread: the chart plots `tvl` in token units. const TvlHistoryItem = Schema.Struct({ timestamp: UtcDateTimeFromString, tvl: ExactDecimal, - tvlRaw: Schema.String, }); export type TvlHistoryItem = typeof TvlHistoryItem.Type; @@ -54,8 +53,9 @@ export const TvlHistoryResponse = Schema.Struct({ }), }); +// Only the reward total is read. export const RewardsSummary = Schema.Struct({ - ...Struct.pick(LegacyApi.YieldRewardsSummaryResponseDto.fields, ["rewards"]), + rewards: Schema.Struct({ total: ExactDecimal }), token: LegacyToken, }); export type RewardsSummary = typeof RewardsSummary.Type; diff --git a/packages/widget/src/domain/portfolio/positions.ts b/packages/widget/src/domain/portfolio/positions.ts index bd2cb64fe..b5fd363ee 100644 --- a/packages/widget/src/domain/portfolio/positions.ts +++ b/packages/widget/src/domain/portfolio/positions.ts @@ -1,8 +1,13 @@ import type BigNumber from "bignumber.js"; -import { DateTime } from "effect"; -import type { EarnBalance, EarnPosition, EarnValidator } from "../earn/models"; +import { DateTime, Schema } from "effect"; +import { + type EarnBalance, + type EarnPosition, + type EarnValidator, + EarnValidatorKey, + getVisibleShares, +} from "../earn/models"; import type { YieldRewardRate } from "../earn/reward-rate"; -import type { ValidatorKey } from "../earn/validator"; import { exactDecimal, exactZero } from "../finance/exact"; import type { YieldId } from "../identity/identifiers"; import { equalTokens, type TokenString, tokenString } from "../token/token"; @@ -19,9 +24,28 @@ export type PositionValidators = ReadonlyArray; export type PositionDetailsLabelType = "hasFrozenV1"; -type BalanceType = "validators" | "default"; - -export type BalanceDataKey = BalanceType | `validator::${ValidatorKey}`; +/** + * Identifies one balance group of a position: `default`, `validators` (a + * balance spread over several validators), or `validator::`. + */ +export type BalanceDataKey = + | "default" + | "validators" + | `validator::${EarnValidatorKey}`; + +const validatorBalanceDataKeyPrefix = "validator::"; +const isEarnValidatorKey = Schema.is(EarnValidatorKey); + +export const BalanceDataKey = Schema.String.pipe( + Schema.refine( + (key): key is BalanceDataKey => + key === "default" || + key === "validators" || + (key.startsWith(validatorBalanceDataKeyPrefix) && + isEarnValidatorKey(key.slice(validatorBalanceDataKeyPrefix.length))), + { expected: "a position balance key" } + ) +); export type PositionsData = Map< YieldId, @@ -64,7 +88,7 @@ export const getPositionBalanceDataKey = ( const validator = balance.validator ?? balance.validators?.[0]; if (validator) { - return `validator::${validator.key}` as BalanceDataKey; + return `validator::${validator.key}`; } return "default"; @@ -124,11 +148,11 @@ export const getPositionData = ( export const getPositionBalances = ( position: PositionData | null, - balanceId: string | null + balanceId: BalanceDataKey | null ): PositionBalances | null => { if (!position || !balanceId) return null; - const balanceData = position.balanceData.get(balanceId as BalanceDataKey); + const balanceData = position.balanceData.get(balanceId); return balanceData ? { ...balanceData, rewardRate: position.rewardRate } : null; @@ -193,20 +217,13 @@ export const groupPositionBalanceRows = ( | "amountUsd" | "date" | "isEncrypted" - | "revealedHandle" - | "shareAmount" - | "shareAmountRaw" + | "shares" | "shareToken" | "token" | "type" > >, - isCancelling: ( - balance: Pick< - EarnBalance, - "isEncrypted" | "revealedHandle" | "shareAmountRaw" - > - ) => boolean + isCancelling: (balance: Pick) => boolean ): ReadonlyArray => [ ...balances .reduce((rows, balance) => { @@ -229,7 +246,7 @@ export const groupPositionBalanceRows = ( ...(balance.shareToken ? { shareAmount: (previous?.shareAmount ?? exactZero()).plus( - exactDecimal(balance.shareAmount || 0) + getVisibleShares(balance)?.amount ?? exactZero() ), shareToken: balance.shareToken, } diff --git a/packages/widget/src/features/activity/model/activity-action-details.ts b/packages/widget/src/features/activity/model/activity-action-details.ts index 15c8c4c7c..ba84cb84f 100644 --- a/packages/widget/src/features/activity/model/activity-action-details.ts +++ b/packages/widget/src/features/activity/model/activity-action-details.ts @@ -41,18 +41,19 @@ type ActivityDetailsTransactionFields = Readonly<{ readonly error: string | null; readonly explorerUrl: string | null; readonly hash: string | null; - readonly id: string; readonly status: ActivityDetailsTransactionStatus; }>; -/** Yield and Borrow name transaction types from different vocabularies. */ +/** Yield and Borrow name transactions from different vocabularies. */ type ActivityDetailsTransaction = | (ActivityDetailsTransactionFields & { readonly _tag: "yield"; + readonly id: ActionTransaction["id"]; readonly type: ActionTransaction["type"]; }) | (ActivityDetailsTransactionFields & { readonly _tag: "borrow"; + readonly id: BorrowActivityTransaction["id"]; readonly type: BorrowActivityTransaction["type"]; }); diff --git a/packages/widget/src/features/activity/model/activity-enrichment.ts b/packages/widget/src/features/activity/model/activity-enrichment.ts index ac29cb9cb..4d314a0ad 100644 --- a/packages/widget/src/features/activity/model/activity-enrichment.ts +++ b/packages/widget/src/features/activity/model/activity-enrichment.ts @@ -4,6 +4,11 @@ import { getActionValidatorAddresses } from "../../../domain/action/rules"; import type { Integration } from "../../../domain/borrow/catalog/integration"; import type { Market } from "../../../domain/borrow/catalog/market"; import type { BorrowToken } from "../../../domain/borrow/catalog/token"; +import type { + IntegrationId, + MarketId, + TokenAddress, +} from "../../../domain/borrow/ids"; import type { BorrowActivityAction } from "../../../domain/borrow/responses"; import type { EarnValidator, @@ -26,8 +31,8 @@ import type { /** Facts resolved for a set of Activity rows; missing facts stay absent. */ export type ActivityEnrichmentFacts = { - readonly borrowIntegrationsById: ReadonlyMap; - readonly borrowMarketsById: ReadonlyMap; + readonly borrowIntegrationsById: ReadonlyMap; + readonly borrowMarketsById: ReadonlyMap; readonly validatorsByKey: ReadonlyMap; readonly yieldsById: ReadonlyMap; }; @@ -53,7 +58,7 @@ const BORROW_DEBT_ACTIONS: Record = { /** A market's loan or collateral token at an address, case-insensitively. */ export const findBorrowMarketToken = ( market: Market, - address: string + address: TokenAddress ): BorrowToken | null => { const normalized = address.toLowerCase(); @@ -110,8 +115,9 @@ export const projectBorrowActivityItem = ( walletScope: WalletScopeKey, { borrowIntegrationsById, borrowMarketsById }: ActivityEnrichmentFacts ): BorrowActivityItem => { - const market = - borrowMarketsById.get(action.rawArguments?.marketId ?? "") ?? null; + const market = action.rawArguments + ? (borrowMarketsById.get(action.rawArguments.marketId) ?? null) + : null; return { _tag: "borrow", diff --git a/packages/widget/src/features/activity/react/activity-action-route.tsx b/packages/widget/src/features/activity/react/activity-action-route.tsx index 16a48668b..38b9eff55 100644 --- a/packages/widget/src/features/activity/react/activity-action-route.tsx +++ b/packages/widget/src/features/activity/react/activity-action-route.tsx @@ -108,9 +108,9 @@ const BoundActivityActionRoute = ({ const retry = useAtomSet(retryActivityActionRouteAtom(selectionKey)); // The action whose Continuation this route mounted from its Review. Its // Session lives in this subtree across Review, Steps, and Complete. - const [continuedActionId, setContinuedActionId] = useState( - null - ); + const [continuedActionId, setContinuedActionId] = useState< + ActivityActionItem["actionData"]["id"] | null + >(null); if (result.status === "loading") { return ; diff --git a/packages/widget/src/features/activity/state/enrichment.ts b/packages/widget/src/features/activity/state/enrichment.ts index a903e9c7d..50493e188 100644 --- a/packages/widget/src/features/activity/state/enrichment.ts +++ b/packages/widget/src/features/activity/state/enrichment.ts @@ -6,6 +6,7 @@ import type { YieldAction } from "../../../domain/action/models"; import { getActionValidatorAddresses } from "../../../domain/action/rules"; import type { Integration } from "../../../domain/borrow/catalog/integration"; import type { Market } from "../../../domain/borrow/catalog/market"; +import type { IntegrationId, MarketId } from "../../../domain/borrow/ids"; import type { BorrowNetwork } from "../../../domain/borrow/network"; import type { EarnValidator, @@ -182,8 +183,8 @@ export const loadActivityEnrichmentFacts = ( concurrency: ACTIVITY_ENRICHMENT_CONCURRENCY, }).pipe( Effect.map((facts): ActivityEnrichmentFacts => { - const borrowIntegrationsById = new Map(); - const borrowMarketsById = new Map(); + const borrowIntegrationsById = new Map(); + const borrowMarketsById = new Map(); const validatorsByKey = new Map(); const yieldsById = new Map(); diff --git a/packages/widget/src/features/borrow-transaction-flow/model/borrow-completion.ts b/packages/widget/src/features/borrow-transaction-flow/model/borrow-completion.ts index ad73f042e..c1b37cc67 100644 --- a/packages/widget/src/features/borrow-transaction-flow/model/borrow-completion.ts +++ b/packages/widget/src/features/borrow-transaction-flow/model/borrow-completion.ts @@ -1,5 +1,6 @@ import type { Market } from "../../../domain/borrow/catalog/market"; import type { ActionMetadata } from "../../../domain/borrow/execution/action"; +import type { MarketId } from "../../../domain/borrow/ids"; import type { BorrowPositions } from "../../../domain/borrow/positions/borrow-positions"; import { exactDecimal, exactZero } from "../../../domain/finance/exact"; import { @@ -50,10 +51,10 @@ export const settleBorrowPosition = ({ positions, }: { readonly action: Summary["action"]; - readonly marketId: string; + readonly marketId: MarketId; readonly markets: ReadonlyArray; /** The position page's market; a pool collateral action targets another. */ - readonly positionMarketId: string; + readonly positionMarketId: MarketId; readonly positions: BorrowPositions; }): BorrowSettledPosition | null => { const market = markets.find((candidate) => candidate.id === marketId); diff --git a/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts b/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts index d1cf10c19..85480b0b1 100644 --- a/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts +++ b/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-flow.ts @@ -7,7 +7,7 @@ import type { ActionMetadata, } from "../../../domain/borrow/execution/action"; import type { ActionCommand } from "../../../domain/borrow/execution/action-command"; -import type { MarketId } from "../../../domain/borrow/ids"; +import { ActionId, type MarketId } from "../../../domain/borrow/ids"; import type { BorrowNetwork } from "../../../domain/borrow/network"; import type { RiskGateBlockReason, @@ -404,7 +404,7 @@ export class BorrowFlowSession { const BorrowFlowNavigationState = Schema.Struct({ borrowFlowSession: Schema.instanceOf(BorrowFlowSession), /** On Steps and Complete: the handed-off action their Execution runs. */ - executionActionId: Schema.optional(Schema.String), + executionActionId: Schema.optional(ActionId), }); type BorrowFlowNavigationState = typeof BorrowFlowNavigationState.Type; diff --git a/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-workflow.ts b/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-workflow.ts index df3b4a6a4..3c50722cf 100644 --- a/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-workflow.ts +++ b/packages/widget/src/features/borrow-transaction-flow/model/borrow-transaction-workflow.ts @@ -1,4 +1,5 @@ import type { Transaction } from "../../../domain/borrow/execution/transaction"; +import type { TransactionHash } from "../../../domain/identity/identifiers"; import { flattenTransactionWorkflowTransactions, getCurrentTransactionWorkflowBatch, @@ -13,7 +14,7 @@ import { type BorrowExecutionStep = { readonly done: boolean; readonly explorerUrl: string | null; - readonly id: string; + readonly id: Transaction["id"]; readonly progress: TransactionStepProgress; readonly type: Transaction["type"]; }; @@ -21,8 +22,8 @@ type BorrowExecutionStep = { /** A submitted transaction of a completed action, for the completion page. */ type BorrowCompletedTransaction = { readonly explorerUrl: string; - readonly hash: string | null; - readonly id: string; + readonly hash: TransactionHash | null; + readonly id: Transaction["id"]; readonly type: Transaction["type"]; }; @@ -108,7 +109,7 @@ export const projectBorrowExecution = (state: TransactionWorkflowState) => { { explorerUrl: submission.link, hash: submission.hash, - id: submission.transactionId, + id: submission.source.transaction.id, type: submission.source.transaction.type, }, ] diff --git a/packages/widget/src/features/borrow-transaction-flow/state/execution-page.ts b/packages/widget/src/features/borrow-transaction-flow/state/execution-page.ts index 77a4a6771..c5762aa35 100644 --- a/packages/widget/src/features/borrow-transaction-flow/state/execution-page.ts +++ b/packages/widget/src/features/borrow-transaction-flow/state/execution-page.ts @@ -2,6 +2,7 @@ import { Data, Effect, Option, Ref, type Scope, Stream } from "effect"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; import { walletRuntime } from "../../../app/runtime/wallet-runtime"; +import type { ActionId } from "../../../domain/borrow/ids"; import { isBorrowNetwork } from "../../../domain/borrow/network"; import { BorrowMarketsKey, @@ -37,7 +38,7 @@ import { leaveBorrowFlow } from "./borrow-flow-session"; export const startBorrowFlowExecution = Effect.fn("startBorrowFlowExecution")( function* ( { handoff, walletScope }: BorrowFlowSession, - actionId: string + actionId: ActionId ): Effect.fn.Return< TransactionWorkflowHandle, TransactionWorkflowInputError, @@ -115,7 +116,7 @@ export const makeBorrowFlowSettledPositionAtom = ( /** One handed-off action's Execution in a Session. */ export class BorrowFlowExecutionKey extends Data.Class<{ - readonly actionId: string; + readonly actionId: ActionId; readonly session: BorrowFlowSession; }> {} @@ -136,7 +137,7 @@ export const borrowFlowExecutionPage = Atom.family( export const makeBorrowFlowExecutionPage = ( session: BorrowFlowSession, - actionId: string + actionId: ActionId ) => { const workflowAtom = walletRuntime .atom(startBorrowFlowExecution(session, actionId)) diff --git a/packages/widget/src/features/borrow/borrow-entry/model/borrow-entry.ts b/packages/widget/src/features/borrow/borrow-entry/model/borrow-entry.ts index d0e870012..82bc58630 100644 --- a/packages/widget/src/features/borrow/borrow-entry/model/borrow-entry.ts +++ b/packages/widget/src/features/borrow/borrow-entry/model/borrow-entry.ts @@ -9,7 +9,11 @@ import { isListedBorrowMarket, type Market, } from "../../../../domain/borrow/catalog/market"; -import { decodeTokenId, type TokenId } from "../../../../domain/borrow/ids"; +import { + decodeTokenId, + type MarketId, + type TokenId, +} from "../../../../domain/borrow/ids"; import type { BorrowNetwork } from "../../../../domain/borrow/network"; import { type BorrowPositions, @@ -43,7 +47,7 @@ export type BorrowFormIntent = { readonly borrowAmount: string; readonly collateralAmount: string; readonly selectedCollateralTokenId: TokenId | null; - readonly selectedMarketId: string | null; + readonly selectedMarketId: MarketId | null; }; export type BorrowFormAction = @@ -61,7 +65,7 @@ export type BorrowFormAction = } | { readonly type: "market/select"; - readonly marketId: string; + readonly marketId: MarketId; } | { readonly type: "amounts/clear"; @@ -122,7 +126,7 @@ export type BorrowEntryView = { readonly selectedIntegration: Integration | null; readonly selectedMarket: Market | null; readonly selectedMarketPosition: MarketPosition | null; - readonly selectedMarketId: string | null; + readonly selectedMarketId: MarketId | null; readonly validation: BorrowFormValidation; readonly walletBalances: BorrowMarketWalletBalances | null; }; diff --git a/packages/widget/src/features/borrow/borrow-entry/model/market-groups.ts b/packages/widget/src/features/borrow/borrow-entry/model/market-groups.ts index 6eb5bc676..041e0c329 100644 --- a/packages/widget/src/features/borrow/borrow-entry/model/market-groups.ts +++ b/packages/widget/src/features/borrow/borrow-entry/model/market-groups.ts @@ -1,5 +1,6 @@ import BigNumber from "bignumber.js"; import type { CollateralToken } from "../../../../domain/borrow/catalog/collateral-token"; +import type { Integration } from "../../../../domain/borrow/catalog/integration"; import { getBorrowMarketPairLabel, getListedCollateralTokens, @@ -34,11 +35,6 @@ type BorrowMarketGroup = { readonly network: BorrowNetwork; }; -type NamedIntegration = { - readonly id: string; - readonly name: string; -}; - export const toBorrowEntryToken = ({ network, token, @@ -101,7 +97,7 @@ export const filterBorrowMarketGroups = ({ markets, search, }: { - readonly integrations: readonly NamedIntegration[]; + readonly integrations: ReadonlyArray>; readonly markets: readonly Market[]; readonly search: string; }): readonly BorrowMarketGroup[] => { diff --git a/packages/widget/src/features/borrow/borrow-entry/state/borrow-entry.ts b/packages/widget/src/features/borrow/borrow-entry/state/borrow-entry.ts index 3205a5784..5b9871ee1 100644 --- a/packages/widget/src/features/borrow/borrow-entry/state/borrow-entry.ts +++ b/packages/widget/src/features/borrow/borrow-entry/state/borrow-entry.ts @@ -5,7 +5,7 @@ import * as Atom from "effect/reactivity/Atom"; import { appRuntime } from "../../../../app/runtime/app-runtime"; import { BorrowFeatureDisabled } from "../../../../domain/borrow/availability"; import type { CollateralToken } from "../../../../domain/borrow/catalog/collateral-token"; -import { decodeTokenId } from "../../../../domain/borrow/ids"; +import { decodeTokenId, type MarketId } from "../../../../domain/borrow/ids"; import { type BorrowNetwork, isBorrowNetwork, @@ -286,7 +286,7 @@ export const setBorrowCollateralMaxAmountAtom = appRuntime .pipe(Atom.withLabel("setBorrowCollateralMaxAmountAtom")); export const selectBorrowMarketAtom = appRuntime - .fn((marketId: string, context) => { + .fn((marketId: MarketId, context) => { context.set(currentBorrowEntryAtom, { marketId, type: "market/select", diff --git a/packages/widget/src/features/borrow/borrow-entry/ui/components/market-select-modal.tsx b/packages/widget/src/features/borrow/borrow-entry/ui/components/market-select-modal.tsx index 61bbc0923..40e5940f1 100644 --- a/packages/widget/src/features/borrow/borrow-entry/ui/components/market-select-modal.tsx +++ b/packages/widget/src/features/borrow/borrow-entry/ui/components/market-select-modal.tsx @@ -9,6 +9,7 @@ import { type Market, } from "../../../../../domain/borrow/catalog/market"; import { getBorrowOriginationFeeBps } from "../../../../../domain/borrow/execution/origination-fee"; +import type { MarketId } from "../../../../../domain/borrow/ids"; import { formatBorrowProviderName } from "../../../../../shared/lib/formatters"; import { formatPercent, @@ -40,7 +41,7 @@ export const MarketSelectModal = ({ }: { readonly integrations: ReadonlyArray; readonly markets: readonly Market[]; - readonly selectedMarketId: string | null; + readonly selectedMarketId: MarketId | null; }) => { const { t } = useTranslation(); const { locale } = useWidgetPresentation(); diff --git a/packages/widget/src/features/borrow/market-position/model/action-form.ts b/packages/widget/src/features/borrow/market-position/model/action-form.ts index 1f3caf4a1..18e460ca4 100644 --- a/packages/widget/src/features/borrow/market-position/model/action-form.ts +++ b/packages/widget/src/features/borrow/market-position/model/action-form.ts @@ -1,4 +1,5 @@ import type BigNumber from "bignumber.js"; +import type { TokenAddress } from "../../../../domain/borrow/ids"; import type { RiskGateBlockReason } from "../../../../domain/borrow/risk/risk-gate"; import { exactDecimal } from "../../../../domain/finance/exact"; import type { TokenBalance } from "../../../../domain/finance/models"; @@ -46,7 +47,7 @@ export type BorrowRepayFormAction = export type BorrowWithdrawFormIntent = { readonly amount: string; - readonly selectedTokenAddress: string | null; + readonly selectedTokenAddress: TokenAddress | null; }; export type BorrowWithdrawFormAction = @@ -56,7 +57,7 @@ export type BorrowWithdrawFormAction = } | { readonly type: "token/select"; - readonly tokenAddress: string; + readonly tokenAddress: TokenAddress; } | { readonly type: "reset"; diff --git a/packages/widget/src/features/borrow/market-position/model/details.ts b/packages/widget/src/features/borrow/market-position/model/details.ts index ebb9276ec..ed43ba88b 100644 --- a/packages/widget/src/features/borrow/market-position/model/details.ts +++ b/packages/widget/src/features/borrow/market-position/model/details.ts @@ -4,6 +4,7 @@ import { deriveMarketPositionOverview, type MarketPosition, } from "../../../../domain/borrow/positions/market-position"; +import type { BorrowPositionActionKey } from "../../../../domain/borrow/positions/position-action-key"; import { deriveMarketRiskLimits } from "../../../../domain/borrow/risk/market-risk"; import { borrowTokenToAppToken } from "../../../../shared/lib/borrow-token"; import { @@ -42,7 +43,7 @@ type BorrowPositionDetailRow = { }; export type BorrowPositionAction = { - readonly id: string; + readonly id: BorrowPositionActionKey; readonly label: string; readonly pendingContext: BorrowPositionPendingActionContext; readonly type: diff --git a/packages/widget/src/features/borrow/market-position/state/action-form.ts b/packages/widget/src/features/borrow/market-position/state/action-form.ts index 3e3ba979d..a9fe4cd9a 100644 --- a/packages/widget/src/features/borrow/market-position/state/action-form.ts +++ b/packages/widget/src/features/borrow/market-position/state/action-form.ts @@ -5,6 +5,7 @@ import { appRuntime } from "../../../../app/runtime/app-runtime"; import { BorrowFeatureDisabled } from "../../../../domain/borrow/availability"; import type { MarketId } from "../../../../domain/borrow/ids"; import type { BorrowNetwork } from "../../../../domain/borrow/network"; +import type { BorrowPositionActionKey } from "../../../../domain/borrow/positions/position-action-key"; import { sameWalletScopeOwner, type WalletScopeKey, @@ -38,7 +39,7 @@ import { } from "../model/details"; class BorrowPositionActionRouteKey extends Data.Class<{ - readonly actionId: string; + readonly actionId: BorrowPositionActionKey; readonly marketId: MarketId; readonly network: BorrowNetwork; }> {} @@ -53,7 +54,7 @@ export const makeBorrowPositionActionRouteKey = ( }); class BorrowPositionActionFormKey extends Data.Class<{ - readonly actionId: string; + readonly actionId: BorrowPositionActionKey; readonly marketId: MarketId; readonly network: BorrowNetwork; readonly owner: WalletScopeOwnerKey; diff --git a/packages/widget/src/features/borrow/positions/react/use-borrow-positions.ts b/packages/widget/src/features/borrow/positions/react/use-borrow-positions.ts index dab468e33..e3cba4feb 100644 --- a/packages/widget/src/features/borrow/positions/react/use-borrow-positions.ts +++ b/packages/widget/src/features/borrow/positions/react/use-borrow-positions.ts @@ -1,8 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; +import type { MarketId } from "../../../../domain/borrow/ids"; import { useWalletScopeRoute } from "../../../wallet/index"; import { BorrowPositionKey, borrowPositionAtom } from "../state/positions"; -export const useBorrowPosition = (marketId: string | null | undefined) => { +export const useBorrowPosition = (marketId: MarketId | null | undefined) => { const walletScope = useWalletScopeRoute(); const positionResult = useAtomValue( borrowPositionAtom( diff --git a/packages/widget/src/features/borrow/positions/state/positions.ts b/packages/widget/src/features/borrow/positions/state/positions.ts index 4c832353d..9f61bef63 100644 --- a/packages/widget/src/features/borrow/positions/state/positions.ts +++ b/packages/widget/src/features/borrow/positions/state/positions.ts @@ -12,7 +12,7 @@ import { import { walletScopeAtom } from "../../../wallet/index"; export class BorrowPositionKey extends Data.Class<{ - readonly marketId: MarketId | string | null; + readonly marketId: MarketId | null; readonly scope: WalletScopeKey; }> {} diff --git a/packages/widget/src/features/earn/model/pending-action-deep-link.ts b/packages/widget/src/features/earn/model/pending-action-deep-link.ts index 4b3f1feb3..9fbd5527b 100644 --- a/packages/widget/src/features/earn/model/pending-action-deep-link.ts +++ b/packages/widget/src/features/earn/model/pending-action-deep-link.ts @@ -1,5 +1,10 @@ +import type { YieldPendingActionType } from "../../../domain/action/pending-action"; import type { EarnBalance } from "../../../domain/earn/models"; -import { getPositionBalanceDataKey } from "../../../domain/portfolio/positions"; +import type { ValidatorAddress } from "../../../domain/identity/identifiers"; +import { + type BalanceDataKey, + getPositionBalanceDataKey, +} from "../../../domain/portfolio/positions"; /** * A deep link with `balanceId` targets only balances under that position key; @@ -11,10 +16,10 @@ export const findPendingActionDeepLinkTarget = ({ pendingActionType, validator, }: { - readonly balanceId: string | null; + readonly balanceId: BalanceDataKey | null; readonly balances: ReadonlyArray; - readonly pendingActionType: string; - readonly validator: string | null; + readonly pendingActionType: YieldPendingActionType; + readonly validator: ValidatorAddress | null; }) => { for (const balance of balances) { if (balanceId && getPositionBalanceDataKey(balance) !== balanceId) continue; diff --git a/packages/widget/src/features/earn/state/earn-selection/catalog/catalog.ts b/packages/widget/src/features/earn/state/earn-selection/catalog/catalog.ts index 3683d6102..7ab376bc1 100644 --- a/packages/widget/src/features/earn/state/earn-selection/catalog/catalog.ts +++ b/packages/widget/src/features/earn/state/earn-selection/catalog/catalog.ts @@ -1,9 +1,11 @@ +import type BigNumber from "bignumber.js"; import { Array as EArray, Option } from "effect"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; import type { YieldCategory } from "../../../../../domain/earn/contract"; import type { EarnValidator } from "../../../../../domain/earn/models"; import { filterValidators } from "../../../../../domain/earn/yield"; +import { exactZero } from "../../../../../domain/finance/exact"; import { isFundedBalance } from "../../../../../domain/finance/funded-balance"; import type { PositionsData } from "../../../../../domain/portfolio/positions"; import { toPositionsData } from "../../../../../domain/portfolio/positions"; @@ -148,7 +150,7 @@ const tokenBalancesResult = ( get: Atom.AtomContext, key: TokenOptionsKey ): AsyncResult.AsyncResult< - ReadonlyMap | null, + ReadonlyMap | null, EarnCatalogError > => { if (!key.scope) return AsyncResult.success(null); @@ -159,7 +161,7 @@ const tokenBalancesResult = ( new Map( balances.map((balance) => [ tokenString(balance.token), - balance.amount.toFixed(), + balance.amount, ]) ) ), @@ -174,7 +176,7 @@ const tokenBalancesResult = ( const enrichTokenOptions = ( canonical: ReadonlyArray, - balances: ReadonlyMap | null + balances: ReadonlyMap | null ) => canonical .map((option, index) => ({ @@ -182,7 +184,7 @@ const enrichTokenOptions = ( option: balances ? ({ ...option, - amount: balances.get(tokenString(option.token)) ?? "0", + amount: balances.get(tokenString(option.token)) ?? exactZero(), source: "balance", } satisfies EarnTokenOption) : option, diff --git a/packages/widget/src/features/earn/state/earn-selection/model/form.ts b/packages/widget/src/features/earn/state/earn-selection/model/form.ts index 197149b44..13eb8afd0 100644 --- a/packages/widget/src/features/earn/state/earn-selection/model/form.ts +++ b/packages/widget/src/features/earn/state/earn-selection/model/form.ts @@ -1,3 +1,4 @@ +import type BigNumber from "bignumber.js"; import type { EarnYieldWithProvider } from "../../../../../domain/earn/models"; import { getEnterAmountConstraint } from "../../../../../domain/earn/stake"; import { @@ -17,7 +18,7 @@ export const canSubmitEarnForm = ({ positionsData, selectedYield, }: { - readonly availableAmount: string | null; + readonly availableAmount: BigNumber | null; readonly form: EarnSelectionForm; readonly positionsData: PositionsData; readonly selectedYield: EarnYieldWithProvider; @@ -60,7 +61,7 @@ export const resolveForm = ({ positionsData, selectedYield, }: { - availableAmount: string | null; + availableAmount: BigNumber | null; intent: EarnEntryIntent; positionsData: PositionsData; selectedYield: EarnYieldWithProvider; @@ -104,12 +105,12 @@ const resolveStakeAmount = ({ constraint, intent, }: { - availableAmount: string | null; + availableAmount: BigNumber | null; constraint: ReturnType; intent: EarnEntryIntent; }) => { if (constraint.type === "force-max") { - return availableAmount ?? "0"; + return availableAmount?.toFixed() ?? "0"; } if (intent.amountInput !== "untouched") { diff --git a/packages/widget/src/features/earn/state/earn-selection/types.ts b/packages/widget/src/features/earn/state/earn-selection/types.ts index 3d7f80277..d31159d84 100644 --- a/packages/widget/src/features/earn/state/earn-selection/types.ts +++ b/packages/widget/src/features/earn/state/earn-selection/types.ts @@ -1,3 +1,4 @@ +import type BigNumber from "bignumber.js"; import { Data } from "effect"; import type { AsyncResult } from "effect/reactivity/AsyncResult"; import type { Atom, PullResult, Writable } from "effect/reactivity/Atom"; @@ -27,7 +28,7 @@ import type { export type EarnTokenOption = { readonly token: Token; readonly availableYields: ReadonlyArray; - readonly amount: string | null; + readonly amount: BigNumber | null; readonly source: "balance" | "default"; }; export type EarnTokenKey = TokenString; diff --git a/packages/widget/src/features/earn/state/token-selection.ts b/packages/widget/src/features/earn/state/token-selection.ts index ec230b2f1..2dec2698f 100644 --- a/packages/widget/src/features/earn/state/token-selection.ts +++ b/packages/widget/src/features/earn/state/token-selection.ts @@ -3,7 +3,6 @@ import { Option } from "effect"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; import { appRuntime } from "../../../app/runtime/app-runtime"; -import { exactDecimal } from "../../../domain/finance/exact"; import { isFundedBalance } from "../../../domain/finance/funded-balance"; import type { Prices } from "../../../domain/health/models"; import { tokenString } from "../../../domain/token/token"; @@ -25,11 +24,8 @@ export type EarnTokenSelectionItem = EarnTokenOption & { readonly fundedUsd: BigNumber | null; }; -const positiveAmount = (option: EarnTokenOption) => { - if (option.amount === null) return null; - const amount = exactDecimal(option.amount); - return amount.isGreaterThan(0) ? amount : null; -}; +const positiveAmount = (option: EarnTokenOption) => + option.amount?.isGreaterThan(0) ? option.amount : null; const toSelectionItem = ( option: EarnTokenOption, diff --git a/packages/widget/src/features/earn/ui/compact/earn-page/components/select-provider/index.tsx b/packages/widget/src/features/earn/ui/compact/earn-page/components/select-provider/index.tsx index f22eb35a7..3a5f36cae 100644 --- a/packages/widget/src/features/earn/ui/compact/earn-page/components/select-provider/index.tsx +++ b/packages/widget/src/features/earn/ui/compact/earn-page/components/select-provider/index.tsx @@ -1,4 +1,5 @@ import { Trigger } from "@radix-ui/react-dialog"; +import type BigNumber from "bignumber.js"; import { useTranslation } from "react-i18next"; import { formatUsd } from "../../../../../../../shared/lib/number-format"; import { Box } from "../../../../../../../shared/ui/primitives/box"; @@ -27,8 +28,11 @@ const getDisplayWebsite = (website: string) => { } }; -const getProviderTvl = (tvlUsd: unknown, locale: string) => { - if (typeof tvlUsd !== "string" && typeof tvlUsd !== "number") return null; +const getProviderTvl = ( + tvlUsd: BigNumber | null | undefined, + locale: string +) => { + if (!tvlUsd) return null; const formatted = formatUsd(tvlUsd, locale); diff --git a/packages/widget/src/features/earn/ui/compact/earn-page/components/select-validator-section/select-validator-trigger.tsx b/packages/widget/src/features/earn/ui/compact/earn-page/components/select-validator-section/select-validator-trigger.tsx index 07fc4ea11..677a9b1f7 100644 --- a/packages/widget/src/features/earn/ui/compact/earn-page/components/select-validator-section/select-validator-trigger.tsx +++ b/packages/widget/src/features/earn/ui/compact/earn-page/components/select-validator-section/select-validator-trigger.tsx @@ -44,7 +44,7 @@ const formatValidatorTvl = ( selectedStake: EarnYieldWithProvider, locale: string ) => { - const tvl = validator.tvl ?? validator.tvlRaw; + const tvl = validator.tvl; if (!tvl) return null; diff --git a/packages/widget/src/features/earn/ui/components/select-yield-section/select-yield-reward-details.tsx b/packages/widget/src/features/earn/ui/components/select-yield-section/select-yield-reward-details.tsx index dd00e513c..f133e3d9d 100644 --- a/packages/widget/src/features/earn/ui/components/select-yield-section/select-yield-reward-details.tsx +++ b/packages/widget/src/features/earn/ui/components/select-yield-section/select-yield-reward-details.tsx @@ -5,7 +5,6 @@ import { getYieldOutputToken, getYieldTypeLabels, } from "../../../../../domain/earn/yield"; -import { exactDecimal } from "../../../../../domain/finance/exact"; import type { Token } from "../../../../../domain/token/token"; import { formatTokenAmount } from "../../../../../shared/lib/number-format"; import { Divider } from "../../../../../shared/ui/components/divider"; @@ -96,11 +95,9 @@ export const SelectYieldRewardDetails = ({ yieldDto.metadata.name, }, ]; - const pricePerShare = exactDecimal( - yieldDto.state?.pricePerShareState?.price ?? 1 - ); + const pricePerShare = yieldDto.state?.pricePerShareState?.price; const outputAmount = - pricePerShare.isFinite() && !pricePerShare.isZero() + pricePerShare && !pricePerShare.isZero() ? stakeAmount.dividedBy(pricePerShare) : stakeAmount; diff --git a/packages/widget/src/features/earn/ui/dashboard/earn-details/components/provider-selection-card.tsx b/packages/widget/src/features/earn/ui/dashboard/earn-details/components/provider-selection-card.tsx index b2cc708b8..3727a547a 100644 --- a/packages/widget/src/features/earn/ui/dashboard/earn-details/components/provider-selection-card.tsx +++ b/packages/widget/src/features/earn/ui/dashboard/earn-details/components/provider-selection-card.tsx @@ -39,7 +39,7 @@ type ProviderCardItem = { logo: string | undefined; name: string; preferred: boolean | undefined; - stakedBalance: ProviderDetailsItem["stakedBalance"] | EarnValidator["tvlRaw"]; + stakedBalance: ProviderDetailsItem["stakedBalance"]; status: ProviderDetailsItem["status"] | EarnValidator["status"]; validator: EarnValidator | undefined; website: string | undefined; @@ -332,8 +332,7 @@ const getProviderCardItems = ({ logo: providerDetails?.logo ?? validator.logoURI, name: providerDetails?.name ?? name, preferred: providerDetails?.preferred ?? validator.preferred, - stakedBalance: - providerDetails?.stakedBalance ?? validator.tvl ?? validator.tvlRaw, + stakedBalance: providerDetails?.stakedBalance ?? validator.tvl, status: providerDetails?.status ?? validator.status, validator, website: providerDetails?.website ?? validator.website, diff --git a/packages/widget/src/features/portfolio/state/read-models/positions.ts b/packages/widget/src/features/portfolio/state/read-models/positions.ts index 37d3df23e..b2062ce7a 100644 --- a/packages/widget/src/features/portfolio/state/read-models/positions.ts +++ b/packages/widget/src/features/portfolio/state/read-models/positions.ts @@ -10,6 +10,7 @@ import { getYieldCategory } from "../../../../domain/earn/yield"; import { exactDecimal } from "../../../../domain/finance/exact"; import type { YieldId } from "../../../../domain/identity/identifiers"; import { + type BalanceDataKey, type PositionData, type PositionValidators, toPositionsData, @@ -32,7 +33,7 @@ export type PositionItem = { readonly integrationId: YieldId; readonly balancesWithAmount: EarnBalance[]; readonly allBalances: EarnBalance[]; - readonly balanceId: string; + readonly balanceId: BalanceDataKey; readonly actionRequired: boolean; /** * Only optional cancellations are pending, or an Instant Exit is still to diff --git a/packages/widget/src/features/portfolio/state/read-models/summary.ts b/packages/widget/src/features/portfolio/state/read-models/summary.ts index d63feffdd..b21b875a7 100644 --- a/packages/widget/src/features/portfolio/state/read-models/summary.ts +++ b/packages/widget/src/features/portfolio/state/read-models/summary.ts @@ -160,7 +160,7 @@ export const availableBalanceSummaryAtom = Atom.make((get) => { getTokenPriceInUSD({ amount: balance.amount, baseToken: balance.token, - pricePerShare: "1", + pricePerShare: null, prices, token: balance.token, }) diff --git a/packages/widget/src/features/portfolio/ui/dashboard/positions/hooks/use-position-list-item.ts b/packages/widget/src/features/portfolio/ui/dashboard/positions/hooks/use-position-list-item.ts index 36943531f..ce940755c 100644 --- a/packages/widget/src/features/portfolio/ui/dashboard/positions/hooks/use-position-list-item.ts +++ b/packages/widget/src/features/portfolio/ui/dashboard/positions/hooks/use-position-list-item.ts @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { exactDecimal } from "../../../../../../domain/finance/exact"; import { getTokenPriceInUSD } from "../../../../../../domain/finance/price"; import { PricesKey } from "../../../../../../resources/token-prices/index"; import { config } from "../../../../../../shared/config/widget-defaults"; @@ -37,8 +36,7 @@ export const usePositionListItem = (item: PositionItem) => { rewardsSummaries._tag === "Ready" ? rewardsSummaries.value?.[item.integrationId] : undefined; - const rewardsSummary = - summary && exactDecimal(summary.rewards.total).gt(0) ? summary : null; + const rewardsSummary = summary?.rewards.total.gt(0) ? summary : null; const pricesView = useAtomValue( positionPricesAtom( @@ -56,11 +54,7 @@ export const usePositionListItem = (item: PositionItem) => { const prices = pricesView._tag === "Ready" ? pricesView.value : null; const rewardsAmountFormatted = rewardsSummary - ? formatTokenAmount( - exactDecimal(rewardsSummary.rewards.total), - locale, - "down" - ) + ? formatTokenAmount(rewardsSummary.rewards.total, locale, "down") : null; const rewardsAmountPriceFormatted = diff --git a/packages/widget/src/features/position-details/model/flow-actions.ts b/packages/widget/src/features/position-details/model/flow-actions.ts index 03e1ec51b..907d3ed31 100644 --- a/packages/widget/src/features/position-details/model/flow-actions.ts +++ b/packages/widget/src/features/position-details/model/flow-actions.ts @@ -18,15 +18,20 @@ import { isConfidentialYield, toBaseUnits, } from "../../../domain/earn/confidential"; -import type { - EarnBalance, - EarnValidator, - EarnYieldWithProvider, +import { + type EarnBalance, + type EarnValidator, + type EarnValidatorKey, + type EarnYieldWithProvider, + getVisibleShares, } from "../../../domain/earn/models"; import { getMaxSelectedValidators } from "../../../domain/earn/validator"; import { getYieldActionArg } from "../../../domain/earn/yield"; import { truncateToTokenDecimals } from "../../../domain/finance/exact"; -import type { WalletAddress } from "../../../domain/identity/identifiers"; +import type { + ValidatorAddress, + WalletAddress, +} from "../../../domain/identity/identifiers"; import type { Token } from "../../../domain/token/token"; import type { AdditionalAddresses } from "../../../domain/wallet/address"; @@ -39,20 +44,26 @@ type PendingActionSelection = Readonly<{ readonly yieldBalance: EarnBalance; }>; +/** Selected validators by key, so subnets of one address stay distinct. */ +type PendingActionSelectedValidators = ReadonlyMap< + EarnValidatorKey, + ValidatorAddress +>; + export type PendingActionModalState = | Readonly<{ readonly _tag: "Closed"; readonly attemptId: null; readonly multiSelect: false; readonly pendingAction: null; - readonly selectedValidators: Set; + readonly selectedValidators: PendingActionSelectedValidators; }> | Readonly<{ readonly _tag: "Open"; readonly attemptId: PendingActionAttemptId; readonly multiSelect: boolean; readonly pendingAction: PendingActionSelection; - readonly selectedValidators: Set; + readonly selectedValidators: PendingActionSelectedValidators; }>; export type PendingActionModalStore = Readonly<{ @@ -71,7 +82,7 @@ export const closedPendingActionModalState: PendingActionModalState = { attemptId: null, multiSelect: false, pendingAction: null, - selectedValidators: new Set(), + selectedValidators: new Map(), }; export const makePendingActionModalStore = (): PendingActionModalStore => ({ @@ -91,12 +102,12 @@ const makeOpenPendingActionModalState = ({ attemptId, multiSelect: isPendingActionValidatorAddressesRequired(pendingAction), pendingAction: { pendingAction, yieldBalance }, - selectedValidators: new Set([ - ...(yieldBalance.validators?.map((validator) => validator.address) ?? []), - ...(yieldBalance.validator?.address - ? [yieldBalance.validator.address] - : []), - ]), + selectedValidators: new Map( + [ + ...(yieldBalance.validators ?? []), + ...(yieldBalance.validator ? [yieldBalance.validator] : []), + ].map((validator) => [validator.key, validator.address]) + ), }); export const openPendingActionModal = ({ @@ -159,27 +170,34 @@ export const togglePendingActionValidator = ({ validator, }: { readonly store: PendingActionModalStore; - readonly validator: EarnValidator["address"]; + readonly validator: EarnValidator; }): PendingActionModalStore => { if (store.state._tag !== "Open") return store; - const selectedValidators = new Set(store.state.selectedValidators); + const selectedValidators = new Map(store.state.selectedValidators); const maxSelected = getMaxSelectedValidators( store.state.pendingAction.yieldBalance.token.network ); - if (store.state.multiSelect && selectedValidators.has(validator)) { - selectedValidators.delete(validator); + if (store.state.multiSelect && selectedValidators.has(validator.key)) { + selectedValidators.delete(validator.key); } else if (!store.state.multiSelect) { selectedValidators.clear(); - selectedValidators.add(validator); + selectedValidators.set(validator.key, validator.address); } else if (maxSelected === null || selectedValidators.size < maxSelected) { - selectedValidators.add(validator); + selectedValidators.set(validator.key, validator.address); } return selectedValidators.size > 0 ? { ...store, state: { ...store.state, selectedValidators } } : store; }; +/** The addresses a pending action submits for the selected validators. */ +export const getPendingActionValidatorAddresses = ( + state: PendingActionModalState +): ReadonlyArray => [ + ...new Set(state.selectedValidators.values()), +]; + export const pendingActionNeedsValidatorSelection = ( pendingAction: PendingAction ): boolean => @@ -304,7 +322,8 @@ const preparePositionDetailsExitAction = (facts: PositionDetailsExitFacts) => { // Exact share count; converting back could leave dust. if (facts.useMaxAmount) { return shareBalances.reduce( - (total, balance) => total + BigInt(balance.shareAmountRaw ?? 0), + (total, balance) => + total + (getVisibleShares(balance)?.amountRaw ?? 0n), 0n ); } @@ -381,7 +400,7 @@ export type PendingActionTelemetry = | Readonly<{ readonly _tag: "ValidatorsSubmitted"; readonly pendingActionType: PendingAction["type"]; - readonly validators: ReadonlyArray; + readonly validators: ReadonlyArray; readonly yieldId: EarnYieldWithProvider["id"]; }>; @@ -422,7 +441,7 @@ export const resolvePositionPendingActionDecision = ({ return { attemptId: null, pendingAction: command.pendingAction, - selectedValidators: [] as EarnValidator["address"][], + selectedValidators: [] as ReadonlyArray, yieldBalance: command.yieldBalance, }; } @@ -430,7 +449,7 @@ export const resolvePositionPendingActionDecision = ({ ? { attemptId: modal.attemptId, ...modal.pendingAction, - selectedValidators: [...modal.selectedValidators], + selectedValidators: getPendingActionValidatorAddresses(modal), } : null; })(); diff --git a/packages/widget/src/features/position-details/model/position-overview.ts b/packages/widget/src/features/position-details/model/position-overview.ts index 012c9f69a..a23ffedd4 100644 --- a/packages/widget/src/features/position-details/model/position-overview.ts +++ b/packages/widget/src/features/position-details/model/position-overview.ts @@ -7,13 +7,13 @@ import { isOptionalPendingAction, } from "../../../domain/action/pending-action"; import { - type ConfidentialHandle, getBalanceRowDisplayAmount, isCancellingBalance, isConfidentialYield, isUnfinishedInstantExit, } from "../../../domain/earn/confidential"; import type { + ConfidentialHandle, EarnBalance, EarnYieldWithProvider, } from "../../../domain/earn/models"; @@ -412,10 +412,7 @@ const getRewardsMetric = ({ }; } - if ( - rewardsSummary && - exactDecimal(rewardsSummary.rewards.total).isGreaterThan(0) - ) { + if (rewardsSummary?.rewards.total.isGreaterThan(0)) { promotedFactIds.add("rewards"); return { diff --git a/packages/widget/src/features/position-details/react/use-pending-actions.ts b/packages/widget/src/features/position-details/react/use-pending-actions.ts index 57e995355..2c89e14c8 100644 --- a/packages/widget/src/features/position-details/react/use-pending-actions.ts +++ b/packages/widget/src/features/position-details/react/use-pending-actions.ts @@ -2,7 +2,6 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; import type { PendingAction } from "../../../domain/action/models"; import type { EarnBalance } from "../../../domain/earn/models"; -import type { ValidatorInput as ValidatorDto } from "../../../domain/earn/validator"; import { positionDetailsPendingActions } from "../state/actions/pending-action"; import type { PositionDetailsWorkflowAction as Actions, @@ -46,7 +45,7 @@ export const usePendingActions = ({ }); }; - const onValidatorsSubmit = (_selectedValidators: ValidatorDto["address"][]) => + const onValidatorsSubmit = () => runPendingAction({ _tag: "SubmitValidators" }); return { diff --git a/packages/widget/src/features/position-details/react/use-validator-addresses-handling.ts b/packages/widget/src/features/position-details/react/use-validator-addresses-handling.ts index 31e252625..6f58a8eea 100644 --- a/packages/widget/src/features/position-details/react/use-validator-addresses-handling.ts +++ b/packages/widget/src/features/position-details/react/use-validator-addresses-handling.ts @@ -1,5 +1,5 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import type { ValidatorInput as ValidatorDto } from "../../../domain/earn/validator"; +import type { EarnValidator } from "../../../domain/earn/models"; import type { SelectModalProps } from "../../../shared/ui/components/select-modal"; import { positionDetailsPendingActions } from "../state/actions/pending-action"; import type { PositionDetailsWorkflowKey } from "../state/workflow"; @@ -29,10 +29,10 @@ export const useValidatorAddressesHandling = ( closeModal: () => close(undefined), modalState, multiSelect: state.multiSelect, - onItemClick: (validator: ValidatorDto["address"]) => toggle(validator), + onItemClick: (validator: EarnValidator) => toggle(validator), openModal: open, pendingAction: isOpen ? state.pendingAction.pendingAction : null, - selectedValidators: state.selectedValidators, + selectedValidatorKeys: new Set(state.selectedValidators.keys()), showValidatorsModal: isOpen, submitDisabled: state.selectedValidators.size === 0, yieldBalance: isOpen ? state.pendingAction.yieldBalance : null, diff --git a/packages/widget/src/features/position-details/state/actions/pending-action.ts b/packages/widget/src/features/position-details/state/actions/pending-action.ts index 618e7c07e..1ffbd3293 100644 --- a/packages/widget/src/features/position-details/state/actions/pending-action.ts +++ b/packages/widget/src/features/position-details/state/actions/pending-action.ts @@ -11,6 +11,8 @@ import type { EarnBalance, EarnValidator, } from "../../../../domain/earn/models"; +import type { YieldId } from "../../../../domain/identity/identifiers"; +import type { BalanceDataKey } from "../../../../domain/portfolio/positions"; import { walletScopeOwnerKey } from "../../../../domain/wallet/wallet-scope"; import { TrackingService } from "../../../../services/tracking/tracking-service"; import { startYieldTransactionFlowAtom } from "../../../yield-transaction-flow/index"; @@ -34,8 +36,8 @@ import type { PositionDetailsWorkflowKey } from "../workflow"; import { positionDetailsFlowFactsAtom } from "./facts"; class PendingActionModalKey extends Data.Class<{ - readonly balanceId: string | null; - readonly integrationId: string | null; + readonly balanceId: BalanceDataKey | null; + readonly integrationId: YieldId | null; readonly owner: ReturnType; readonly pendingActionType: PositionDetailsWorkflowKey["pendingActionType"]; }> {} @@ -163,7 +165,7 @@ const openPositionPendingActionModalAtom = Atom.family( const togglePositionPendingActionValidatorAtom = Atom.family( (key: PositionDetailsWorkflowKey) => - Atom.fnSync((validator: EarnValidator["address"], context) => { + Atom.fnSync((validator: EarnValidator, context) => { const storeAtom = pendingActionModalStoreAtom( getPendingActionModalKey(key) ); diff --git a/packages/widget/src/features/position-details/state/actions/reveal.ts b/packages/widget/src/features/position-details/state/actions/reveal.ts index 3b743d8c0..d8633fa1e 100644 --- a/packages/widget/src/features/position-details/state/actions/reveal.ts +++ b/packages/widget/src/features/position-details/state/actions/reveal.ts @@ -7,9 +7,9 @@ import { walletRuntime } from "../../../../app/runtime/wallet-runtime"; import { getConfidentialChainId, getConfidentialHandleContract, - getEncryptedHandle, isUserRevealableBalance, } from "../../../../domain/earn/confidential"; +import { getEncryptedHandle } from "../../../../domain/earn/models"; import { resourceInvalidationKeys } from "../../../../services/resource-invalidation"; import { RevealedBalances } from "../../../../services/zama/revealed-balances"; import { ZamaService } from "../../../../services/zama/zama-service"; diff --git a/packages/widget/src/features/position-details/state/details-view.ts b/packages/widget/src/features/position-details/state/details-view.ts index 17ce49e2b..7eb6e4a13 100644 --- a/packages/widget/src/features/position-details/state/details-view.ts +++ b/packages/widget/src/features/position-details/state/details-view.ts @@ -2,10 +2,13 @@ import type BigNumber from "bignumber.js"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; import { - type ConfidentialHandle, isConfidentialYield, isUserRevealableBalance, } from "../../../domain/earn/confidential"; +import { + type ConfidentialHandle, + getVisibleShares, +} from "../../../domain/earn/models"; import { getRewardRateBreakdown, type YieldRewardRate, @@ -102,23 +105,15 @@ export const positionDetailsViewAtom = Atom.family( integration && workflow.positionBalancesByType ? [...workflow.positionBalancesByType.values()].reduce( (conversions, balances) => { - balances - .filter( - (balance) => - !balance.isEncrypted && - balance.shareAmount && - balance.amount && - !balance.token.isPoints - ) - .forEach((balance) => { - conversions.set(tokenString(balance.token), { - rate: exactDecimal(balance.shareAmount ?? 0).dividedBy( - exactDecimal(balance.amount ?? 0) - ), - shareSymbol: balance.shareToken?.symbol, - symbol: balance.token.symbol, - }); + balances.forEach((balance) => { + const shareAmount = getVisibleShares(balance)?.amount; + if (!shareAmount || balance.token.isPoints) return; + conversions.set(tokenString(balance.token), { + rate: shareAmount.dividedBy(balance.amount), + shareSymbol: balance.shareToken?.symbol, + symbol: balance.token.symbol, }); + }); return conversions; }, new Map() @@ -226,7 +221,7 @@ export const positionDetailsViewAtom = Atom.family( unstakeMaxAmount: amountArgument && !forceMax ? (amountArgument.maximum ?? null) : null, unstakeMinAmount: - amountArgument && !forceMax && exactDecimal(minimum).isGreaterThan(0) + amountArgument && !forceMax && minimum.isGreaterThan(0) ? minimum : null, unstakeToken: workflow.unstakeToken, diff --git a/packages/widget/src/features/position-details/state/stake-entry.ts b/packages/widget/src/features/position-details/state/stake-entry.ts index f95c31643..daa95ec9a 100644 --- a/packages/widget/src/features/position-details/state/stake-entry.ts +++ b/packages/widget/src/features/position-details/state/stake-entry.ts @@ -2,12 +2,13 @@ import { Data } from "effect"; import * as Atom from "effect/reactivity/Atom"; import type { TronResource } from "../../../domain/action/tron-resource"; import type { YieldId } from "../../../domain/identity/identifiers"; +import type { BalanceDataKey } from "../../../domain/portfolio/positions"; import type { Token } from "../../../domain/token/token"; import type { WalletScopeKey } from "../../../domain/wallet/wallet-scope"; type PositionDetailsStakeEntryParams = { integrationId: YieldId; - balanceId: string; + balanceId: BalanceDataKey; walletScope: WalletScopeKey; }; diff --git a/packages/widget/src/features/position-details/state/stake-view.ts b/packages/widget/src/features/position-details/state/stake-view.ts index 2d4c41381..41929bace 100644 --- a/packages/widget/src/features/position-details/state/stake-view.ts +++ b/packages/widget/src/features/position-details/state/stake-view.ts @@ -6,10 +6,10 @@ import { appRuntime } from "../../../app/runtime/app-runtime"; import type { TronResource } from "../../../domain/action/tron-resource"; import type { EarnValidator, + EarnValidatorKey, EarnYieldWithProvider, } from "../../../domain/earn/models"; import { stakeTokenSameAsGasToken } from "../../../domain/earn/stake"; -import type { ValidatorKey } from "../../../domain/earn/validator"; import { getYieldActionArg, isYieldValidatorSelectionRequired, @@ -104,7 +104,7 @@ const positionDetailsStakeFacadeAtom = Atom.family( const selectedValidatorsAtom = Atom.make((get) => { const selectedYield = get(selectedYieldAtom); if (!selectedYield || !isYieldValidatorSelectionRequired(selectedYield)) { - return new Map(); + return new Map(); } const balances = get(positionBalancesResultAtom).pipe( diff --git a/packages/widget/src/features/position-details/state/workflow-view.ts b/packages/widget/src/features/position-details/state/workflow-view.ts index 4e7d5ad02..2d7e242b6 100644 --- a/packages/widget/src/features/position-details/state/workflow-view.ts +++ b/packages/widget/src/features/position-details/state/workflow-view.ts @@ -1,5 +1,5 @@ import BigNumber from "bignumber.js"; -import { Option, Schema } from "effect"; +import { Option } from "effect"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Atom from "effect/reactivity/Atom"; import { @@ -22,7 +22,6 @@ import { truncateToTokenDecimals, } from "../../../domain/finance/exact"; import { getTokenPriceInUSD } from "../../../domain/finance/price"; -import { YieldId } from "../../../domain/identity/identifiers"; import { getSingleExitTokenSum, type PositionBalancesByType, @@ -194,9 +193,7 @@ const resolveUnstakeAmount = ({ export const positionDetailsWorkflowViewAtom = Atom.family( (key: PositionDetailsWorkflowKey) => Atom.make((get) => { - const yieldId = key.integrationId - ? Schema.decodeOption(YieldId)(key.integrationId).pipe(Option.getOrNull) - : null; + const yieldId = key.integrationId; const yieldOpportunity = get( yieldOpportunityAtom.foreground(new YieldOpportunityKey({ yieldId })) ); diff --git a/packages/widget/src/features/position-details/state/workflow.ts b/packages/widget/src/features/position-details/state/workflow.ts index 41e41dc56..78d581ecb 100644 --- a/packages/widget/src/features/position-details/state/workflow.ts +++ b/packages/widget/src/features/position-details/state/workflow.ts @@ -8,7 +8,10 @@ import type { TokenAddress, YieldId, } from "../../../domain/identity/identifiers"; -import type { YieldBalanceType } from "../../../domain/portfolio/positions"; +import type { + BalanceDataKey, + YieldBalanceType, +} from "../../../domain/portfolio/positions"; import type { Token } from "../../../domain/token/token"; import type { WalletScopeKey } from "../../../domain/wallet/wallet-scope"; @@ -91,7 +94,7 @@ export const reducePositionDetailsWorkflow = ({ export class PositionDetailsWorkflowKey extends Data.TaggedClass( "PositionDetailsWorkflowKey" )<{ - readonly balanceId: string | null; + readonly balanceId: BalanceDataKey | null; readonly integrationId: YieldId | null; readonly pendingActionType: YieldPendingActionType | null; readonly scope: WalletScopeKey; diff --git a/packages/widget/src/features/position-details/ui/components/position-details-validator-modal.tsx b/packages/widget/src/features/position-details/ui/components/position-details-validator-modal.tsx index 16df155af..42b03f26b 100644 --- a/packages/widget/src/features/position-details/ui/components/position-details-validator-modal.tsx +++ b/packages/widget/src/features/position-details/ui/components/position-details-validator-modal.tsx @@ -31,13 +31,13 @@ export const PositionDetailsValidatorModal = () => { return ( { - validatorAddressesHandling.onItemClick(val.address); + validatorAddressesHandling.onItemClick(val); if (validatorAddressesHandling.multiSelect) return; - onValidatorsSubmit([val.address]); + onValidatorsSubmit(); }} selectedStake={integrationDataValue} validators={validatorsData} @@ -56,11 +56,7 @@ export const PositionDetailsValidatorModal = () => { : "primary", }} disabled={validatorAddressesHandling.submitDisabled} - onClick={() => - onValidatorsSubmit([ - ...validatorAddressesHandling.selectedValidators.values(), - ]) - } + onClick={() => onValidatorsSubmit()} > {t("position_details.select_validators.submit")} diff --git a/packages/widget/src/features/position-details/ui/dashboard/components/position-details-stake-actions.tsx b/packages/widget/src/features/position-details/ui/dashboard/components/position-details-stake-actions.tsx index 999572999..10fe3acc0 100644 --- a/packages/widget/src/features/position-details/ui/dashboard/components/position-details-stake-actions.tsx +++ b/packages/widget/src/features/position-details/ui/dashboard/components/position-details-stake-actions.tsx @@ -1,6 +1,7 @@ import { Match } from "effect"; import { useTranslation } from "react-i18next"; import { getYieldTypeLabels } from "../../../../../domain/earn/yield"; +import { widgetRoutes } from "../../../../../services/navigation/widget-routes"; import { formatNumber, formatTokenAmount, @@ -11,12 +12,15 @@ import { AmountTokenSection } from "../../../../../shared/ui/components/amount-t import { Box } from "../../../../../shared/ui/primitives/box"; import { ContentLoaderSquare } from "../../../../../shared/ui/primitives/content-loader"; import { useWidgetPresentation } from "../../../../../shared/ui/widget-presentation"; +import { useWidgetRouteMatch } from "../../../../navigation/index"; +import { useWalletScopeRoute } from "../../../../wallet/index"; import { type PageCta, PageCtaButton } from "../../../../widget-shell/views"; import { ConfidentialTokenBalance, TronResourceSelection, } from "../../../../yield-entry/views"; import { KycGateCard, MetaInfo } from "../../../../yield-summary/views"; +import { PositionDetailsStakeEntryKey } from "../../../state/stake-entry"; import { usePositionDetailsStake } from "../hooks/use-position-details-stake"; import { PositionDetailsStakeTokenSelect } from "./position-details-stake-token-select"; @@ -203,8 +207,12 @@ const PositionDetailsStakePrimaryAction = ({ return ; }; -export const PositionDetailsStakeActions = () => { - const stake = usePositionDetailsStake(); +const PositionDetailsStakeActionsContent = ({ + stakeKey, +}: { + stakeKey: PositionDetailsStakeEntryKey; +}) => { + const stake = usePositionDetailsStake(stakeKey); return ( <> @@ -216,3 +224,23 @@ export const PositionDetailsStakeActions = () => { ); }; + +/** Staking more needs the position the route names; without one, nothing. */ +export const PositionDetailsStakeActions = () => { + const walletScope = useWalletScopeRoute(); + const match = useWidgetRouteMatch(widgetRoutes.positionDetails); + + if (!match) return null; + + return ( + + ); +}; diff --git a/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts b/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts index 01f78843d..a22362b26 100644 --- a/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts +++ b/packages/widget/src/features/position-details/ui/dashboard/hooks/use-position-details-stake.ts @@ -1,10 +1,6 @@ import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import type { YieldId } from "../../../../../domain/identity/identifiers"; -import { widgetRoutes } from "../../../../../services/navigation/widget-routes"; -import { useWidgetRouteMatch } from "../../../../navigation/index"; -import { useWalletScopeRoute } from "../../../../wallet/index"; import { usePositionDetails } from "../../../react/use-position-details"; -import { PositionDetailsStakeEntryKey } from "../../../state/stake-entry"; +import type { PositionDetailsStakeEntryKey } from "../../../state/stake-entry"; import { hidePositionDetailsStakeTokenBalanceAtom, positionDetailsStakeViewAtom, @@ -17,14 +13,7 @@ import { submitPositionDetailsStakeAtom, } from "../../../state/stake-view"; -export const usePositionDetailsStake = () => { - const walletScope = useWalletScopeRoute(); - const match = useWidgetRouteMatch(widgetRoutes.positionDetails); - const key = new PositionDetailsStakeEntryKey({ - balanceId: match?.balanceId ?? "", - integrationId: (match?.integrationId ?? "") as YieldId, - walletScope, - }); +export const usePositionDetailsStake = (key: PositionDetailsStakeEntryKey) => { const view = useAtomValue(positionDetailsStakeViewAtom(key)); const refreshKyc = useAtomSet(refreshPositionDetailsStakeKycAtom(key)); const setAmount = useAtomSet(setPositionDetailsStakeAmountAtom(key)); diff --git a/packages/widget/src/features/yield-entry/model/yield-entry.ts b/packages/widget/src/features/yield-entry/model/yield-entry.ts index de535eb4d..911e26283 100644 --- a/packages/widget/src/features/yield-entry/model/yield-entry.ts +++ b/packages/widget/src/features/yield-entry/model/yield-entry.ts @@ -4,6 +4,7 @@ import { ActionCommand } from "../../../domain/action/models"; import type { TronResource } from "../../../domain/action/tron-resource"; import type { EarnValidator, + EarnValidatorKey, EarnYieldWithProvider, } from "../../../domain/earn/models"; import { @@ -12,10 +13,8 @@ import { getMinUnstakeAmount, isForceMaxAmount, } from "../../../domain/earn/stake"; -import type { ValidatorKey } from "../../../domain/earn/validator"; import { getYieldActionArg } from "../../../domain/earn/yield"; import { - exactDecimal, exactZero, truncateToTokenDecimals, } from "../../../domain/finance/exact"; @@ -36,7 +35,7 @@ type YieldEntryInput = Readonly<{ readonly token: Token | null; readonly tronResource: TronResource | null; readonly useMaxAmount: boolean; - readonly validators: ReadonlyMap; + readonly validators: ReadonlyMap; readonly yield: EarnYieldWithProvider | null; }>; @@ -230,7 +229,7 @@ type YieldAmountConstraintsInput = Readonly<{ }> | Readonly<{ readonly type: "exit"; - readonly pricePerShare: string | null; + readonly pricePerShare: BigNumber | null; }> ); @@ -241,11 +240,9 @@ const resolveMinimum = ( if (forceMax) return input.availableAmount; if (!input.yield) return null; - return exactDecimal( - input.type === "enter" - ? getMinStakeAmount(input.yield, input.selectedYieldHasActivePosition) - : getMinUnstakeAmount(input.yield, input.pricePerShare) - ); + return input.type === "enter" + ? getMinStakeAmount(input.yield, input.selectedYieldHasActivePosition) + : getMinUnstakeAmount(input.yield, input.pricePerShare); }; const resolveMaximum = ( diff --git a/packages/widget/src/features/yield-entry/ui/components/select-validator/index.tsx b/packages/widget/src/features/yield-entry/ui/components/select-validator/index.tsx index b3516242d..6ecf6de10 100644 --- a/packages/widget/src/features/yield-entry/ui/components/select-validator/index.tsx +++ b/packages/widget/src/features/yield-entry/ui/components/select-validator/index.tsx @@ -3,9 +3,9 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { EarnValidator, + EarnValidatorKey, EarnYieldWithProvider, } from "../../../../../domain/earn/models"; -import type { ValidatorKey } from "../../../../../domain/earn/validator"; import type { SelectModalProps } from "../../../../../shared/ui/components/select-modal"; import { SelectModal } from "../../../../../shared/ui/components/select-modal"; import type { InfiniteScrollProps } from "../../../../../shared/ui/components/virtual-list"; @@ -20,7 +20,7 @@ type SelectValidatorProps = PropsWithChildren< SelectModalProps, "isLoading" | "onClose" | "onOpen" | "state" | "trigger" > & { - selectedValidators: Set; + selectedValidators: ReadonlySet; onItemClick: (item: EarnValidator) => void; onViewMoreClick?: () => void; onLoadMore?: () => void; diff --git a/packages/widget/src/features/yield-entry/ui/components/select-validator/meta-info.tsx b/packages/widget/src/features/yield-entry/ui/components/select-validator/meta-info.tsx index 6108e2bd6..981c53d90 100644 --- a/packages/widget/src/features/yield-entry/ui/components/select-validator/meta-info.tsx +++ b/packages/widget/src/features/yield-entry/ui/components/select-validator/meta-info.tsx @@ -74,7 +74,7 @@ export const useMetaInfo = ({ ? { title: t("details.validators_staked_balance"), val: formatBigNumber( - exactDecimal(stakedBalance), + stakedBalance, (value) => `${formatNumber(value, locale, 0)} ${stakedBalanceToken.symbol}` ), diff --git a/packages/widget/src/features/yield-entry/ui/components/select-validator/select-validator-list.tsx b/packages/widget/src/features/yield-entry/ui/components/select-validator/select-validator-list.tsx index bb34ed379..12fa36210 100644 --- a/packages/widget/src/features/yield-entry/ui/components/select-validator/select-validator-list.tsx +++ b/packages/widget/src/features/yield-entry/ui/components/select-validator/select-validator-list.tsx @@ -3,9 +3,9 @@ import type { ComponentProps } from "react"; import { useTranslation } from "react-i18next"; import type { EarnValidator, + EarnValidatorKey, EarnYieldWithProvider, } from "../../../../../domain/earn/models"; -import type { ValidatorKey } from "../../../../../domain/earn/validator"; import { getRewardTypeFormatted } from "../../../../../shared/lib/formatters"; import { formatRewardRatePercent } from "../../../../../shared/lib/number-format"; import { vars } from "../../../../../shared/styles/theme/contract.css"; @@ -49,7 +49,7 @@ export const SelectValidatorList = ({ ...infiniteScroll }: { multiSelect: boolean; - selectedValidators: Set; + selectedValidators: ReadonlySet; onItemClick: (item: EarnValidator) => void; onViewMoreClick: () => void; selectedStake: EarnYieldWithProvider; diff --git a/packages/widget/src/features/yield-summary/model/estimated-rewards.ts b/packages/widget/src/features/yield-summary/model/estimated-rewards.ts index 6c58104be..5e14c97d4 100644 --- a/packages/widget/src/features/yield-summary/model/estimated-rewards.ts +++ b/packages/widget/src/features/yield-summary/model/estimated-rewards.ts @@ -2,9 +2,9 @@ import type BigNumber from "bignumber.js"; import { Array as EArray, Option } from "effect"; import type { EarnValidator, + EarnValidatorKey, EarnYieldWithProvider, } from "../../../domain/earn/models"; -import type { ValidatorKey } from "../../../domain/earn/validator"; import { isBittensorStaking } from "../../../domain/earn/yield"; import { exactDecimal, exactZero } from "../../../domain/finance/exact"; @@ -20,7 +20,7 @@ export const getYieldEstimatedRewards = ({ }: { readonly amount: BigNumber; readonly providers: ReadonlyArray | null; - readonly validators: ReadonlyMap; + readonly validators: ReadonlyMap; readonly yield: EarnYieldWithProvider | null; }) => { const firstValidator = EArray.head([...validators.values()]).pipe( diff --git a/packages/widget/src/features/yield-summary/model/yield-details-formatters.ts b/packages/widget/src/features/yield-summary/model/yield-details-formatters.ts index a112f0e21..201e752ff 100644 --- a/packages/widget/src/features/yield-summary/model/yield-details-formatters.ts +++ b/packages/widget/src/features/yield-summary/model/yield-details-formatters.ts @@ -1,3 +1,4 @@ +import type BigNumber from "bignumber.js"; import type { TFunction } from "i18next"; import type { EarnYieldWithProvider } from "../../../domain/earn/models"; import type { getEffectiveYieldRewardRateDetails } from "../../../domain/earn/reward-rate"; @@ -65,15 +66,11 @@ export const formatMinStake = ( t: TFunction, locale: string ): { kpiPrimaryEligible: boolean; value: string } | null => { - const minimum = + const amount = yieldDto.mechanics.entryLimits?.minimum ?? getYieldActionArg(yieldDto, "enter", "amount")?.minimum; - if (minimum === null || minimum === undefined) return null; - - const amount = exactDecimal(minimum); - - if (!amount.isFinite()) return null; + if (!amount) return null; if (amount.isZero()) { return { @@ -113,11 +110,11 @@ export const formatCommission = ( }; export const formatProviderTvl = ( - tvl: string | number | null | undefined, + tvl: BigNumber | null | undefined, tokenSymbol: string, locale: string ) => { - if (tvl === null || tvl === undefined) return null; + if (!tvl) return null; const formatted = formatCompactNumber(tvl, locale); @@ -169,13 +166,7 @@ export const formatPricePerShare = ( ): string | null => { const price = yieldDto.state?.pricePerShareState?.price; - if (price === null || price === undefined) return null; - - const amount = exactDecimal(price); - - if (!amount.isFinite() || amount.isLessThanOrEqualTo(0)) return null; - - return formatNumber(amount, locale, 8); + return price?.isGreaterThan(0) ? formatNumber(price, locale, 8) : null; }; export const formatCooldownDays = (days: number, t: TFunction): string => { @@ -205,10 +196,10 @@ export const formatOptionalDays = ( }; export const formatMeaningfulCompactUsd = ( - value: string | number | null | undefined, + value: BigNumber | null | undefined, locale: string ) => { - if (!isPositiveFinite(value)) return null; + if (!value?.isGreaterThan(0)) return null; const formatted = formatUsd(value, locale); @@ -216,20 +207,12 @@ export const formatMeaningfulCompactUsd = ( }; export const formatMeaningfulCompactNumber = ( - value: string | number | null | undefined, + value: number | null | undefined, locale: string ) => { - if (!isPositiveFinite(value)) return null; + if (value == null || value <= 0) return null; const formatted = formatCompactNumber(value, locale); return formatted === "-" ? null : formatted; }; - -const isPositiveFinite = (value: string | number | null | undefined) => { - if (value === null || value === undefined || value === "") return false; - - const amount = exactDecimal(value); - - return amount.isFinite() && amount.isGreaterThan(0); -}; diff --git a/packages/widget/src/features/yield-summary/model/yield-summary.ts b/packages/widget/src/features/yield-summary/model/yield-summary.ts index c63a1b110..67f64682f 100644 --- a/packages/widget/src/features/yield-summary/model/yield-summary.ts +++ b/packages/widget/src/features/yield-summary/model/yield-summary.ts @@ -1,9 +1,9 @@ import type BigNumber from "bignumber.js"; import type { EarnValidator, + EarnValidatorKey, EarnYieldWithProvider, } from "../../../domain/earn/models"; -import type { ValidatorKey } from "../../../domain/earn/validator"; import { getExtendedYieldType, getYieldRewardTokens, @@ -25,7 +25,7 @@ export type YieldSummaryProvider = Readonly<{ export type YieldSummaryInput = Readonly<{ readonly validators: - | ReadonlyMap + | ReadonlyMap | ReadonlyArray | null; readonly yield: EarnYieldWithProvider | null; @@ -78,7 +78,11 @@ const getYieldSummaryProviders = ({ const values = Array.isArray(validators) ? validators - : [...(validators as ReadonlyMap).values()]; + : [ + ...( + validators as ReadonlyMap + ).values(), + ]; if (values.length === 0) return [getYieldProvider(selectedYield)]; return values.map(getValidatorProvider); diff --git a/packages/widget/src/features/yield-summary/ui/components/meta-info/index.tsx b/packages/widget/src/features/yield-summary/ui/components/meta-info/index.tsx index bb991152c..13f1c0ede 100644 --- a/packages/widget/src/features/yield-summary/ui/components/meta-info/index.tsx +++ b/packages/widget/src/features/yield-summary/ui/components/meta-info/index.tsx @@ -1,9 +1,9 @@ import type { JSX, ReactNode } from "react"; import type { EarnValidator, + EarnValidatorKey, EarnYieldWithProvider, } from "../../../../../domain/earn/models"; -import type { ValidatorKey } from "../../../../../domain/earn/validator"; import type { Token } from "../../../../../domain/token/token"; import { Box } from "../../../../../shared/ui/primitives/box"; import { ContentLoaderSquare } from "../../../../../shared/ui/primitives/content-loader"; @@ -21,7 +21,7 @@ type MetaInfoTextSize = NonNullable["size"]>; type Props = { isLoading?: boolean; selectedStake: EarnYieldWithProvider | null; - selectedValidators: Map; + selectedValidators: Map; selectedToken: Token | null; textSize?: MetaInfoTextSize; }; diff --git a/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts b/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts index c78f1df5c..67f729d24 100644 --- a/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts +++ b/packages/widget/src/features/yield-transaction-flow/model/yield-transaction-flow.ts @@ -11,7 +11,6 @@ import type { ExitReceiveToken } from "../../../domain/action/rules"; import { getActionInputToken } from "../../../domain/action/rules"; import { type CancelReturn, - type ConfidentialHandle, getConfidentialAssetToken, getConfidentialChainId, getConfidentialConversion, @@ -21,17 +20,21 @@ import { toConfidentialUnderlyingAmount, } from "../../../domain/earn/confidential"; import type { + ConfidentialHandle, EarnProvider, EarnValidator, + EarnValidatorKey, EarnYieldWithProvider, } from "../../../domain/earn/models"; -import type { ValidatorKey } from "../../../domain/earn/validator"; import { exactDecimal } from "../../../domain/finance/exact"; import type { ProviderOption, YieldId, } from "../../../domain/identity/identifiers"; -import type { YieldBalanceType } from "../../../domain/portfolio/positions"; +import type { + BalanceDataKey, + YieldBalanceType, +} from "../../../domain/portfolio/positions"; import type { Token } from "../../../domain/token/token"; import { sameWalletScopeOwner, @@ -69,7 +72,7 @@ type EnterYieldTransactionFlowIntake = { readonly selectedProviderOption: YieldFlowProviderOption | null; readonly selectedStake: EarnYieldWithProvider; readonly selectedToken: Token; - readonly selectedValidators: ReadonlyMap; + readonly selectedValidators: ReadonlyMap; readonly walletScope: WalletScopeKey; }; @@ -133,17 +136,17 @@ type YieldTransactionFlowMount = | Readonly<{ readonly _tag: "Earn" }> | Readonly<{ readonly _tag: "PositionExit"; - readonly balanceId: string; + readonly balanceId: BalanceDataKey; readonly integrationId: YieldId; }> | Readonly<{ readonly _tag: "PositionManage"; - readonly balanceId: string; + readonly balanceId: BalanceDataKey; readonly integrationId: YieldId; }> | Readonly<{ readonly _tag: "PositionStake"; - readonly balanceId: string; + readonly balanceId: BalanceDataKey; readonly integrationId: YieldId; }>; @@ -364,7 +367,7 @@ export const makeYieldActionContinuationSession = ( * mount share it, so a layout can keep that subtree (and its Session) mounted * across the flow's own navigation. Null outside Yield flow routes. */ -export const getYieldFlowRouteGroup = (pathname: string): string | null => +export const getYieldFlowRouteGroup = (pathname: string): WidgetPath | null => Option.getOrNull( Option.firstSomeOf( Object.values(widgetYieldFlowRoutes).map((routes) => diff --git a/packages/widget/src/features/yield-transaction-flow/state/yield-summary.ts b/packages/widget/src/features/yield-transaction-flow/state/yield-summary.ts index 467ea9608..e0070644e 100644 --- a/packages/widget/src/features/yield-transaction-flow/state/yield-summary.ts +++ b/packages/widget/src/features/yield-transaction-flow/state/yield-summary.ts @@ -33,13 +33,13 @@ const makePercentageFee = ({ percentage, }: { readonly amount: BigNumber; - readonly percentage: string | undefined; + readonly percentage: BigNumber | null | undefined; }): YieldFlowPercentageFee | null => - percentage === undefined + percentage == null ? null : { amount: amount.multipliedBy(percentage).dividedBy(100), - ratio: exactDecimal(percentage).dividedBy(100), + ratio: percentage.dividedBy(100), }; export const makeYieldFlowStakeReviewViewAtom = ( @@ -69,17 +69,7 @@ export const makeYieldFlowStakeReviewViewAtom = ( validators: new Map(intake.selectedValidators), yield: intake.selectedStake, }); - const yieldFee = ( - intake.selectedStake as typeof intake.selectedStake & { - mechanics?: { - fee?: { - deposit?: string; - management?: string; - performance?: string; - }; - }; - } - ).mechanics?.fee; + const yieldFee = intake.selectedStake.mechanics.fee; const feeInput = { amount: stakeAmount }; return { diff --git a/packages/widget/src/resources/yield-positions/yield-positions.ts b/packages/widget/src/resources/yield-positions/yield-positions.ts index 1a754affa..0d23ca507 100644 --- a/packages/widget/src/resources/yield-positions/yield-positions.ts +++ b/packages/widget/src/resources/yield-positions/yield-positions.ts @@ -5,6 +5,7 @@ import { appRuntime } from "../../app/runtime/app-runtime"; import type { YieldBalancesCommand } from "../../domain/finance/models"; import type { YieldId } from "../../domain/identity/identifiers"; import { + type BalanceDataKey, getPositionBalances, getPositionData, type PositionBalancesByType, @@ -121,7 +122,7 @@ export const positionDataAtom = Atom.family((key: PositionDataKey) => ); export class PositionBalancesKey extends Data.Class<{ - readonly balanceId: string | null; + readonly balanceId: BalanceDataKey | null; readonly scope: WalletScopeKey; readonly yieldId: YieldId | null; }> {} diff --git a/packages/widget/src/services/api/borrow-operations.ts b/packages/widget/src/services/api/borrow-operations.ts index 34d24d065..50f9ae870 100644 --- a/packages/widget/src/services/api/borrow-operations.ts +++ b/packages/widget/src/services/api/borrow-operations.ts @@ -5,6 +5,10 @@ import { type SubmitTransactionCommand as BorrowSubmitTransactionCommand, SubmitTransactionResult as BorrowSubmitTransactionResult, } from "../../domain/borrow/execution/transaction"; +import type { + ActionId as BorrowActionId, + TransactionId as BorrowTransactionId, +} from "../../domain/borrow/ids"; import type * as BorrowApi from "../../generated/api/borrow-client"; import type { RichErrorService } from "../errors/rich-error-service"; import { @@ -18,7 +22,7 @@ import type { ApiClients } from "./transport"; type SubmitTransactionRequest = { readonly command: BorrowSubmitTransactionCommand; - readonly transactionId: string; + readonly transactionId: BorrowTransactionId; }; export const makeBorrowOperations = ( @@ -45,7 +49,7 @@ export const makeBorrowOperations = ( }); const getAction = Effect.fn("BorrowOperations.getAction")(function* ( - actionId: string + actionId: BorrowActionId ) { const client = yield* requireTransport(); const response = yield* client.read @@ -60,7 +64,7 @@ export const makeBorrowOperations = ( }); const stepAction = Effect.fn("BorrowOperations.stepAction")(function* ( - actionId: string + actionId: BorrowActionId ) { const client = yield* requireTransport(); return yield* client.write diff --git a/packages/widget/src/services/api/operations.ts b/packages/widget/src/services/api/operations.ts index 160c9d89f..89f948100 100644 --- a/packages/widget/src/services/api/operations.ts +++ b/packages/widget/src/services/api/operations.ts @@ -15,6 +15,10 @@ import type { SubmitTransactionCommand as BorrowSubmitTransactionCommand, SubmitTransactionResult as BorrowSubmitTransactionResult, } from "../../domain/borrow/execution/transaction"; +import type { + ActionId as BorrowActionId, + TransactionId as BorrowTransactionId, +} from "../../domain/borrow/ids"; import type { EarnYieldBalancesResponse } from "../../domain/earn/models"; import type { WalletAddress, YieldId } from "../../domain/identity/identifiers"; import { @@ -46,14 +50,14 @@ type BorrowOperationsService = { command: BorrowActionCommand ) => Effect.Effect; readonly getAction: ( - actionId: string + actionId: BorrowActionId ) => Effect.Effect; readonly stepAction: ( - actionId: string + actionId: BorrowActionId ) => Effect.Effect; readonly submitTransaction: (request: { readonly command: BorrowSubmitTransactionCommand; - readonly transactionId: string; + readonly transactionId: BorrowTransactionId; }) => Effect.Effect; /** * `submitTransaction` for work that outlives the user's Transaction Flow: @@ -61,7 +65,7 @@ type BorrowOperationsService = { */ readonly submitTransactionInBackground: (request: { readonly command: BorrowSubmitTransactionCommand; - readonly transactionId: string; + readonly transactionId: BorrowTransactionId; }) => Effect.Effect; }; diff --git a/packages/widget/src/services/api/resource-sources.ts b/packages/widget/src/services/api/resource-sources.ts index f43ad0de5..aa087168a 100644 --- a/packages/widget/src/services/api/resource-sources.ts +++ b/packages/widget/src/services/api/resource-sources.ts @@ -5,7 +5,10 @@ import type { ActivityActionsQuery } from "../../domain/activity/query"; import type { BorrowFeatureDisabled } from "../../domain/borrow/availability"; import type { Integration } from "../../domain/borrow/catalog/integration"; import type { BorrowMarketsScope } from "../../domain/borrow/catalog/market"; -import type { ActionId as BorrowActionId } from "../../domain/borrow/ids"; +import type { + ActionId as BorrowActionId, + IntegrationId as BorrowIntegrationId, +} from "../../domain/borrow/ids"; import type { BorrowNetwork } from "../../domain/borrow/network"; import type { BorrowActionsPage, @@ -83,7 +86,7 @@ export type EarnTokenCatalogRequest = { export type BorrowActionsRequest = { readonly address: WalletAddress; - readonly integrationId?: string; + readonly integrationId?: BorrowIntegrationId; readonly limit: number; readonly network: BorrowNetwork; readonly offset: number; diff --git a/packages/widget/src/services/navigation/widget-routes.ts b/packages/widget/src/services/navigation/widget-routes.ts index f1a641ad4..b49d2101b 100644 --- a/packages/widget/src/services/navigation/widget-routes.ts +++ b/packages/widget/src/services/navigation/widget-routes.ts @@ -1,7 +1,9 @@ import { Option, Schema } from "effect"; import { YieldPendingActionType } from "../../domain/action/pending-action"; import { ActionId as BorrowActionId, MarketId } from "../../domain/borrow/ids"; +import { BorrowPositionActionKey } from "../../domain/borrow/positions/position-action-key"; import { ActionId, YieldId } from "../../domain/identity/identifiers"; +import { BalanceDataKey } from "../../domain/portfolio/positions"; import type { WidgetLayout } from "../config/widget-config-model"; /** @@ -327,7 +329,10 @@ const flowRoutes = ( ); }; -const positionParams = { integrationId: YieldId } as const; +const positionParams = { + balanceId: BalanceDataKey, + integrationId: YieldId, +} as const; const activityActionParams = { actionId: ActionId } as const; const borrowPositionParams = { marketId: MarketId } as const; @@ -382,7 +387,7 @@ export const widgetRoutes = { borrowPositionAction: route( "/positions/borrow/:marketId/action/:actionId", wideLayouts, - borrowPositionParams + { ...borrowPositionParams, actionId: BorrowPositionActionKey } ), } as const; diff --git a/packages/widget/src/services/transaction-workflow/internal/confirmation.ts b/packages/widget/src/services/transaction-workflow/internal/confirmation.ts index ccc9eb482..d847f5b86 100644 --- a/packages/widget/src/services/transaction-workflow/internal/confirmation.ts +++ b/packages/widget/src/services/transaction-workflow/internal/confirmation.ts @@ -1,4 +1,5 @@ import { Data, Duration, Effect, Match, Ref, Schedule } from "effect"; +import { isHex } from "viem"; import { isTransactionError } from "../../../domain/action/rules"; import { type Action as BorrowAction, @@ -70,8 +71,13 @@ export const makeConfirmCurrent = Effect.gen(function* () { if (current._tag === "Direct") { const direct = current.transaction.source.transaction; const { chainId } = direct; - const hash = current.transaction.meta.signedTx as `0x${string}` | null; + const { meta } = current.transaction; + const hash = meta.broadcasted === true ? meta.signedTx : null; if (!hash) return yield* fail("The transaction hash is not available."); + // viem takes hex, and an EVM wallet answers in it. + if (!isHex(hash)) { + return yield* fail("The transaction hash is not EVM hex."); + } const outcome = yield* evmRpc .awaitOutcome({ chainId, hash }) .pipe( diff --git a/packages/widget/src/services/transaction-workflow/internal/held-hash-registration.ts b/packages/widget/src/services/transaction-workflow/internal/held-hash-registration.ts index 6a0d3b383..b584b94b3 100644 --- a/packages/widget/src/services/transaction-workflow/internal/held-hash-registration.ts +++ b/packages/widget/src/services/transaction-workflow/internal/held-hash-registration.ts @@ -8,6 +8,7 @@ import { Match, Schedule, } from "effect"; +import type { TransactionHash } from "../../../domain/identity/identifiers"; import { BorrowOperations, isTransientApiFailure, @@ -53,7 +54,7 @@ export const makeHeldHashRegistration = Effect.gen(function* () { const registerHash = ( source: ApiRecordedSource, - hash: string + hash: TransactionHash ): Effect.Effect => { const register = Match.value(source).pipe( Match.tag("Yield", (source) => diff --git a/packages/widget/src/services/transaction-workflow/internal/model.ts b/packages/widget/src/services/transaction-workflow/internal/model.ts index beea56565..8cbd9e078 100644 --- a/packages/widget/src/services/transaction-workflow/internal/model.ts +++ b/packages/widget/src/services/transaction-workflow/internal/model.ts @@ -24,18 +24,40 @@ type TransactionWorkflowTransaction = type TransactionWorkflowSource = TransactionWorkflowTransaction["source"]; type TransactionWorkflowDomainContext = TransactionWorkflowContext["domain"]; -export const isTransactionWorkflowDoneStatus = (status: string) => - status === "CONFIRMED" || status === "SKIPPED"; - -const isBroadcastStatus = (status: string) => - status === "BROADCASTED" || status === "CONFIRMED"; +type TransactionWorkflowRemoteStatus = + | YieldAction["transactions"][number]["status"] + | BorrowAction["transactions"][number]["status"]; + +/** Every remote status is classified, so a new one must be placed here. */ +const transactionWorkflowStatusProgress = { + NOT_FOUND: { broadcast: false, done: false }, + CREATED: { broadcast: false, done: false }, + BLOCKED: { broadcast: false, done: false }, + WAITING_FOR_SIGNATURE: { broadcast: false, done: false }, + SIGNED: { broadcast: false, done: false }, + BROADCASTED: { broadcast: true, done: false }, + PENDING: { broadcast: false, done: false }, + CONFIRMED: { broadcast: true, done: true }, + FAILED: { broadcast: false, done: false }, + SKIPPED: { broadcast: false, done: true }, +} as const satisfies Record< + TransactionWorkflowRemoteStatus, + { readonly broadcast: boolean; readonly done: boolean } +>; + +export const isTransactionWorkflowDoneStatus = ( + status: TransactionWorkflowRemoteStatus +) => transactionWorkflowStatusProgress[status].done; const toTransaction = ( source: Exclude ): TransactionWorkflowTransaction => ({ source, meta: { - broadcasted: isBroadcastStatus(source.transaction.status) ? true : null, + broadcasted: transactionWorkflowStatusProgress[source.transaction.status] + .broadcast + ? true + : null, confirmationError: null, done: isTransactionWorkflowDoneStatus(source.transaction.status), signError: null, @@ -69,11 +91,12 @@ const toDirectTransaction = ({ return { source: { _tag: "Direct", transaction }, meta: { - broadcasted: record ? true : null, + ...(record + ? { broadcasted: true, signedTx: record.hash } + : { broadcasted: null, signedTx: null }), confirmationError: null, done: record?._tag === "Confirmed", signError: null, - signedTx: record?.hash ?? null, submissionIndex: null, url: null, }, diff --git a/packages/widget/src/services/transaction-workflow/internal/preparation.ts b/packages/widget/src/services/transaction-workflow/internal/preparation.ts index babade525..2f9962516 100644 --- a/packages/widget/src/services/transaction-workflow/internal/preparation.ts +++ b/packages/widget/src/services/transaction-workflow/internal/preparation.ts @@ -1,7 +1,9 @@ import { Data, Duration, Effect, Result, Schedule } from "effect"; -import { keccak256 } from "viem"; +import { isHex, keccak256 } from "viem"; import type { ManageActionCommand } from "../../../domain/action/models"; import { getValidStakeSessionTx } from "../../../domain/action/rules"; +import { getEncryptedHandle } from "../../../domain/earn/models"; +import { TransactionHash } from "../../../domain/identity/identifiers"; import { type ActionPreviewRequest, isTransientApiFailure, @@ -151,10 +153,11 @@ export const makePrepareAction = Effect.gen(function* () { : (context.submissions[unwrap.meta.submissionIndex] ?? null); // The API may not return the hash; a signed EVM transaction's keccak256 // is its hash. + const signedPayload = submission?.signedPayload ?? null; const hash = submission?.hash ?? - (submission?.signedPayload - ? keccak256(submission.signedPayload as `0x${string}`) + (signedPayload && isHex(signedPayload) + ? TransactionHash.make(keccak256(signedPayload)) : null); if (!hash) { return yield* fail("The unwrap transaction is unknown.", null, false); @@ -175,8 +178,7 @@ export const makePrepareAction = Effect.gen(function* () { .filter( (balance) => balance.type === "withdrawable" && - balance.shareAmountRaw?.toLowerCase() === - requestId.toLowerCase() + getEncryptedHandle(balance) === requestId ) .flatMap((balance) => balance.pendingActions) .find(({ type }) => type === "WITHDRAW"); diff --git a/packages/widget/src/services/transaction-workflow/internal/signing.ts b/packages/widget/src/services/transaction-workflow/internal/signing.ts index aa5500af3..4ec3c71c0 100644 --- a/packages/widget/src/services/transaction-workflow/internal/signing.ts +++ b/packages/widget/src/services/transaction-workflow/internal/signing.ts @@ -4,8 +4,10 @@ import { decodeBorrowTransactionForWallet, decodeBorrowTypedDataForWallet, } from "../../../domain/borrow/execution/transaction"; -import { TransactionId } from "../../../domain/identity/identifiers"; -import type { Network } from "../../../domain/network/network"; +import { + TransactionId, + type WalletSignature, +} from "../../../domain/identity/identifiers"; import type { WalletScopeOwner } from "../../../domain/wallet/wallet-scope"; import { EvmRpc } from "../../evm-rpc/evm-rpc"; import { TrackingService } from "../../tracking/tracking-service"; @@ -82,10 +84,16 @@ const walletSignFailureReason = ( }), }); +/** What the wallet returned: a broadcast hash, a signed payload, or a signature. */ +type WorkflowSignedResult = + | WalletBroadcastResult + | WalletSignedPayloadResult + | { readonly broadcasted: false; readonly signedTx: WalletSignature }; + /** `context` with its current transaction holding what the wallet returned. */ export const withSignedTransaction = ( context: TransactionWorkflowContext, - signed: WalletBroadcastResult | WalletSignedPayloadResult + signed: WorkflowSignedResult ): TransactionWorkflowContext => updateCurrentTransactionWorkflowTransaction({ context, @@ -93,10 +101,9 @@ export const withSignedTransaction = ( ...current, meta: { ...current.meta, - broadcasted: signed.broadcasted, + ...signed, confirmationError: null, signError: null, - signedTx: signed.signedTx, }, }), }); @@ -249,7 +256,7 @@ export const makePrepareAndSign = Effect.fn("makePrepareAndSign")(function* ( signed.broadcasted ? recordDirectTransaction(ledger, direct, { _tag: "Sent", - hash: signed.signedTx as `0x${string}`, + hash: signed.signedTx, }) : Effect.void ) @@ -284,7 +291,7 @@ export const makePrepareAndSign = Effect.fn("makePrepareAndSign")(function* ( expectedOwner, family: "yield", ledgerHwAppId: null, - network: network as Network, + network, tx: payload, txMeta: { ...domain.actionMeta, @@ -342,7 +349,7 @@ export const makePrepareAndSign = Effect.fn("makePrepareAndSign")(function* ( expectedOwner, family: "borrow", ledgerHwAppId: null, - network: network as Network, + network, tx, txMeta, }) diff --git a/packages/widget/src/services/transaction-workflow/internal/submission.ts b/packages/widget/src/services/transaction-workflow/internal/submission.ts index b181f7b76..63226a6ad 100644 --- a/packages/widget/src/services/transaction-workflow/internal/submission.ts +++ b/packages/widget/src/services/transaction-workflow/internal/submission.ts @@ -1,5 +1,10 @@ import { Effect, Match } from "effect"; -import { keccak256 } from "viem"; +import { type Hex, isHex, keccak256 } from "viem"; +import { + type SignedTransactionPayload, + TransactionHash, + type WalletSignature, +} from "../../../domain/identity/identifiers"; import { BorrowOperations, YieldOperations } from "../../api/operations"; import { EvmRpc } from "../../evm-rpc/evm-rpc"; import { TrackingService } from "../../tracking/tracking-service"; @@ -16,6 +21,28 @@ import { import { recordDirectTransaction } from "./ledger"; import { updateCurrentTransactionWorkflowTransaction } from "./model"; +type TransactionWorkflowTransactionMeta = + TransactionWorkflowContext["batches"][number]["transactions"][number]["meta"]; + +/** What the wallet returned that a submission records. */ +type SubmittableSigned = + | { readonly broadcasted: true; readonly signedTx: TransactionHash } + | { + readonly broadcasted: false; + readonly signedTx: SignedTransactionPayload | WalletSignature; + }; + +/** Null until the wallet returned something, or when no hash is known. */ +const getSubmittableSigned = ( + meta: TransactionWorkflowTransactionMeta +): SubmittableSigned | null => { + if (meta.broadcasted === null) return null; + if (meta.broadcasted === false) return meta; + return meta.signedTx === null + ? null + : { broadcasted: true, signedTx: meta.signedTx }; +}; + export const makeSubmitCurrent = Effect.gen(function* () { const [borrowOperations, evmRpc, tracking, yieldOperations] = yield* Effect.all([ @@ -35,7 +62,7 @@ export const makeSubmitCurrent = Effect.gen(function* () { const current = yield* requireCurrentWorkflow(context); const { batch, transaction, workflowId } = current; const { source } = transaction; - const signedTx = transaction.meta.signedTx; + const signed = getSubmittableSigned(transaction.meta); const broadcasted = transaction.meta.broadcasted === true; const fail = (message: string, cause?: unknown) => new TransactionSubmissionError({ @@ -47,7 +74,7 @@ export const makeSubmitCurrent = Effect.gen(function* () { workflowId, }); - if (!signedTx) { + if (!signed) { return yield* fail("The signed transaction payload is not available."); } @@ -56,54 +83,72 @@ export const makeSubmitCurrent = Effect.gen(function* () { const { source } = transaction; const direct = source.transaction; const { chainId, id } = direct; - const signed = signedTx as `0x${string}`; + const submitted = ( + hash: TransactionHash, + evmHash: Hex, + signedPayload: SignedTransactionPayload | WalletSignature | null + ) => + ({ + batchId: batch.id, + hash, + link: evmRpc.explorerUrl({ chainId, hash: evmHash }), + signedPayload, + source, + status: null, + transactionId: id, + }) satisfies TransactionWorkflowSubmission; + + // viem takes hex, and an EVM wallet answers in it. + if (signed.broadcasted) { + const hash = signed.signedTx; + return isHex(hash) + ? Effect.succeed(submitted(hash, hash, null)) + : Effect.fail(fail("The transaction hash is not EVM hex.")); + } + + const payload = signed.signedTx; + if (!isHex(payload)) { + return Effect.fail(fail("The signed transaction is not EVM hex.")); + } // The hash is known before the broadcast, so a lost response cannot // make Retry sign the transaction again. - const hash = broadcasted ? signed : keccak256(signed); - const broadcast = broadcasted - ? Effect.void - : Effect.uninterruptibleMask((restore) => - recordDirectTransaction(ledger, direct, { - _tag: "Sent", - hash, - }).pipe( - Effect.andThen( - restore(evmRpc.broadcast({ chainId, hash, signedTx: signed })) - ), - // Only a broadcast the node refused is forgotten: a failed or - // interrupted one may still have reached it. - Effect.tapError((error) => - error._tag === "EvmBroadcastRejectedError" - ? recordDirectTransaction(ledger, direct, null) - : Effect.void - ) - ) - ); + const evmHash = keccak256(payload); + const hash = TransactionHash.make(evmHash); - return broadcast.pipe( + return Effect.uninterruptibleMask((restore) => + recordDirectTransaction(ledger, direct, { + _tag: "Sent", + hash, + }).pipe( + Effect.andThen( + restore( + evmRpc.broadcast({ chainId, hash: evmHash, signedTx: payload }) + ) + ), + // Only a broadcast the node refused is forgotten: a failed or + // interrupted one may still have reached it. + Effect.tapError((error) => + error._tag === "EvmBroadcastRejectedError" + ? recordDirectTransaction(ledger, direct, null) + : Effect.void + ) + ) + ).pipe( Effect.mapError((cause) => fail("Transaction broadcast failed.", cause) ), - Effect.as({ - batchId: batch.id, - hash, - link: evmRpc.explorerUrl({ chainId, hash }), - signedPayload: broadcasted ? null : signed, - source, - status: null, - transactionId: id, - } satisfies TransactionWorkflowSubmission) + Effect.as(submitted(hash, evmHash, payload)) ); }), Match.tag("Yield", ({ transaction }) => { const { source } = transaction; - const submit = broadcasted + const submit = signed.broadcasted ? yieldOperations.submitTransactionHash({ - payload: { hash: signedTx }, + payload: { hash: signed.signedTx }, transactionId: source.transaction.id, }) : yieldOperations.submitSignedTransaction({ - payload: { signedTransaction: signedTx }, + payload: { signedTransaction: signed.signedTx }, transactionId: source.transaction.id, }); @@ -120,9 +165,11 @@ export const makeSubmitCurrent = Effect.gen(function* () { (response) => ({ batchId: batch.id, - hash: broadcasted ? signedTx : (response.hash ?? null), + hash: signed.broadcasted + ? signed.signedTx + : (response.hash ?? null), link: response.explorerUrl ?? null, - signedPayload: broadcasted ? null : signedTx, + signedPayload: signed.broadcasted ? null : signed.signedTx, source, status: response.status ?? null, transactionId: source.transaction.id, @@ -135,9 +182,9 @@ export const makeSubmitCurrent = Effect.gen(function* () { return borrowOperations .submitTransaction({ - command: broadcasted - ? { transactionHash: signedTx } - : { signedPayload: signedTx }, + command: signed.broadcasted + ? { transactionHash: signed.signedTx } + : { signedPayload: signed.signedTx }, transactionId: source.transaction.id, }) .pipe( @@ -148,11 +195,11 @@ export const makeSubmitCurrent = Effect.gen(function* () { (response) => ({ batchId: batch.id, - hash: broadcasted - ? signedTx - : (response.transactionHash ?? signedTx), + hash: signed.broadcasted + ? signed.signedTx + : (response.transactionHash ?? null), link: response.link ?? null, - signedPayload: broadcasted ? null : signedTx, + signedPayload: signed.broadcasted ? null : signed.signedTx, source, status: response.status ?? null, transactionId: source.transaction.id, @@ -169,19 +216,22 @@ export const makeSubmitCurrent = Effect.gen(function* () { ...context, submissions: [...context.submissions, submission], }, - update: (current) => ({ - ...current, - meta: { + update: (current) => { + const meta = { ...current.meta, - // A Direct transaction is confirmed by its hash. - ...(current.source._tag === "Direct" - ? { broadcasted: true, signedTx: submission.hash } - : {}), confirmationError: null, signError: null, submissionIndex, - }, - }), + }; + return { + ...current, + // A Direct transaction is confirmed by its hash. + meta: + current.source._tag === "Direct" + ? { ...meta, broadcasted: true, signedTx: submission.hash } + : meta, + }; + }, }); yield* tracking.trackEvent("txSubmitted", { diff --git a/packages/widget/src/services/transaction-workflow/transaction-workflow-model.ts b/packages/widget/src/services/transaction-workflow/transaction-workflow-model.ts index 63c8e4375..22263cbcb 100644 --- a/packages/widget/src/services/transaction-workflow/transaction-workflow-model.ts +++ b/packages/widget/src/services/transaction-workflow/transaction-workflow-model.ts @@ -7,7 +7,13 @@ import type { } from "../../domain/action/models"; import type { Action as BorrowAction } from "../../domain/borrow/execution/action"; import type { Transaction as BorrowTransaction } from "../../domain/borrow/execution/transaction"; -import type { TransactionWorkflowId } from "../../domain/identity/identifiers"; +import type { + SignedTransactionPayload, + TransactionHash, + TransactionWorkflowId, + WalletSignature, +} from "../../domain/identity/identifiers"; +import type { Network } from "../../domain/network/network"; import { WalletScopeKey } from "../../domain/wallet/wallet-scope"; import { toRepresentationNumber } from "../../shared/lib/number-format"; import type { EvmRpcError } from "../evm-rpc/evm-rpc"; @@ -25,7 +31,7 @@ import type { ConfidentialPreparation } from "../zama/zama-service"; export type DirectTransactionRecord = Readonly<{ readonly _tag: "Confirmed" | "Sent"; - readonly hash: `0x${string}`; + readonly hash: TransactionHash; /** What was sent, so a later confirm can resume it without planning again. */ readonly transaction: DirectTransaction; }>; @@ -206,11 +212,15 @@ type TransactionWorkflowSource = readonly transaction: BorrowTransaction; }; +/** A transaction's id, branded by the source it came from. */ +type TransactionWorkflowTransactionId = + TransactionWorkflowSource["transaction"]["id"]; + type WorkflowErrorFields = { readonly batchId: string; readonly cause?: unknown; readonly message: string; - readonly transactionId: string | null; + readonly transactionId: TransactionWorkflowTransactionId | null; readonly workflowId: TransactionWorkflowId; }; @@ -300,17 +310,17 @@ export class TransactionSignError extends Data.TaggedError( )<{ readonly batchId: string; readonly message: string; - readonly network: string; + readonly network: Network; readonly reason: TransactionSignFailureReason; - readonly transactionId: string | null; + readonly transactionId: TransactionWorkflowTransactionId | null; readonly workflowId: TransactionWorkflowId; }> {} export const makeTransactionSignError = (input: { readonly batchId: string; - readonly network: string; + readonly network: Network; readonly reason: TransactionSignFailureReason; - readonly transactionId: string | null; + readonly transactionId: TransactionWorkflowTransactionId | null; readonly workflowId: TransactionWorkflowId; }): TransactionSignError => new TransactionSignError({ @@ -344,7 +354,7 @@ export class TransactionSubmissionError extends Data.TaggedError( export class TransactionConfirmationError extends Data.TaggedError( "TransactionConfirmationError" -) {} +) {} export class TransactionAdvanceError extends Data.TaggedError( "TransactionAdvanceError" @@ -366,20 +376,31 @@ export type TransactionWorkflowError = export type TransactionWorkflowSubmission = { readonly batchId: string; - readonly hash: string | null; + readonly hash: TransactionHash | null; readonly link: string | null; - readonly signedPayload: string | null; + readonly signedPayload: SignedTransactionPayload | WalletSignature | null; readonly source: TransactionWorkflowSource; readonly status: string | null; - readonly transactionId: string; + readonly transactionId: TransactionWorkflowTransactionId; }; -type TransactionWorkflowTransactionMeta = { - readonly broadcasted: boolean | null; +/** + * What the wallet returned. A broadcast transaction holds its hash, or none + * when only its API status says it was broadcast; a signed one holds the + * payload, or for a message or typed data the signature, an API submits. + */ +type TransactionWorkflowSignedMeta = + | { readonly broadcasted: null; readonly signedTx: null } + | { readonly broadcasted: true; readonly signedTx: TransactionHash | null } + | { + readonly broadcasted: false; + readonly signedTx: SignedTransactionPayload | WalletSignature; + }; + +type TransactionWorkflowTransactionMeta = TransactionWorkflowSignedMeta & { readonly confirmationError: TransactionConfirmationError | null; readonly done: boolean; readonly signError: TransactionSignError | null; - readonly signedTx: string | null; readonly submissionIndex: number | null; readonly url: string | null; }; diff --git a/packages/widget/src/services/wallet/external-provider.ts b/packages/widget/src/services/wallet/external-provider.ts index 6a066677c..df0211945 100644 --- a/packages/widget/src/services/wallet/external-provider.ts +++ b/packages/widget/src/services/wallet/external-provider.ts @@ -1,4 +1,8 @@ import { Data, Effect, Schema } from "effect"; +import { + TransactionHash, + WalletSignature, +} from "../../domain/identity/identifiers"; import type { SKBorrowTx, SKTx, @@ -30,15 +34,18 @@ export type CurrentRef = { }; /** A host call: its broadcast hash or signature, or why it failed. */ -type HostOutcome = Effect.Effect< - string, +type HostOutcome = Effect.Effect< + A, ExternalProviderError | ExternalProviderCancelled >; export class ExternalProvider { constructor(private variantProvider: CurrentRef) {} - sendTransaction(tx: SKTx, txMeta: YieldTransactionMeta): HostOutcome { + sendTransaction( + tx: SKTx, + txMeta: YieldTransactionMeta + ): HostOutcome { return Effect.suspend(() => { const sendTransaction = this.variantProvider.current.provider.sendTransaction; @@ -70,7 +77,7 @@ export class ExternalProvider { sendBorrowTransaction( tx: SKBorrowTx, txMeta: BorrowTransactionMeta - ): HostOutcome { + ): HostOutcome { return Effect.suspend(() => { const config = this.variantProvider.current; if (!config.borrow) { @@ -101,7 +108,7 @@ export class ExternalProvider { }); } - signMessage(messageHash: string): HostOutcome { + signMessage(messageHash: string): HostOutcome { return Effect.suspend(() => callHost( () => this.variantProvider.current.provider.signMessage(messageHash), @@ -121,7 +128,7 @@ export class ExternalProvider { signTypedData( typedData: Eip712TypedData, txMeta: BorrowTransactionMeta - ): HostOutcome { + ): HostOutcome { return Effect.suspend(() => { const config = this.variantProvider.current; if (!config.borrow) { @@ -147,7 +154,7 @@ export class ExternalProvider { signZamaPermit( typedData: Eip712TypedData, meta: SKZamaPermitMeta - ): HostOutcome { + ): HostOutcome { return Effect.suspend(() => { const provider = this.variantProvider.current.provider; if (!hasZamaPermitSigner(provider)) { @@ -187,17 +194,17 @@ const SignatureError = Schema.Struct({ */ const TransactionError = Schema.Struct({ ...SignatureError.fields, - txHash: Schema.optional(Schema.NonEmptyString), + txHash: Schema.optional(TransactionHash), }); const TransactionSuccess = Schema.Struct({ type: Schema.Literal("success"), - txHash: Schema.NonEmptyString, + txHash: TransactionHash, }); const SignatureSuccess = Schema.Struct({ type: Schema.Literal("success"), - signature: Schema.NonEmptyString, + signature: WalletSignature, }); const TransactionOutcome = Schema.Union([ @@ -214,12 +221,12 @@ const SignatureOutcome = Schema.Union([ /** Yield callbacks also accept the legacy bare string. */ const LegacyTransactionOutcome = Schema.Union([ - Schema.NonEmptyString, + TransactionHash, ...TransactionOutcome.members, ]); const LegacySignatureOutcome = Schema.Union([ - Schema.NonEmptyString, + WalletSignature, ...SignatureOutcome.members, ]); @@ -230,7 +237,7 @@ const LegacySignatureOutcome = Schema.Union([ */ const toTransactionHash = (family: "borrow" | "yield") => - (result: typeof TransactionOutcome.Type): HostOutcome => { + (result: typeof TransactionOutcome.Type): HostOutcome => { if (result.type === "success") return Effect.succeed(result.txHash); if (result.type === "error" && result.txHash !== undefined) { return Effect.logWarning( @@ -243,7 +250,9 @@ const toTransactionHash = return failHost(result); }; -const toSignature = (result: typeof SignatureOutcome.Type): HostOutcome => +const toSignature = ( + result: typeof SignatureOutcome.Type +): HostOutcome => result.type === "success" ? Effect.succeed(result.signature) : failHost(result); @@ -270,7 +279,7 @@ const callHost = ( const failHost = ( result: typeof HostCancelled.Type | typeof SignatureError.Type -): HostOutcome => +): Effect.Effect => result.type === "cancelled" ? Effect.fail(new ExternalProviderCancelled()) : Effect.fail(hostResultError(result.error)); diff --git a/packages/widget/src/services/wallet/init-params.ts b/packages/widget/src/services/wallet/init-params.ts index 2cf007d42..1b9bdc4af 100644 --- a/packages/widget/src/services/wallet/init-params.ts +++ b/packages/widget/src/services/wallet/init-params.ts @@ -1,6 +1,8 @@ import { Effect, Schema, SchemaGetter, SchemaTransformation } from "effect"; -import { YieldId } from "../../domain/identity/identifiers"; +import { YieldPendingActionType } from "../../domain/action/pending-action"; +import { ValidatorAddress, YieldId } from "../../domain/identity/identifiers"; import { Network } from "../../domain/network/network"; +import { BalanceDataKey } from "../../domain/portfolio/positions"; import { HostTokenKey } from "../../domain/token/token"; import { isWalletNetwork, @@ -18,12 +20,10 @@ const SafeQueryParam = Schema.String.check( // Position balance keys (`default`, `validators`, `validator::
[:]`) // contain `:`, which is safe inside a route segment; `/`, `%`, `?` and `..` stay out. -const BalanceIdParam = Schema.String.check( +const BalanceIdParam = BalanceDataKey.check( Schema.isPattern(/^(?!.*\.\.)[a-zA-Z0-9-_.+:]*$/) ); -const PendingActionType = SafeQueryParam.check(Schema.isPattern(/^[A-Z_]+$/)); - const InitTab = invalidAsNull( Schema.Literals(["earn", "positions", "manage", "activity", "borrow"]) ); @@ -94,10 +94,10 @@ export const InitParams = Schema.Struct({ accountId: invalidAsNull(AccountId), balanceId: invalidAsNull(BalanceIdParam), network: invalidAsNull(SupportedNetwork), - pendingaction: invalidAsNull(PendingActionType), + pendingaction: invalidAsNull(YieldPendingActionType), tab: InitTab, token: invalidAsNull(InitToken), - validator: invalidAsNull(Schema.String), + validator: invalidAsNull(ValidatorAddress), yieldId: invalidAsNull(SafeYieldId), }); export type InitParams = typeof InitParams.Type; diff --git a/packages/widget/src/services/wallet/internal/adapters/cardano/driver.ts b/packages/widget/src/services/wallet/internal/adapters/cardano/driver.ts index 3cf83180f..11ef5325a 100644 --- a/packages/widget/src/services/wallet/internal/adapters/cardano/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/cardano/driver.ts @@ -4,7 +4,10 @@ import { WalletCapabilityUnavailableError, WalletSigningError, } from "../../../wallet-errors"; -import type { WalletSignedPayloadResult } from "../../../wallet-transactions"; +import { + decodeWalletSignedPayloadResult, + type WalletSignedPayloadResult, +} from "../../../wallet-transactions"; import { isCardanoConnector } from "./cardano-connector-meta"; export const makeCardanoWalletDriver = ({ @@ -37,6 +40,6 @@ export const makeCardanoWalletDriver = ({ ) ); - return { broadcasted: false, signedTx }; + return yield* decodeWalletSignedPayloadResult(signedTx); }), }); diff --git a/packages/widget/src/services/wallet/internal/adapters/cosmos/driver.ts b/packages/widget/src/services/wallet/internal/adapters/cosmos/driver.ts index dbf545a91..9968f6ca1 100644 --- a/packages/widget/src/services/wallet/internal/adapters/cosmos/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/cosmos/driver.ts @@ -4,6 +4,7 @@ import { WalletCapabilityUnavailableError, WalletSigningError, } from "../../../wallet-errors"; +import { decodeWalletSignedPayloadResult } from "../../../wallet-transactions"; import { type CosmosChainWallet, isCosmosConnector, @@ -34,6 +35,6 @@ export const makeCosmosWalletDriver = ({ ) ); - return { broadcasted: false, signedTx }; + return yield* decodeWalletSignedPayloadResult(signedTx); }), }); diff --git a/packages/widget/src/services/wallet/internal/adapters/external-provider/driver.ts b/packages/widget/src/services/wallet/internal/adapters/external-provider/driver.ts index c9c096c6d..4fddc7b28 100644 --- a/packages/widget/src/services/wallet/internal/adapters/external-provider/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/external-provider/driver.ts @@ -1,6 +1,7 @@ import { Effect, Result, Schema } from "effect"; import type { Address } from "viem"; import type { Connector } from "wagmi"; +import type { WalletSignature } from "../../../../../domain/identity/identifiers"; import type { Network } from "../../../../../domain/network/network"; import { isEvmWalletNetwork } from "../../../../../domain/wallet/network"; import type { @@ -188,7 +189,7 @@ export const makeExternalProviderWalletDriver = ({ }: { readonly message: string; }): Effect.Effect< - string, + WalletSignature, | WalletCapabilityUnavailableError | WalletRequestCancelledError | WalletSigningError @@ -208,7 +209,7 @@ export const makeExternalProviderWalletDriver = ({ signTypedData: ( input: WalletSignBorrowTypedDataInput ): Effect.Effect< - string, + WalletSignature, | WalletCapabilityUnavailableError | WalletRequestCancelledError | WalletSigningError @@ -228,7 +229,7 @@ export const makeExternalProviderWalletDriver = ({ signZamaPermit: ( input: WalletSignPermitInput ): Effect.Effect< - string, + WalletSignature, | WalletCapabilityUnavailableError | WalletRequestCancelledError | WalletSigningError diff --git a/packages/widget/src/services/wallet/internal/adapters/ledger/driver.ts b/packages/widget/src/services/wallet/internal/adapters/ledger/driver.ts index 56f5fb799..644992ad5 100644 --- a/packages/widget/src/services/wallet/internal/adapters/ledger/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/ledger/driver.ts @@ -7,9 +7,9 @@ import { WalletDecodeError, WalletSwitchError, } from "../../../wallet-errors"; -import type { - WalletBroadcastResult, - WalletSignTransactionInput, +import { + decodeWalletBroadcastResult, + type WalletSignTransactionInput, } from "../../../wallet-transactions"; import { isLedgerLiveConnector, @@ -71,10 +71,7 @@ export const makeLedgerWalletDriver = ({ new WalletBroadcastError({ cause, customMessage: null }), }); - return { - broadcasted: true, - signedTx: signedTx as string, - } satisfies WalletBroadcastResult; + return yield* decodeWalletBroadcastResult(signedTx); }), switchAccount: ({ account }: WalletSwitchAccountInput) => requireLedgerConnector(connector).pipe( diff --git a/packages/widget/src/services/wallet/internal/adapters/safe/driver.ts b/packages/widget/src/services/wallet/internal/adapters/safe/driver.ts index e9bc0c944..13f532df2 100644 --- a/packages/widget/src/services/wallet/internal/adapters/safe/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/safe/driver.ts @@ -6,7 +6,10 @@ import { WalletCapabilityUnavailableError, WalletDecodeError, } from "../../../wallet-errors"; -import type { WalletBroadcastResult } from "../../../wallet-transactions"; +import { + decodeWalletBroadcastResult, + type WalletBroadcastResult, +} from "../../../wallet-transactions"; import { decodeAndPrepareBorrowEvmTransaction, decodeAndPrepareEvmTransaction, @@ -113,6 +116,6 @@ export const makeSafeWalletDriver = ({ ) ); - return { broadcasted: true, signedTx }; + return yield* decodeWalletBroadcastResult(signedTx); }), }); diff --git a/packages/widget/src/services/wallet/internal/adapters/solana/driver.ts b/packages/widget/src/services/wallet/internal/adapters/solana/driver.ts index 20ad0fd7d..8b819da2f 100644 --- a/packages/widget/src/services/wallet/internal/adapters/solana/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/solana/driver.ts @@ -5,7 +5,10 @@ import { WalletCapabilityUnavailableError, WalletDecodeError, } from "../../../wallet-errors"; -import type { WalletBroadcastResult } from "../../../wallet-transactions"; +import { + decodeWalletBroadcastResult, + type WalletBroadcastResult, +} from "../../../wallet-transactions"; import { isSolanaConnector } from "./solana-connector-meta"; import { unsignedSolanaTransactionCodec } from "./transaction"; @@ -39,6 +42,6 @@ export const makeSolanaWalletDriver = ({ new WalletBroadcastError({ cause, customMessage: null }), }); - return { broadcasted: true, signedTx }; + return yield* decodeWalletBroadcastResult(signedTx); }), }); diff --git a/packages/widget/src/services/wallet/internal/adapters/stellar/driver.ts b/packages/widget/src/services/wallet/internal/adapters/stellar/driver.ts index 7984dc2c6..7838d8ff7 100644 --- a/packages/widget/src/services/wallet/internal/adapters/stellar/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/stellar/driver.ts @@ -6,7 +6,10 @@ import { WalletIntegrationError, WalletSigningError, } from "../../../wallet-errors"; -import type { WalletSignTransactionInput } from "../../../wallet-transactions"; +import { + decodeWalletSignedPayloadResult, + type WalletSignTransactionInput, +} from "../../../wallet-transactions"; import { isStellarConnector } from "./stellar-connector-meta"; const stellarMainnetIdentity = getProtocolChainIdentity("stellar"); @@ -50,7 +53,7 @@ export const makeStellarWalletDriver = ({ }); } - return { broadcasted: false, signedTx: result.signedTxXdr }; + return yield* decodeWalletSignedPayloadResult(result.signedTxXdr); } ); diff --git a/packages/widget/src/services/wallet/internal/adapters/substrate/driver.ts b/packages/widget/src/services/wallet/internal/adapters/substrate/driver.ts index 8757e8306..fc27371be 100644 --- a/packages/widget/src/services/wallet/internal/adapters/substrate/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/substrate/driver.ts @@ -5,6 +5,7 @@ import { WalletDecodeError, WalletSigningError, } from "../../../wallet-errors"; +import { decodeWalletSignedPayloadResult } from "../../../wallet-transactions"; import { isSubstrateConnector } from "./substrate-connector-meta"; import { substratePayloadCodec } from "./transaction"; @@ -41,6 +42,6 @@ export const makeSubstrateWalletDriver = ({ ) ); - return { broadcasted: false, signedTx }; + return yield* decodeWalletSignedPayloadResult(signedTx); }), }); diff --git a/packages/widget/src/services/wallet/internal/adapters/ton/driver.ts b/packages/widget/src/services/wallet/internal/adapters/ton/driver.ts index c6961da7c..822f14cec 100644 --- a/packages/widget/src/services/wallet/internal/adapters/ton/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/ton/driver.ts @@ -4,7 +4,10 @@ import { WalletBroadcastError, WalletCapabilityUnavailableError, } from "../../../wallet-errors"; -import type { WalletBroadcastResult } from "../../../wallet-transactions"; +import { + decodeWalletBroadcastResult, + type WalletBroadcastResult, +} from "../../../wallet-transactions"; import { isTonConnector } from "./ton-connector-meta"; export const makeTonWalletDriver = ({ @@ -36,6 +39,6 @@ export const makeTonWalletDriver = ({ ) ); - return { broadcasted: true, signedTx }; + return yield* decodeWalletBroadcastResult(signedTx); }), }); diff --git a/packages/widget/src/services/wallet/internal/adapters/tron/driver.ts b/packages/widget/src/services/wallet/internal/adapters/tron/driver.ts index 830b7e1bb..849883acb 100644 --- a/packages/widget/src/services/wallet/internal/adapters/tron/driver.ts +++ b/packages/widget/src/services/wallet/internal/adapters/tron/driver.ts @@ -5,7 +5,10 @@ import { WalletDecodeError, WalletSigningError, } from "../../../wallet-errors"; -import type { WalletSignedPayloadResult } from "../../../wallet-transactions"; +import { + decodeWalletSignedPayloadResult, + type WalletSignedPayloadResult, +} from "../../../wallet-transactions"; import { unsignedTronTransactionCodec } from "./transaction"; import { isTronConnector } from "./tron-connector-meta"; @@ -44,6 +47,6 @@ export const makeTronWalletDriver = ({ new WalletSigningError({ cause, operation: "transaction" }), }); - return { broadcasted: false, signedTx }; + return yield* decodeWalletSignedPayloadResult(signedTx); }), }); diff --git a/packages/widget/src/services/wallet/internal/runtime/wagmi-actions.ts b/packages/widget/src/services/wallet/internal/runtime/wagmi-actions.ts index 7fbf4e6b6..155147f83 100644 --- a/packages/widget/src/services/wallet/internal/runtime/wagmi-actions.ts +++ b/packages/widget/src/services/wallet/internal/runtime/wagmi-actions.ts @@ -14,9 +14,10 @@ import { WalletSigningError, WalletSwitchError, } from "../../wallet-errors"; -import type { - WalletBroadcastResult, - WalletEvmTransactionInput, +import { + decodeWalletBroadcastResult, + decodeWalletSignature, + type WalletEvmTransactionInput, } from "../../wallet-transactions"; import { WagmiOperations } from "../platform/wagmi-operations"; @@ -76,13 +77,7 @@ export const makeWagmiActions = Effect.gen(function* () { customMessage: null, }) ), - Effect.map( - (signedTx) => - ({ - broadcasted: true, - signedTx, - }) satisfies WalletBroadcastResult - ) + Effect.flatMap(decodeWalletBroadcastResult) ); }), signMessage: Effect.fn("signMessage")(function* ( @@ -101,6 +96,9 @@ export const makeWagmiActions = Effect.gen(function* () { cause: error.cause, operation: "message", }) + ), + Effect.flatMap((signature) => + decodeWalletSignature(signature, "message") ) ); }), @@ -116,6 +114,9 @@ export const makeWagmiActions = Effect.gen(function* () { cause: error.cause, operation: "typed-data", }) + ), + Effect.flatMap((signature) => + decodeWalletSignature(signature, "typed-data") ) ); }), diff --git a/packages/widget/src/services/wallet/wallet-transactions.ts b/packages/widget/src/services/wallet/wallet-transactions.ts index 104331aae..b606d7f06 100644 --- a/packages/widget/src/services/wallet/wallet-transactions.ts +++ b/packages/widget/src/services/wallet/wallet-transactions.ts @@ -1,3 +1,4 @@ +import { Effect, Schema } from "effect"; import type { Address, Hex } from "viem"; import type { Connector } from "wagmi"; import type { @@ -6,9 +7,15 @@ import type { } from "../../domain/action/models"; import type { Action as BorrowAction } from "../../domain/borrow/execution/action"; import type { Transaction as BorrowTransaction } from "../../domain/borrow/execution/transaction"; +import { + SignedTransactionPayload, + TransactionHash, + WalletSignature, +} from "../../domain/identity/identifiers"; import type { Network } from "../../domain/network/network"; import type { Token } from "../../domain/token/token"; import * as LegacyApi from "../../generated/api/legacy-schema"; +import { WalletBroadcastError, WalletSigningError } from "./wallet-errors"; type YieldProviderDetail = { readonly name: string; @@ -41,6 +48,8 @@ export type YieldTransactionMeta = YieldActionMeta & { readonly txType: ActionTransaction["type"]; }; +type BorrowRawArguments = NonNullable; + export type BorrowTransactionMeta = { readonly actionId: BorrowAction["id"]; readonly actionType: BorrowAction["action"]; @@ -52,11 +61,11 @@ export type BorrowTransactionMeta = { readonly borrowAmount?: string; readonly collateralAmount?: string; readonly collateralAmountRaw?: string; - readonly collateralTokenAddress?: string; - readonly marketId: string; + readonly collateralTokenAddress?: BorrowRawArguments["collateralTokenAddress"]; + readonly marketId: BorrowRawArguments["marketId"]; readonly repayAll?: boolean; readonly targetLtv?: string; - readonly tokenAddress?: string; + readonly tokenAddress?: BorrowRawArguments["tokenAddress"]; }; readonly txId: BorrowTransaction["id"]; readonly txType: BorrowTransaction["type"]; @@ -111,10 +120,41 @@ export type WalletSignTransactionInput = WalletSignTransactionInputFields & export type WalletSignedPayloadResult = { readonly broadcasted: false; - readonly signedTx: string; + readonly signedTx: SignedTransactionPayload; }; export type WalletBroadcastResult = { readonly broadcasted: true; - readonly signedTx: string; + readonly signedTx: TransactionHash; }; + +/** What a wallet answered for a transaction it signed and broadcast. */ +export const decodeWalletBroadcastResult = ( + hash: unknown +): Effect.Effect => + Schema.decodeUnknownEffect(TransactionHash)(hash).pipe( + Effect.map((signedTx) => ({ broadcasted: true, signedTx }) as const), + Effect.mapError( + (cause) => new WalletBroadcastError({ cause, customMessage: null }) + ) + ); + +/** What a wallet answered for a transaction it signed but did not broadcast. */ +export const decodeWalletSignedPayloadResult = ( + payload: unknown +): Effect.Effect => + Schema.decodeUnknownEffect(SignedTransactionPayload)(payload).pipe( + Effect.map((signedTx) => ({ broadcasted: false, signedTx }) as const), + Effect.mapError( + (cause) => new WalletSigningError({ cause, operation: "transaction" }) + ) + ); + +/** The signature a wallet answered for a message or typed data. */ +export const decodeWalletSignature = ( + signature: unknown, + operation: "message" | "typed-data" +): Effect.Effect => + Schema.decodeUnknownEffect(WalletSignature)(signature).pipe( + Effect.mapError((cause) => new WalletSigningError({ cause, operation })) + ); diff --git a/packages/widget/src/services/zama/revealed-balances.ts b/packages/widget/src/services/zama/revealed-balances.ts index 4c187788b..6ac3a6647 100644 --- a/packages/widget/src/services/zama/revealed-balances.ts +++ b/packages/widget/src/services/zama/revealed-balances.ts @@ -8,10 +8,10 @@ import { SubscriptionRef, } from "effect"; import type { - ConfidentialHandle, ConfidentialTokenBalanceKey, RevealedBalances as Values, } from "../../domain/earn/confidential"; +import type { ConfidentialHandle } from "../../domain/earn/models"; import type { WalletScopeOwner } from "../../domain/wallet/wallet-scope"; type RevealedBalancesApi = Readonly<{ diff --git a/packages/widget/src/services/zama/zama-service.ts b/packages/widget/src/services/zama/zama-service.ts index fe24edad7..31b94a1d1 100644 --- a/packages/widget/src/services/zama/zama-service.ts +++ b/packages/widget/src/services/zama/zama-service.ts @@ -20,12 +20,14 @@ import { erc20Abi, type Hex, http, + isHex, type PublicClient, parseAbi, parseEventLogs, } from "viem"; import { type DirectTransaction, + DirectTransactionId, makeErc20Approval, } from "../../domain/action/direct-transaction"; import type { @@ -36,13 +38,16 @@ import { type ConfidentialConversion, type ConfidentialEncryption, type ConfidentialEncryptionPlan, - ConfidentialHandle, ConfidentialTokenBalanceKey, getConfidentialNetwork, planConfidentialEncryption, toConfidentialArguments, } from "../../domain/earn/confidential"; -import type { EarnYieldWithProvider } from "../../domain/earn/models"; +import { + ConfidentialHandle, + type EarnYieldWithProvider, +} from "../../domain/earn/models"; +import type { TransactionHash } from "../../domain/identity/identifiers"; import type { Network } from "../../domain/network/network"; import type { Token } from "../../domain/token/token"; import { WidgetConfigService } from "../config/widget-config"; @@ -57,6 +62,12 @@ class ZamaError extends Data.TaggedError("ZamaError")<{ readonly operation: string; }> {} +// The SDK returns hex handles; the domain keeps them branded. +const EncryptedHandles = Schema.Struct({ + amount: Schema.NullOr(ConfidentialHandle), + legs: Schema.NullOr(Schema.Array(ConfidentialHandle)), +}); + /** * How a confidential vault's action command gets its hidden amount: the * amount encrypted for the signing wallet, or a withdrawal decrypted publicly. @@ -171,7 +182,7 @@ type ZamaServiceApi = Readonly<{ */ readonly readUnwrapRequest: (input: { readonly chainId: number; - readonly hash: string; + readonly hash: TransactionHash; readonly owner: string; }) => Effect.Effect; readonly revealHandles: (input: { @@ -309,7 +320,14 @@ const makeZamaService = Effect.fn("makeZamaService")(function* () { }, } satisfies Eip712TypedData, }); - return signature as Hex; + // viem's signer seam takes hex; an EVM wallet signs in hex. + if (!isHex(signature)) { + return yield* new ZamaError({ + cause: "The wallet returned a non-hex permit signature", + operation: "permit", + }); + } + return signature; }); const makeSigner = ( @@ -427,16 +445,22 @@ const makeZamaService = Effect.fn("makeZamaService")(function* () { ? encryptValues(plan.legs.contract, plan.legs.values) : Effect.succeed(null), ]); - const amountHandle = amount?.encryptedValues[0]; + const handles = yield* Schema.decodeEffect(EncryptedHandles)({ + amount: amount?.encryptedValues[0] ?? null, + legs: legs?.encryptedValues ?? null, + }).pipe( + Effect.mapError((cause) => new ZamaError({ cause, operation: "encrypt" })) + ); return { amount: - amount && amountHandle - ? { handle: amountHandle, proof: amount.inputProof } + amount && handles.amount + ? { handle: handles.amount, proof: amount.inputProof } + : null, + legs: + legs && handles.legs + ? { handles: handles.legs, proof: legs.inputProof } : null, - legs: legs - ? { handles: legs.encryptedValues, proof: legs.inputProof } - : null, } satisfies ConfidentialEncryption; }); @@ -518,7 +542,7 @@ const makeZamaService = Effect.fn("makeZamaService")(function* () { makeErc20Approval({ amount, chainId: conversion.chainId, - id: "conversion-approval", + id: DirectTransactionId.make("conversion-approval"), network, type, ...allowance, @@ -531,7 +555,7 @@ const makeZamaService = Effect.fn("makeZamaService")(function* () { args: [allowance.owner, amount], functionName: "wrap", }), - id: "conversion-wrap", + id: DirectTransactionId.make("conversion-wrap"), network, to: allowance.spender, type: "WRAP", @@ -698,11 +722,18 @@ const makeZamaService = Effect.fn("makeZamaService")(function* () { const readUnwrapRequest: ZamaServiceApi["readUnwrapRequest"] = Effect.fn( "ZamaService.readUnwrapRequest" )(function* ({ chainId, hash, owner }) { + // viem takes hex, and an EVM transaction's hash is in it. + if (!isHex(hash)) { + return yield* unpreparable( + "The unwrap transaction hash is not hex.", + hash + ); + } const client = yield* getPublicClient(chainId).pipe( Effect.mapError(retryableFailure) ); const receipt = yield* tryZama("read-unwrap-receipt", () => - client.getTransactionReceipt({ hash: hash as Hex }) + client.getTransactionReceipt({ hash }) ).pipe(Effect.mapError(retryableFailure)); const [requested] = parseEventLogs({ abi: wrapperAbi, diff --git a/packages/widget/src/shared/lib/formatters.ts b/packages/widget/src/shared/lib/formatters.ts index 849647945..64661cf93 100644 --- a/packages/widget/src/shared/lib/formatters.ts +++ b/packages/widget/src/shared/lib/formatters.ts @@ -42,7 +42,7 @@ export const getGasFeeInUSD = ({ if (!yieldDto || !gas) return ""; const gasFeeInUSD = getTokenPriceInUSD({ - amount: gas.toString(), + amount: gas, prices: prices ?? new Prices(new Map()), token: yieldDto.mechanics.gasFeeToken, pricePerShare: null, diff --git a/packages/widget/tests/app/journeys/deep-links-flow/param-validation.dom.test.tsx b/packages/widget/tests/app/journeys/deep-links-flow/param-validation.dom.test.tsx index fbc77ac2a..60b8e217d 100644 --- a/packages/widget/tests/app/journeys/deep-links-flow/param-validation.dom.test.tsx +++ b/packages/widget/tests/app/journeys/deep-links-flow/param-validation.dom.test.tsx @@ -96,6 +96,13 @@ describe("Deep link param validation", () => { await setAndAssertIsValidPendingActionParam("UNSTAKE/../", false); }); + it("decodes an unknown pendingaction as null", () => { + expect( + decodeHref("https://host.test/?pendingaction=NOT_A_PENDING_ACTION") + .pendingaction + ).toBeNull(); + }); + it("Keeps valid params when another param is invalid", () => { const url = new URL(window.location.href); url.searchParams.set("network", "not-supported"); @@ -199,6 +206,9 @@ describe("Deep link param validation", () => { "../positions", "validator::a%2Fb", "validator::a?b", + "balance-1", + "validator::", + "validator:abc", ]) { const url = new URL("https://host.test/"); url.searchParams.set("balanceId", balanceId); diff --git a/packages/widget/tests/app/journeys/deep-links-flow/setup.ts b/packages/widget/tests/app/journeys/deep-links-flow/setup.ts index b8b07cb41..deb3ae0a1 100644 --- a/packages/widget/tests/app/journeys/deep-links-flow/setup.ts +++ b/packages/widget/tests/app/journeys/deep-links-flow/setup.ts @@ -2,6 +2,7 @@ import { Array as EArray } from "effect"; import { HttpResponse, http } from "msw"; import { vitest } from "vitest"; import type { ManageActionCommand } from "../../../../src/domain/action/models"; +import { TransactionHash } from "../../../../src/domain/identity/identifiers"; import { legacyYieldFixture, yieldApiActionFixture, @@ -434,7 +435,7 @@ export const setup = async ( network: EArray.getUnsafe(pendingAction.transactions, 0).network, status: "BROADCASTED", id: transactionId, - hash: "transaction_hash", + hash: TransactionHash.make("transaction_hash"), }), }); } @@ -448,7 +449,9 @@ export const setup = async ( explorerUrl: "https://snowtrace.dev/tx/0x5c2e4ac81fa12b8e935e1cf5e39eda4594d75e82da0c9b44c6d85f20214452fb", network: avaxLiquidStaking.token.network, - hash: "0x5c2e4ac81fa12b8e935e1cf5e39eda4594d75e82da0c9b44c6d85f20214452fb", + hash: TransactionHash.make( + "0x5c2e4ac81fa12b8e935e1cf5e39eda4594d75e82da0c9b44c6d85f20214452fb" + ), status: "CONFIRMED", }), }); diff --git a/packages/widget/tests/app/journeys/staking-flow/setup.ts b/packages/widget/tests/app/journeys/staking-flow/setup.ts index a93bd5dc6..98aae6296 100644 --- a/packages/widget/tests/app/journeys/staking-flow/setup.ts +++ b/packages/widget/tests/app/journeys/staking-flow/setup.ts @@ -4,6 +4,7 @@ import { avalanche } from "viem/chains"; import { vitest } from "vitest"; import type { ActionCommand } from "../../../../src/domain/action/models"; import { EarnYieldWithProvider } from "../../../../src/domain/earn/models"; +import { TransactionHash } from "../../../../src/domain/identity/identifiers"; import type { LegacyTransaction } from "../../../../src/services/wallet/wallet-transactions"; import { type LegacyYieldFixture, @@ -401,7 +402,7 @@ export const setup = async ( id: transactionId, network: transactionConstruct.network, type: "STAKE", - hash: "transaction_hash", + hash: TransactionHash.make("transaction_hash"), status: "BROADCASTED", unsignedTransaction: transactionConstruct.unsignedTransaction, gasEstimate: transactionConstruct.gasEstimate @@ -420,7 +421,9 @@ export const setup = async ( type: "STAKE", explorerUrl: "https://snowtrace.dev/tx/0x5c2e4ac81fa12b8e935e1cf5e39eda4594d75e82da0c9b44c6d85f20214452fb", - hash: "0x5c2e4ac81fa12b8e935e1cf5e39eda4594d75e82da0c9b44c6d85f20214452fb", + hash: TransactionHash.make( + "0x5c2e4ac81fa12b8e935e1cf5e39eda4594d75e82da0c9b44c6d85f20214452fb" + ), status: "CONFIRMED", unsignedTransaction: transactionConstruct.unsignedTransaction, gasEstimate: transactionConstruct.gasEstimate diff --git a/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx b/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx index 2bebf1d77..933b1c3fd 100644 --- a/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx +++ b/packages/widget/tests/app/layouts/compact/compact-position-details-redirects.dom.test.tsx @@ -70,59 +70,57 @@ const renderAt = (path: string) => describe("compact position details redirects", () => { it("keeps the bare hub", async () => { - const app = await renderAt("/positions/yield-1/balance-1"); + const app = await renderAt("/positions/yield-1/default"); expect(textFor(app.container, "hub")).toBe("hub"); expect(textFor(app.container, "location")).toBe( - "/positions/yield-1/balance-1" + "/positions/yield-1/default" ); }); it.each(["stake", "unstake", "pending-action"] as const)( "redirects bare %s index to the hub", async (suffix) => { - const app = await renderAt(`/positions/yield-1/balance-1/${suffix}`); + const app = await renderAt(`/positions/yield-1/default/${suffix}`); expect(textFor(app.container, "hub")).toBe("hub"); expect(textFor(app.container, "location")).toBe( - "/positions/yield-1/balance-1" + "/positions/yield-1/default" ); } ); it.each([ - "/positions/yield-1/balance-1/stake/bogus", - "/positions/yield-1/balance-1/unstake/bogus", - "/positions/yield-1/balance-1/pending-action/bogus", + "/positions/yield-1/default/stake/bogus", + "/positions/yield-1/default/unstake/bogus", + "/positions/yield-1/default/pending-action/bogus", ] as const)("redirects illegal child %s to the hub", async (path) => { const app = await renderAt(path); expect(textFor(app.container, "hub")).toBe("hub"); expect(textFor(app.container, "location")).toBe( - "/positions/yield-1/balance-1" + "/positions/yield-1/default" ); }); it("redirects unknown position segments to the hub, not home", async () => { - const app = await renderAt("/positions/yield-1/balance-1/bogus"); + const app = await renderAt("/positions/yield-1/default/bogus"); expect(textFor(app.container, "hub")).toBe("hub"); expect(textFor(app.container, "home")).toBeNull(); expect(textFor(app.container, "location")).toBe( - "/positions/yield-1/balance-1" + "/positions/yield-1/default" ); }); it("still reaches real flow children", async () => { - const unstake = await renderAt( - "/positions/yield-1/balance-1/unstake/review" - ); + const unstake = await renderAt("/positions/yield-1/default/unstake/review"); expect( unstake.container.querySelector('[data-testid="unstake-review"]') ).not.toBeNull(); const pending = await renderAt( - "/positions/yield-1/balance-1/pending-action/review" + "/positions/yield-1/default/pending-action/review" ); expect( pending.container.querySelector('[data-testid="pending-review"]') diff --git a/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts b/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts index 252859cfb..7f01d7c0f 100644 --- a/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts +++ b/packages/widget/tests/app/runtime/pending-action-deep-link-route.test.ts @@ -9,6 +9,7 @@ import { WalletAddress, YieldId, } from "../../../src/domain/identity/identifiers"; +import { BalanceDataKey } from "../../../src/domain/portfolio/positions"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { findPendingActionDeepLinkTarget } from "../../../src/features/earn/model/pending-action-deep-link"; import { yieldFlowRoutes } from "../../../src/features/yield-transaction-flow/model/yield-transaction-flow"; @@ -40,6 +41,7 @@ const otherAddress = Schema.decodeSync(WalletAddress)( ); const scope = new WalletScopeKey({ address, network: "ethereum" }); const yieldId = Schema.decodeSync(YieldId)("yield-1"); +const decodeBalanceDataKey = Schema.decodeSync(BalanceDataKey); const pendingObservation = (ownerScope = scope): DeepLinkRouteObservation => ({ maintenance: false, @@ -49,7 +51,7 @@ const pendingObservation = (ownerScope = scope): DeepLinkRouteObservation => ({ intake: { walletScope: ownerScope } as never, mount: { _tag: "PositionManage", - balanceId: "balance-1", + balanceId: "default", integrationId: yieldId, }, }, @@ -58,7 +60,7 @@ const pendingObservation = (ownerScope = scope): DeepLinkRouteObservation => ({ network: ownerScope.network, pendingAction: "CLAIM_REWARDS", validator: null, - yieldId: "yield-1", + yieldId, }, walletScope: ownerScope, }, @@ -176,11 +178,11 @@ describe("DeepLinkCoordinator", () => { expect(pending.pendingAction?._tag).toBe("StartYieldFlow"); expect(started).toHaveLength(1); expect(started[0]?.path).toBe( - "/positions/yield-1/balance-1/pending-action/review" + "/positions/yield-1/default/pending-action/review" ); expect(started[0]?.session.mount).toEqual({ _tag: "PositionManage", - balanceId: "balance-1", + balanceId: "default", integrationId: "yield-1", }); expect(started[0]?.session.intake.walletScope).toEqual(scope); @@ -254,7 +256,7 @@ describe("DeepLinkCoordinator", () => { const position = { maintenance: false, pendingAction: null, - position: { balanceId: "balance-1", yieldId }, + position: { balanceId: "default", yieldId }, ready: true, } satisfies DeepLinkRouteObservation; @@ -271,7 +273,7 @@ describe("DeepLinkCoordinator", () => { expect(navigate).toHaveBeenCalledTimes(2); expect(navigate).toHaveBeenCalledWith( - "/positions/yield-1/balance-1", + "/positions/yield-1/default", expect.anything() ); }) @@ -286,7 +288,7 @@ describe("DeepLinkCoordinator", () => { { maintenance: false, pendingAction: null, - position: { balanceId: "balance-1", yieldId }, + position: { balanceId: "default", yieldId }, ready: true, }, ], @@ -326,7 +328,7 @@ describe("findPendingActionDeepLinkTarget", () => { it("targets the balance matching balanceId even when an earlier balance has the same action", () => { const target = findPendingActionDeepLinkTarget({ - balanceId: "validator::validator-b", + balanceId: decodeBalanceDataKey("validator::validator-b"), balances, pendingActionType: "CLAIM_REWARDS", validator: null, @@ -339,7 +341,7 @@ describe("findPendingActionDeepLinkTarget", () => { it("returns no action for an unknown balanceId", () => { expect( findPendingActionDeepLinkTarget({ - balanceId: "validator::unknown", + balanceId: decodeBalanceDataKey("validator::unknown"), balances, pendingActionType: "CLAIM_REWARDS", validator: null, @@ -350,7 +352,7 @@ describe("findPendingActionDeepLinkTarget", () => { it("returns no action when the matching balance lacks the action type", () => { expect( findPendingActionDeepLinkTarget({ - balanceId: "validator::validator-b", + balanceId: decodeBalanceDataKey("validator::validator-b"), balances, pendingActionType: "UNLOCK_LOCKED", validator: null, diff --git a/packages/widget/tests/app/runtime/transaction-workflow-event-projection.test.ts b/packages/widget/tests/app/runtime/transaction-workflow-event-projection.test.ts index 22e9b5731..a7103f61c 100644 --- a/packages/widget/tests/app/runtime/transaction-workflow-event-projection.test.ts +++ b/packages/widget/tests/app/runtime/transaction-workflow-event-projection.test.ts @@ -15,7 +15,11 @@ import { TestClock } from "effect/testing"; import { transactionWorkflowResourceEventProjection } from "../../../src/app/runtime/transaction-workflow-event-projection"; import { Action } from "../../../src/domain/borrow/execution/action"; import { Transaction } from "../../../src/domain/borrow/execution/transaction"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + SignedTransactionPayload, + TransactionHash, + WalletAddress, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey, walletScopeOwnerKey, @@ -255,14 +259,18 @@ const invalidationsBeforeTeardown = Effect.fn("invalidationsBeforeTeardown")( Effect.succeed({ link: "https://explorer.test/borrow", status: "BROADCASTED", - transactionHash: - "0x1111111111111111111111111111111111111111111111111111111111111111", + transactionHash: TransactionHash.make( + "0x1111111111111111111111111111111111111111111111111111111111111111" + ), }), }, initialWalletState: makeConnectedWalletState(borrowWalletScope), wallet: { signTransaction: () => - Effect.succeed({ broadcasted: false, signedTx: "0xsigned" }), + Effect.succeed({ + broadcasted: false, + signedTx: SignedTransactionPayload.make("0xsigned"), + }), }, }); diff --git a/packages/widget/tests/domain/confidential.test.ts b/packages/widget/tests/domain/confidential.test.ts index 26847f83e..d368b04e5 100644 --- a/packages/widget/tests/domain/confidential.test.ts +++ b/packages/widget/tests/domain/confidential.test.ts @@ -3,7 +3,6 @@ import BigNumber from "bignumber.js"; import { Schema } from "effect"; import { type ConfidentialEncryption, - ConfidentialHandle, fillConfidentialBalance, getCancellingHandles, getCancelReturn, @@ -20,7 +19,10 @@ import { planConfidentialEncryption, toConfidentialArguments, } from "../../src/domain/earn/confidential"; -import type { EarnBalance } from "../../src/domain/earn/models"; +import { + ConfidentialHandle, + type EarnBalance, +} from "../../src/domain/earn/models"; import { Token } from "../../src/domain/token/token"; import { confidentialFixture, @@ -33,9 +35,16 @@ const { cShare, cUsdc, depositBatcher, redeemBatcher, router, usdc } = const confidentialYield = confidentialYieldFixture; const shareToken = Schema.decodeSync(Token)(cShare); +const toHandle = Schema.decodeSync(ConfidentialHandle); +const amountHandle = toHandle(`0x${"01".repeat(32)}`); +const legHandles = [ + toHandle(`0x${"02".repeat(32)}`), + toHandle(`0x${"03".repeat(32)}`), +]; + const encryption: ConfidentialEncryption = { - amount: { handle: "0xamount", proof: "0xamountproof" }, - legs: { handles: ["0xleg0", "0xleg1"], proof: "0xlegproof" }, + amount: { handle: amountHandle, proof: "0xamountproof" }, + legs: { handles: legHandles, proof: "0xlegproof" }, }; describe("confidential vault rules", () => { @@ -45,10 +54,10 @@ describe("confidential vault rules", () => { }); it("publicly decrypts only an unwrap's WITHDRAW, never a batch cancel", () => { - const requestId = `0x${"cd".repeat(32)}`; + const requestId = toHandle(`0x${"cd".repeat(32)}`); const hidden = (type: EarnBalance["type"]) => ({ isEncrypted: true, - shareAmountRaw: requestId, + shares: { _tag: "Encrypted", handle: requestId } as const, type, }); @@ -63,7 +72,7 @@ describe("confidential vault rules", () => { ).toBeNull(); expect( getPendingActionDecryptHandle( - { shareAmountRaw: "5", type: "withdrawable" }, + { shares: { _tag: "Plain", amountRaw: 5n }, type: "withdrawable" }, "WITHDRAW" ) ).toBeNull(); @@ -91,7 +100,7 @@ describe("confidential vault rules", () => { it("counts a cancelled withdrawal's refund in vault shares, not the underlying it is valued in", () => { const refund = getCancelReturn(confidentialYield(), "CANCEL_PENDING", { amount: new BigNumber("0.1"), - shareAmount: "0.097168", + shares: { _tag: "Plain", amount: new BigNumber("0.097168") }, shareToken, type: "exiting", }); @@ -214,9 +223,9 @@ describe("confidential vault rules", () => { ); expect(args).toEqual({ - encryptedAmount: "0xamount", + encryptedAmount: amountHandle, encryptedAmountProof: "0xamountproof", - encryptedLegAmounts: ["0xleg0", "0xleg1"], + encryptedLegAmounts: legHandles, encryptedLegProof: "0xlegproof", inputToken: cUsdc.address, ledgerWalletApiCompatible: true, @@ -279,15 +288,12 @@ describe("confidential vault rules", () => { amountRaw: 0n, amountUsd: null as BigNumber | null, isEncrypted: true, - shareAmount: "0", - shareAmountRaw: handle, + shares: { _tag: "Encrypted", handle: toHandle(handle) } as const, shareToken, token: usdc, }; const revealedShares = (shares: bigint) => - new Map([ - [Schema.decodeSync(ConfidentialHandle)(handle.toUpperCase()), shares], - ]); + new Map([[toHandle(handle.toUpperCase()), shares]]); it("values revealed shares at the share price and the token USD price", () => { const yieldDto = confidentialYieldFixture({ @@ -311,7 +317,12 @@ describe("confidential vault rules", () => { expect(filled.amount.toFixed()).toBe("2.5"); expect(filled.amountUsd?.toFixed()).toBe("5"); - expect(filled.shareAmountRaw).toBe("2000000"); + expect(filled.shares).toEqual({ + _tag: "Revealed", + amount: new BigNumber("2"), + amountRaw: 2_000_000n, + handle, + }); }); it("stays recognisable as its pending position once revealed", () => { @@ -324,7 +335,7 @@ describe("confidential vault rules", () => { }, ]); - expect(filled.isEncrypted).toBeUndefined(); + expect(filled).not.toHaveProperty("isEncrypted"); expect( [hidden, filled].map((balance) => isCancellingBalance(balance, cancelling) diff --git a/packages/widget/tests/domain/earn/earn-models.test.ts b/packages/widget/tests/domain/earn/earn-models.test.ts index c145e116f..325c8a935 100644 --- a/packages/widget/tests/domain/earn/earn-models.test.ts +++ b/packages/widget/tests/domain/earn/earn-models.test.ts @@ -100,6 +100,89 @@ describe("Earn application models", () => { expect(balance.amountUsd?.toFixed()).toBe("12345678901234567890.12"); }); + it.effect("decodes an encrypted balance's shares as its handle", () => + Effect.gen(function* () { + const balance = yield* Schema.decodeEffect(EarnBalance)({ + address: "wallet-1", + type: "active", + amount: "0", + amountRaw: "0", + isEncrypted: true, + pendingActions: [], + shareAmount: "0", + shareAmountRaw: `0x${"AB".repeat(32)}`, + token, + }).pipe(Effect.provide(Logger.layer([]))); + + expect(balance.shares).toEqual({ + _tag: "Encrypted", + handle: `0x${"ab".repeat(32)}`, + }); + }) + ); + + it.effect("decodes a plain balance's shares as exact amounts", () => + Effect.gen(function* () { + const balance = yield* Schema.decodeEffect(EarnBalance)({ + address: "wallet-1", + type: "active", + amount: "1.6", + amountRaw: "1600000", + pendingActions: [], + shareAmount: "1.500000000000000001", + shareAmountRaw: "1500000000000000001", + token, + }).pipe(Effect.provide(Logger.layer([]))); + + expect(balance.shares).toEqual({ + _tag: "Plain", + amount: new BigNumber("1.500000000000000001"), + amountRaw: 1500000000000000001n, + }); + }) + ); + + it.effect("drops only a malformed plain share count", () => + Effect.gen(function* () { + const balance = yield* Schema.decodeEffect(EarnBalance)({ + address: "wallet-1", + type: "active", + amount: "1", + amountRaw: "1", + pendingActions: [], + shareAmount: "2.5", + shareAmountRaw: "not-a-quantity", + token, + }).pipe(Effect.provide(Logger.layer([]))); + + expect(balance.amount.toFixed()).toBe("1"); + expect(balance.shares).toEqual({ + _tag: "Plain", + amount: new BigNumber("2.5"), + }); + }) + ); + + it.effect("drops only a raw share count of the wrong wire type", () => + Effect.gen(function* () { + const balance = yield* Schema.decodeEffect(EarnBalance)({ + address: "wallet-1", + type: "active", + amount: "1", + amountRaw: "1", + pendingActions: [], + shareAmount: "2.5", + shareAmountRaw: { value: 25 }, + token, + }).pipe(Effect.provide(Logger.layer([]))); + + expect(balance.shares).toEqual({ + _tag: "Plain", + amount: new BigNumber("2.5"), + }); + }) + ); + it.effect("safely omits an invalid optional balance timestamp", () => Effect.gen(function* () { const balance = yield* Schema.decodeEffect(EarnBalance)({ @@ -236,6 +319,119 @@ describe("Earn application models", () => { expect(yieldModel.state).toEqual({}); }); + const decodeYieldQuietly = (wire: unknown) => + Schema.decodeUnknownEffect(EarnYield)(wire).pipe( + Effect.provide(Logger.layer([])) + ); + + it.effect("drops only a malformed fee and keeps the yield", () => + Effect.gen(function* () { + const base = yieldApiYieldDtoFixture(); + const yieldModel = yield* decodeYieldQuietly({ + ...base, + mechanics: { + ...base.mechanics, + fee: { deposit: "abc", management: "2.00" }, + }, + }); + + expect(yieldModel.mechanics.fee).toEqual({ + deposit: null, + management: new BigNumber("2"), + }); + }) + ); + + it.effect("drops only a malformed entry limit and keeps the yield", () => + Effect.gen(function* () { + const base = yieldApiYieldDtoFixture(); + const yieldModel = yield* decodeYieldQuietly({ + ...base, + mechanics: { + ...base.mechanics, + entryLimits: { + maximum: null, + minimum: "ten", + subsequentMinimum: "10000", + }, + }, + }); + + expect(yieldModel.mechanics.entryLimits).toEqual({ + maximum: null, + minimum: null, + subsequentMinimum: new BigNumber("10000"), + }); + }) + ); + + it.effect("drops only malformed TVL values and keeps their models", () => + Effect.gen(function* () { + const yieldModel = yield* decodeYieldQuietly({ + ...yieldApiYieldDtoFixture(), + statistics: { tvlUsd: "1,200,000", averagePositionSizeUsd: "3448.27" }, + }); + const provider = yield* Schema.decodeEffect(EarnProvider)({ + id: "stakekit", + name: "StakeKit", + logoURI: "https://stakek.it/logo.svg", + website: "https://stakek.it", + tvlUsd: "", + }).pipe(Effect.provide(Logger.layer([]))); + const validator = yield* Schema.decodeUnknownEffect(EarnValidator)({ + address: "validator-1", + tvl: "lots", + tvlRaw: "8250450000000000000000", + }).pipe(Effect.provide(Logger.layer([]))); + const validatorWithTvl = yield* Schema.decodeEffect(EarnValidator)({ + address: "validator-2", + tvl: "8250.45", + }); + + expect(yieldModel.statistics).toEqual({ + averagePositionSizeUsd: new BigNumber("3448.27"), + tvlUsd: null, + }); + expect(provider.tvlUsd).toBeNull(); + expect(validator.tvl).toBeNull(); + expect(validator).not.toHaveProperty("tvlRaw"); + expect(validatorWithTvl.tvl).toEqual(new BigNumber("8250.45")); + }) + ); + + it.effect( + "drops only a malformed price-per-share state and keeps the yield", + () => + Effect.gen(function* () { + const base = yieldApiYieldDtoFixture(); + const malformed = yield* decodeYieldQuietly({ + ...base, + state: { + pricePerShareState: { + price: "not-a-price", + quoteToken: base.token, + shareToken: base.token, + }, + }, + }); + const priced = yield* decodeYieldQuietly({ + ...base, + state: { + pricePerShareState: { + price: 1.05, + quoteToken: base.token, + shareToken: base.token, + }, + }, + }); + + expect(malformed.state).toEqual({ pricePerShareState: null }); + expect(priced.state?.pricePerShareState?.price).toEqual( + new BigNumber("1.05") + ); + }) + ); + it("decodes advertised provider options as opaque values", () => { const baseYield = yieldApiYieldDtoFixture(); const yieldModel = yieldApiYieldFixture({ diff --git a/packages/widget/tests/domain/earn/reward-rate.test.ts b/packages/widget/tests/domain/earn/reward-rate.test.ts index a57c3585a..183cf0e59 100644 --- a/packages/widget/tests/domain/earn/reward-rate.test.ts +++ b/packages/widget/tests/domain/earn/reward-rate.test.ts @@ -54,7 +54,7 @@ describe("getEffectiveYieldRewardRateDetails", () => { ); const rewardRate = getEffectiveYieldRewardRateDetails({ - selectedValidators: new Map([[validator.address, validator]]), + selectedValidators: new Map([[validator.key, validator]]), yieldDto, }); @@ -89,8 +89,8 @@ describe("getEffectiveYieldRewardRateDetails", () => { const rewardRate = getEffectiveYieldRewardRateDetails({ selectedValidators: new Map([ - [firstValidator.address, firstValidator], - [secondValidator.address, secondValidator], + [firstValidator.key, firstValidator], + [secondValidator.key, secondValidator], ]), yieldDto, }); diff --git a/packages/widget/tests/domain/finance/funded-balance.test.ts b/packages/widget/tests/domain/finance/funded-balance.test.ts index b025fab09..acaa61ee4 100644 --- a/packages/widget/tests/domain/finance/funded-balance.test.ts +++ b/packages/widget/tests/domain/finance/funded-balance.test.ts @@ -5,11 +5,14 @@ import { isFundedBalance } from "../../../src/domain/finance/funded-balance"; describe("isFundedBalance", () => { it("treats a 1-wei balance as dust with or without a price", () => { expect( - isFundedBalance({ amount: "0.000000000000000001", priceUsd: null }) + isFundedBalance({ + amount: new BigNumber("0.000000000000000001"), + priceUsd: null, + }) ).toBe(false); expect( isFundedBalance({ - amount: "0.000000000000000001", + amount: new BigNumber("0.000000000000000001"), priceUsd: new BigNumber(3000), }) ).toBe(false); @@ -17,35 +20,53 @@ describe("isFundedBalance", () => { it("funds a priced balance worth $5", () => { expect( - isFundedBalance({ amount: "0.002", priceUsd: new BigNumber(2500) }) + isFundedBalance({ + amount: new BigNumber("0.002"), + priceUsd: new BigNumber(2500), + }) ).toBe(true); }); it("does not fund a priced balance worth $0.001", () => { expect( - isFundedBalance({ amount: "0.001", priceUsd: new BigNumber(1) }) + isFundedBalance({ + amount: new BigNumber("0.001"), + priceUsd: new BigNumber(1), + }) ).toBe(false); }); it("funds a priced balance worth exactly $0.01", () => { expect( - isFundedBalance({ amount: "0.01", priceUsd: new BigNumber(1) }) + isFundedBalance({ + amount: new BigNumber("0.01"), + priceUsd: new BigNumber(1), + }) ).toBe(true); }); it("uses the unit threshold when no positive price is known", () => { - expect(isFundedBalance({ amount: "0.0000005", priceUsd: null })).toBe( - false - ); - expect(isFundedBalance({ amount: "0.000001", priceUsd: null })).toBe(true); expect( - isFundedBalance({ amount: "0.0000005", priceUsd: new BigNumber(0) }) + isFundedBalance({ amount: new BigNumber("0.0000005"), priceUsd: null }) + ).toBe(false); + expect( + isFundedBalance({ amount: new BigNumber("0.000001"), priceUsd: null }) + ).toBe(true); + expect( + isFundedBalance({ + amount: new BigNumber("0.0000005"), + priceUsd: new BigNumber(0), + }) ).toBe(false); - expect(isFundedBalance({ amount: "5", priceUsd: null })).toBe(true); + expect( + isFundedBalance({ amount: new BigNumber("5"), priceUsd: null }) + ).toBe(true); }); it("does not fund zero or negative balances", () => { - expect(isFundedBalance({ amount: "0", priceUsd: null })).toBe(false); + expect( + isFundedBalance({ amount: new BigNumber("0"), priceUsd: null }) + ).toBe(false); expect( isFundedBalance({ amount: new BigNumber(-1), priceUsd: new BigNumber(1) }) ).toBe(false); diff --git a/packages/widget/tests/domain/finance/price.test.ts b/packages/widget/tests/domain/finance/price.test.ts index a9f045cc1..60995c812 100644 --- a/packages/widget/tests/domain/finance/price.test.ts +++ b/packages/widget/tests/domain/finance/price.test.ts @@ -1,3 +1,4 @@ +import BigNumber from "bignumber.js"; import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { getTokenPriceInUSD } from "../../../src/domain/finance/price"; @@ -15,7 +16,7 @@ describe("token USD prices", () => { }); const amountInUsd = getTokenPriceInUSD({ - amount: "0.0012", + amount: new BigNumber("0.0012"), baseToken: null, pricePerShare: null, prices, diff --git a/packages/widget/tests/domain/portfolio/dashboard-models.test.ts b/packages/widget/tests/domain/portfolio/dashboard-models.test.ts index c064f8855..2755341b8 100644 --- a/packages/widget/tests/domain/portfolio/dashboard-models.test.ts +++ b/packages/widget/tests/domain/portfolio/dashboard-models.test.ts @@ -66,7 +66,6 @@ describe("dashboard application schemas", () => { expect(DateTime.isDateTime(rewardRate.items[0]?.timestamp)).toBe(true); expect(tvl.items).toHaveLength(1); expect(tvl.items[0]?.tvl.toFixed()).toBe("1000.5"); - expect(tvl.items[0]?.tvlRaw).toBe("1000500000"); expect(DateTime.isDateTime(tvl.items[0]?.timestamp)).toBe(true); }) ); diff --git a/packages/widget/tests/domain/portfolio/position-derivations.test.ts b/packages/widget/tests/domain/portfolio/position-derivations.test.ts index 5affd877e..9765bad3d 100644 --- a/packages/widget/tests/domain/portfolio/position-derivations.test.ts +++ b/packages/widget/tests/domain/portfolio/position-derivations.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from "vitest"; import { EarnBalance, EarnPosition } from "../../../src/domain/earn/models"; import { WalletAddress } from "../../../src/domain/identity/identifiers"; import { + BalanceDataKey, getPositionBalances, getPositionData, getSingleExitTokenSum, @@ -66,7 +67,12 @@ describe("position derivations", () => { const positions = toPositionsData([position]); const selected = getPositionData(positions, position.yieldId); - expect(getPositionBalances(selected, "missing-balance-group")).toBeNull(); + expect( + getPositionBalances( + selected, + Schema.decodeSync(BalanceDataKey)("validator::missing") + ) + ).toBeNull(); }); it("sums balances per token identity without merging equal symbols on different addresses", () => { diff --git a/packages/widget/tests/features/activity/activity-action-details.test.ts b/packages/widget/tests/features/activity/activity-action-details.test.ts index a08d0a8c5..e75c13a41 100644 --- a/packages/widget/tests/features/activity/activity-action-details.test.ts +++ b/packages/widget/tests/features/activity/activity-action-details.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import type { ActionTransaction } from "../../../src/domain/action/models"; import { Market } from "../../../src/domain/borrow/catalog/market"; import { BorrowActivityAction } from "../../../src/domain/borrow/responses"; +import { TransactionHash } from "../../../src/domain/identity/identifiers"; import type { BorrowActivityItem, YieldActivityItem, @@ -46,7 +47,7 @@ describe("Activity action details projection", () => { }); const approval = yieldApiTransactionFixture({ broadcastedAt: DateTime.makeUnsafe("2026-08-26T10:05:00.000Z"), - hash: "0xabc", + hash: TransactionHash.make("0xabc"), id: "approval", status: "CONFIRMED", stepIndex: 0, diff --git a/packages/widget/tests/features/activity/activity-details.dom.test.tsx b/packages/widget/tests/features/activity/activity-details.dom.test.tsx index fb7b50964..673a12b9f 100644 --- a/packages/widget/tests/features/activity/activity-details.dom.test.tsx +++ b/packages/widget/tests/features/activity/activity-details.dom.test.tsx @@ -4,6 +4,7 @@ import { I18nextProvider } from "react-i18next"; import { MemoryRouter } from "react-router"; import { describe, expect, it, vi } from "vitest"; import { BorrowActivityAction } from "../../../src/domain/borrow/responses"; +import { TransactionHash } from "../../../src/domain/identity/identifiers"; import type { BorrowActivityItem, YieldActivityItem, @@ -59,7 +60,7 @@ const makeItem = ({ yieldApiTransactionFixture({ error: "Execution reverted", explorerUrl: "https://explorer.test/failed", - hash: "0x1234567890abcdef1234", + hash: TransactionHash.make("0x1234567890abcdef1234"), id: "stake", status: "FAILED", stepIndex: 1, diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx b/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx index c32403bdc..522d6f3fd 100644 --- a/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx +++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-execution-flow.browser.test.tsx @@ -23,7 +23,10 @@ import type { Transaction as BorrowTransaction, SubmitTransactionCommand, } from "../../../src/domain/borrow/execution/transaction"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + TransactionHash, + WalletAddress, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { BorrowFlowSession, @@ -77,8 +80,9 @@ import { applicationRuntimeInitInitialValue } from "../../utils/widget-config"; const address = Schema.decodeSync(WalletAddress)( "0x0000000000000000000000000000000000000001" ); -const transactionHash = - "0x1111111111111111111111111111111111111111111111111111111111111111"; +const transactionHash = TransactionHash.make( + "0x1111111111111111111111111111111111111111111111111111111111111111" +); const walletScope = new WalletScopeKey({ address, network: "base" }); const i18nInstance = createWidgetI18nInstance(); diff --git a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts index 137b32b48..4b0f525c5 100644 --- a/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts +++ b/packages/widget/tests/features/borrow-transaction-flow/borrow-flow-operations.test.ts @@ -13,7 +13,11 @@ import { } from "effect"; import { Action } from "../../../src/domain/borrow/execution/action"; import { Transaction } from "../../../src/domain/borrow/execution/transaction"; -import { IntegrationId, MarketId } from "../../../src/domain/borrow/ids"; +import { + ActionId, + IntegrationId, + MarketId, +} from "../../../src/domain/borrow/ids"; import { TransactionWorkflowId, WalletAddress, @@ -225,7 +229,9 @@ const withoutNavigationState = ( // The action id Steps' navigation state carries after a handoff. const handedOffActionId = (session: BorrowFlowSession) => Ref.get(session.handoff).pipe( - Effect.map((handoff) => handoff?.action.id ?? "nothing-handed-off") + Effect.map( + (handoff) => handoff?.action.id ?? ActionId.make("nothing-handed-off") + ) ); describe("startBorrowTransactionFlow", () => { @@ -771,7 +777,10 @@ describe("startBorrowFlowExecution", () => { const exit = yield* Effect.exit( Effect.scoped( - startBorrowFlowExecution(openSession(), "never-handed-off") + startBorrowFlowExecution( + openSession(), + ActionId.make("never-handed-off") + ) ).pipe(Effect.provide(kit.layer)) ); diff --git a/packages/widget/tests/features/borrow/borrow-entry/market-groups.test.ts b/packages/widget/tests/features/borrow/borrow-entry/market-groups.test.ts index 404302f70..1095b7dcc 100644 --- a/packages/widget/tests/features/borrow/borrow-entry/market-groups.test.ts +++ b/packages/widget/tests/features/borrow/borrow-entry/market-groups.test.ts @@ -1,6 +1,7 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vitest"; import { Market } from "../../../../src/domain/borrow/catalog/market"; +import { IntegrationId } from "../../../../src/domain/borrow/ids"; import { TokenBalancesResponse } from "../../../../src/domain/finance/models"; import { type BorrowCollateralOption, @@ -124,8 +125,11 @@ const aaveUsdt = makeMarket({ }); const integrations = [ - { id: "aave-borrow", name: "Aave V3" }, - { id: "morpho-blue-borrow", name: "Morpho Blue" }, + { id: Schema.decodeSync(IntegrationId)("aave-borrow"), name: "Aave V3" }, + { + id: Schema.decodeSync(IntegrationId)("morpho-blue-borrow"), + name: "Morpho Blue", + }, ]; const markets = [aaveUsdc, morphoUsdc, aaveUsdt]; diff --git a/packages/widget/tests/features/borrow/market-position/action-form.test.ts b/packages/widget/tests/features/borrow/market-position/action-form.test.ts index 494d6e8ad..efa8fae9f 100644 --- a/packages/widget/tests/features/borrow/market-position/action-form.test.ts +++ b/packages/widget/tests/features/borrow/market-position/action-form.test.ts @@ -3,6 +3,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { Integration } from "../../../../src/domain/borrow/catalog/integration"; import { Market } from "../../../../src/domain/borrow/catalog/market"; +import { TokenAddress } from "../../../../src/domain/borrow/ids"; import { BorrowAccountSnapshot } from "../../../../src/domain/borrow/positions/borrow-account-snapshot"; import { deriveBorrowPositions } from "../../../../src/domain/borrow/positions/borrow-positions"; import { @@ -47,7 +48,9 @@ describe("Borrow position action form intent", () => { expect( applyBorrowWithdrawFormAction({ action: { - tokenAddress: "0x0000000000000000000000000000000000000001", + tokenAddress: Schema.decodeSync(TokenAddress)( + "0x0000000000000000000000000000000000000001" + ), type: "token/select", }, intent: changed, diff --git a/packages/widget/tests/features/earn/earn-details-model.test.tsx b/packages/widget/tests/features/earn/earn-details-model.test.tsx index 8d6dfb917..a5416f809 100644 --- a/packages/widget/tests/features/earn/earn-details-model.test.tsx +++ b/packages/widget/tests/features/earn/earn-details-model.test.tsx @@ -1,3 +1,4 @@ +import BigNumber from "bignumber.js"; import type { TFunction } from "i18next"; import { describe, expect, it } from "vitest"; import type { EarnYieldWithProvider } from "../../../src/domain/earn/models"; @@ -25,7 +26,11 @@ const t = (key: string, options?: Record): string => { const minStakeMechanics = { ...yieldApiYieldFixture().mechanics, - entryLimits: { minimum: "1", maximum: null, subsequentMinimum: null }, + entryLimits: { + minimum: new BigNumber("1"), + maximum: null, + subsequentMinimum: null, + }, }; const makeYield = ( @@ -76,8 +81,7 @@ describe("getEarnDetailsModel", () => { yieldDto: makeYield({ state: { pricePerShareState: { - price: 1.06274537, - quoteToken: yieldApiYieldFixture().token, + price: new BigNumber("1.06274537"), shareToken: yieldApiYieldFixture().token, }, }, @@ -183,8 +187,8 @@ describe("getEarnDetailsModel", () => { const model = getEarnDetailsModel({ locale: "en", selectedValidators: new Map([ - [firstValidator.address, firstValidator], - [secondValidator.address, secondValidator], + [firstValidator.key, firstValidator], + [secondValidator.key, secondValidator], ]), t: t as TFunction, yieldDto: makeYield(), @@ -209,8 +213,7 @@ describe("getEarnDetailsModel", () => { }, state: { pricePerShareState: { - price: 1.06274537, - quoteToken: baseYield.token, + price: new BigNumber("1.06274537"), shareToken: baseYield.token, }, }, diff --git a/packages/widget/tests/features/earn/earn-entry-reset.test.ts b/packages/widget/tests/features/earn/earn-entry-reset.test.ts index 4204c2065..416dba382 100644 --- a/packages/widget/tests/features/earn/earn-entry-reset.test.ts +++ b/packages/widget/tests/features/earn/earn-entry-reset.test.ts @@ -1,6 +1,7 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { + ValidatorAddress, WalletAddress, YieldId, } from "../../../src/domain/identity/identifiers"; @@ -62,7 +63,7 @@ describe("Earn Entry amount clearing on workflow start", () => { pendingaction: null, tab: null, token: null, - validator: "validator-a", + validator: Schema.decodeSync(ValidatorAddress)("validator-a"), yieldId: Schema.decodeSync(YieldId)("seeded-yield"), }, intent: { diff --git a/packages/widget/tests/features/earn/earn-facade.test.ts b/packages/widget/tests/features/earn/earn-facade.test.ts index 31cf2c15a..2fe7362df 100644 --- a/packages/widget/tests/features/earn/earn-facade.test.ts +++ b/packages/widget/tests/features/earn/earn-facade.test.ts @@ -146,7 +146,7 @@ describe("Earn facade", () => { it("projects client-side token and yield search from semantic options", () => { const selectedYield = yieldApiYieldFixture(); const tokenOption = { - amount: "10", + amount: new BigNumber("10"), availableYields: [selectedYield.id], source: "balance", token: selectedYield.token, @@ -188,7 +188,7 @@ describe("Earn facade", () => { address: string, amount: string | null ): EarnTokenOption => ({ - amount, + amount: amount === null ? null : new BigNumber(amount), availableYields: [selectedYield.id], source: amount === null ? "default" : "balance", token: { @@ -264,7 +264,7 @@ describe("Earn facade", () => { address: string, amount: string ): EarnTokenOption => ({ - amount, + amount: new BigNumber(amount), availableYields: [selectedYield.id], source: "balance", token: { diff --git a/packages/widget/tests/features/earn/earn-initial-validator.test.ts b/packages/widget/tests/features/earn/earn-initial-validator.test.ts index f9c6b2778..0cdf0ad7c 100644 --- a/packages/widget/tests/features/earn/earn-initial-validator.test.ts +++ b/packages/widget/tests/features/earn/earn-initial-validator.test.ts @@ -5,7 +5,10 @@ import { describe, expect, it, vi } from "vitest"; import { appRuntime } from "../../../src/app/runtime/app-runtime"; import type { EarnYield } from "../../../src/domain/earn/models"; import { TokenBalancesResponse } from "../../../src/domain/finance/models"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + ValidatorAddress, + WalletAddress, +} from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { earnSelectionStatusViewAtom, @@ -50,7 +53,7 @@ const entry = (requestedValidator: string): EarnEntry => ({ pendingaction: null, tab: "earn", token: null, - validator: requestedValidator, + validator: Schema.decodeSync(ValidatorAddress)(requestedValidator), yieldId: validatorYield.id, }, preferredTokenYieldsPerNetwork: null, diff --git a/packages/widget/tests/features/earn/earn-refetch-failure.test.ts b/packages/widget/tests/features/earn/earn-refetch-failure.test.ts index dba44971e..7ace55899 100644 --- a/packages/widget/tests/features/earn/earn-refetch-failure.test.ts +++ b/packages/widget/tests/features/earn/earn-refetch-failure.test.ts @@ -1,3 +1,4 @@ +import BigNumber from "bignumber.js"; import { Effect, Layer, Option, Schema } from "effect"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import * as Reactivity from "effect/reactivity/Reactivity"; @@ -88,7 +89,7 @@ describe("Earn derived atoms after a failed refetch", () => { try { expect(AsyncResult.getOrThrow(registry.get(tokenOptions))).toMatchObject([ - { amount: "5", source: "balance" }, + { amount: new BigNumber("5"), source: "balance" }, ]); offline = true; @@ -100,7 +101,10 @@ describe("Earn derived atoms after a failed refetch", () => { expect(option && tokenString(option.token)).toBe( tokenString(earnYield.token) ); - expect(option).toMatchObject({ amount: "5", source: "balance" }); + expect(option).toMatchObject({ + amount: new BigNumber("5"), + source: "balance", + }); } finally { unmount(); registry.dispose(); diff --git a/packages/widget/tests/features/earn/earn-selection.test.ts b/packages/widget/tests/features/earn/earn-selection.test.ts index a0329cc95..2196d1210 100644 --- a/packages/widget/tests/features/earn/earn-selection.test.ts +++ b/packages/widget/tests/features/earn/earn-selection.test.ts @@ -1031,7 +1031,10 @@ describe("Earn Selection", () => { expect( registry .get(earnSelectionTokenOptionsViewAtom) - .items.map((option) => [option.token.symbol, option.amount]) + .items.map((option) => [ + option.token.symbol, + option.amount?.toFixed(), + ]) ).toEqual([ ["USDC", "5"], [firstYield.token.symbol, "0.000000000000000001"], @@ -1067,7 +1070,7 @@ describe("Earn Selection", () => { registry .get(earnSelectionTokenOptionsViewAtom) .items.map(({ amount, source, token }) => ({ - amount, + amount: amount?.toFixed(), source, symbol: token.symbol, })) diff --git a/packages/widget/tests/features/earn/history-chart-points.test.ts b/packages/widget/tests/features/earn/history-chart-points.test.ts index f05d0eda9..37b220092 100644 --- a/packages/widget/tests/features/earn/history-chart-points.test.ts +++ b/packages/widget/tests/features/earn/history-chart-points.test.ts @@ -23,7 +23,6 @@ describe("history chart adapters", () => { const tvlPoint = toTvlHistoryChartPoint({ timestamp, tvl, - tvlRaw: "9007199254740993250000000000000000", }); const rewardPoint = toRewardRateHistoryChartPoint({ timestamp, diff --git a/packages/widget/tests/features/earn/validator-selection-limit.test.ts b/packages/widget/tests/features/earn/validator-selection-limit.test.ts index 958499db0..f611d22d5 100644 --- a/packages/widget/tests/features/earn/validator-selection-limit.test.ts +++ b/packages/widget/tests/features/earn/validator-selection-limit.test.ts @@ -82,7 +82,7 @@ describe("Validator selection limit", () => { const next = togglePendingActionValidator({ store, - validator: validators[SUBSTRATE_LIMIT]!.address, + validator: validators[SUBSTRATE_LIMIT]!, }); expect(next.state.selectedValidators.size).toBe(SUBSTRATE_LIMIT); diff --git a/packages/widget/tests/features/portfolio/positions-list-item.browser.test.tsx b/packages/widget/tests/features/portfolio/positions-list-item.browser.test.tsx index a25af52f3..c23bc3423 100644 --- a/packages/widget/tests/features/portfolio/positions-list-item.browser.test.tsx +++ b/packages/widget/tests/features/portfolio/positions-list-item.browser.test.tsx @@ -78,7 +78,7 @@ const createItem = ( integrationId: integrationData.id as YieldId, balancesWithAmount: [], allBalances: [], - balanceId: "balance-1", + balanceId: "default", actionRequired: false, hasPendingRequest: false, pointsRewardTokenBalances: [], diff --git a/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx b/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx index 7c34b6785..fabb5a133 100644 --- a/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx +++ b/packages/widget/tests/features/position-details/page-workflow-state.dom.test.tsx @@ -24,7 +24,7 @@ const settings = { const positionWorkflowAtom = positionDetailsWorkflowAtom( new PositionDetailsWorkflowKey({ - balanceId: "balance-1", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("yield-1"), pendingActionType: null, scope: new WalletScopeKey({ diff --git a/packages/widget/tests/features/position-details/position-details-action-model.test.ts b/packages/widget/tests/features/position-details/position-details-action-model.test.ts index d28e84b4b..04e18ce98 100644 --- a/packages/widget/tests/features/position-details/position-details-action-model.test.ts +++ b/packages/widget/tests/features/position-details/position-details-action-model.test.ts @@ -8,21 +8,26 @@ import { PendingActionStateKey, preparePendingActionCommand, } from "../../../src/domain/action/action-command"; -import { EarnBalance } from "../../../src/domain/earn/models"; +import { EarnBalance, EarnValidator } from "../../../src/domain/earn/models"; import { Prices } from "../../../src/domain/health/models"; import { TokenAddress, WalletAddress, } from "../../../src/domain/identity/identifiers"; -import { toPositionBalancesByType } from "../../../src/domain/portfolio/positions"; +import { + type BalanceDataKey, + toPositionBalancesByType, +} from "../../../src/domain/portfolio/positions"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; import { resolvePositionDetailsExitReceiveTokenSelection } from "../../../src/features/position-details/model/exit-receive-token"; import { + getPendingActionValidatorAddresses, makeAutomaticPendingActionModalState, makePendingActionModalStore, openPendingActionModal, reconcilePendingActionModalReceipt, resolvePositionDetailsExitSubmission, + togglePendingActionValidator, } from "../../../src/features/position-details/model/flow-actions"; import { resolvePositionDetailsActionCapabilities } from "../../../src/features/position-details/model/hub"; import { @@ -352,6 +357,69 @@ describe("Position Details action model", () => { ).toBe("Open"); }); + it("selects subnet validators by key and submits their addresses", () => { + const nominate = Schema.decodeSync(EarnBalance)( + yieldBalanceFixture({ + pendingActions: [ + { + arguments: { + fields: [ + { + label: "Validators", + name: "validatorAddresses", + required: true, + type: "string", + }, + ], + }, + intent: "manage", + passthrough: "delegate", + type: "DELEGATE", + }, + ], + token: selectedYield.token, + }) + ); + const [subnetA, subnetB] = [7, 8].map((id) => + Schema.decodeSync(EarnValidator)( + yieldApiValidatorFixture({ + address: "validator-subnet", + subnet: { id, name: `Subnet ${id}` }, + }) + ) + ); + if (!subnetA || !subnetB) throw new Error("Expected subnet validators"); + + const opened = openPendingActionModal({ + input: { + pendingAction: nominate.pendingActions[0]!, + yieldBalance: nominate, + }, + store: makePendingActionModalStore(), + }); + const both = togglePendingActionValidator({ + store: togglePendingActionValidator({ + store: opened, + validator: subnetA, + }), + validator: subnetB, + }); + const onlyB = togglePendingActionValidator({ + store: both, + validator: subnetA, + }); + + expect(opened.state.multiSelect).toBe(true); + expect([...both.state.selectedValidators.keys()]).toEqual([ + subnetA.key, + subnetB.key, + ]); + expect([...onlyB.state.selectedValidators.keys()]).toEqual([subnetB.key]); + expect(getPendingActionValidatorAddresses(onlyB.state)).toEqual([ + subnetB.address, + ]); + }); + it("does not apply an automatic receipt to a different pending action", () => { const first = makeAutomaticPendingActionModalState({ pendingAction, @@ -443,7 +511,7 @@ describe("Position Details action model", () => { network: "ethereum", }); const workflowKey = new PositionDetailsWorkflowKey({ - balanceId: "collision-balance", + balanceId: "default", integrationId: selectedYield.id, pendingActionType: null, scope, @@ -560,7 +628,7 @@ describe("Position Details action model", () => { }) ); - const singleToken = readWorkflowView("single-token", [ + const singleToken = readWorkflowView("default", [ active("1.25"), active("0.75"), ]); @@ -575,7 +643,7 @@ describe("Position Details action model", () => { }).canUnstake ).toBe(true); - const twoTokens = readWorkflowView("two-tokens", [ + const twoTokens = readWorkflowView("default", [ active("1.25"), active("10", usdt), ]); @@ -633,7 +701,7 @@ describe("Position Details action model", () => { const priceKey = (token: { network: string; address?: string }) => `${token.network}-${token.address?.toLowerCase() ?? ""}`; const { registry, workflowKey } = makeWorkflowRegistry( - "valuation", + "default", [activeBalance, claimableBalance], new Prices( new Map([ @@ -661,7 +729,7 @@ describe("Position Details action model", () => { it("withdraws a confidential position in its revealed vault shares, valued in the underlying", () => { const { cShare, usdc } = confidentialFixture; const view = readWorkflowView( - "confidential-shares", + "default", [ Schema.decodeSync(EarnBalance)( yieldBalanceFixture({ @@ -689,7 +757,7 @@ describe("Position Details action model", () => { it("keeps withdrawing available while the share balance is hidden, so the user can decrypt it there", () => { const { cShare, usdc } = confidentialFixture; const view = readWorkflowView( - "confidential-hidden", + "default", [ Schema.decodeSync(EarnBalance)( yieldBalanceFixture({ @@ -841,7 +909,7 @@ describe("Position Details action model", () => { }); const makeWorkflowRegistry = ( - balanceId: string, + balanceId: BalanceDataKey, balances: ReadonlyArray, prices: Prices = new Prices(new Map()), integration: typeof selectedYield = selectedYield @@ -890,7 +958,7 @@ const makeWorkflowRegistry = ( }; const readWorkflowView = ( - balanceId: string, + balanceId: BalanceDataKey, balances: ReadonlyArray, integration: typeof selectedYield = selectedYield ) => { diff --git a/packages/widget/tests/features/position-details/position-details-action-tabs.browser.test.tsx b/packages/widget/tests/features/position-details/position-details-action-tabs.browser.test.tsx index dfb3f0133..6efc029c3 100644 --- a/packages/widget/tests/features/position-details/position-details-action-tabs.browser.test.tsx +++ b/packages/widget/tests/features/position-details/position-details-action-tabs.browser.test.tsx @@ -77,25 +77,22 @@ describe("position details action tabs", () => { it("registers the earn CTA only for the earn index", () => { expect(shouldRegisterDashboardEarnFooterButton("/")).toBe(true); expect( - shouldRegisterDashboardEarnFooterButton("/positions/yield-1/balance-1") + shouldRegisterDashboardEarnFooterButton("/positions/yield-1/default") ).toBe(false); expect( shouldRegisterDashboardEarnFooterButton( - "/positions/yield-1/balance-1/stake" + "/positions/yield-1/default/stake" ) ).toBe(false); expect( shouldRegisterDashboardEarnFooterButton( - "/positions/yield-1/balance-1/stake/review" + "/positions/yield-1/default/stake/review" ) ).toBe(false); }); it("switches Unstake and Stake tabs without changing the URL", async () => { - const app = await renderTabs([ - "/positions", - "/positions/yield-1/balance-1", - ]); + const app = await renderTabs(["/positions", "/positions/yield-1/default"]); await expect .element(app.getByTestId("position-details-action-tab-unstake")) @@ -108,7 +105,7 @@ describe("position details action tabs", () => { .toHaveTextContent("unstake"); await expect .element(app.getByTestId("location")) - .toHaveTextContent("/positions/yield-1/balance-1"); + .toHaveTextContent("/positions/yield-1/default"); await userEvent.click(app.getByTestId("position-details-action-tab-stake")); @@ -117,7 +114,7 @@ describe("position details action tabs", () => { .toHaveTextContent("stake"); await expect .element(app.getByTestId("location")) - .toHaveTextContent("/positions/yield-1/balance-1"); + .toHaveTextContent("/positions/yield-1/default"); }); it("names the tabs Withdraw and Deposit for a vault", async () => { @@ -140,7 +137,7 @@ describe("position details action tabs", () => { it("does not render a selector when there is only one available action", async () => { const app = await render( - + { const reveals: Array<{ chainId: number; owner: string; token: string }> = []; const entryKey = new PositionDetailsStakeEntryKey({ - balanceId: "balance-1", + balanceId: "default", integrationId: selectedYield.id, walletScope, }); diff --git a/packages/widget/tests/features/position-details/position-details-stake-validators.test.ts b/packages/widget/tests/features/position-details/position-details-stake-validators.test.ts index 4ab4b95f6..151741562 100644 --- a/packages/widget/tests/features/position-details/position-details-stake-validators.test.ts +++ b/packages/widget/tests/features/position-details/position-details-stake-validators.test.ts @@ -48,7 +48,7 @@ const positionValidator = yieldApiValidatorFixture({ name: "Position validator", }); const entryKey = new PositionDetailsStakeEntryKey({ - balanceId: "balance-1", + balanceId: "default", integrationId: validatorYield.id, walletScope, }); diff --git a/packages/widget/tests/features/position-details/position-details-workflow.test.ts b/packages/widget/tests/features/position-details/position-details-workflow.test.ts index 2e34baa6c..1fef2054c 100644 --- a/packages/widget/tests/features/position-details/position-details-workflow.test.ts +++ b/packages/widget/tests/features/position-details/position-details-workflow.test.ts @@ -29,7 +29,7 @@ const scopeB = new WalletScopeKey({ const workflowAtom = positionDetailsWorkflowAtom( new PositionDetailsWorkflowKey({ - balanceId: "balance-1", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("yield-1"), pendingActionType: null, scope: scopeA, @@ -76,7 +76,7 @@ describe("position details workflow atoms", () => { ); const walletBWorkflow = positionDetailsWorkflowAtom( new PositionDetailsWorkflowKey({ - balanceId: "balance-1", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("yield-1"), pendingActionType: null, scope: scopeB, diff --git a/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx b/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx index 5234928d4..69d5caed3 100644 --- a/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx +++ b/packages/widget/tests/features/position-details/position-details-yield-entry.browser.test.tsx @@ -64,7 +64,7 @@ const walletScope = new WalletScopeKey({ }); const selectedYield = yieldApiYieldFixture(); const entryKey = new PositionDetailsStakeEntryKey({ - balanceId: "balance-1", + balanceId: "default", integrationId: selectedYield.id, walletScope, }); @@ -155,7 +155,7 @@ const ReviewGuard = () => { intake._tag === "Enter" && intake.selectedStake.id === selectedYield.id && mount._tag === "PositionStake" && - mount.balanceId === "balance-1" + mount.balanceId === "default" ); return carriesPositionStake ? (
Position stake review
@@ -170,11 +170,11 @@ const Runtime = () => { } /> } /> Missing Flow Session} /> @@ -207,7 +207,7 @@ const TestApp = () => { applicationBaseRuntime.layer, Layer.merge( ApplicationRouter.layer(applicationRoutes, { - initialEntries: [`/positions/${selectedYield.id}/balance-1`], + initialEntries: [`/positions/${selectedYield.id}/default`], }), WidgetConfigService.layer({ apiKey: "test", diff --git a/packages/widget/tests/features/position-details/position-overview-model.test.tsx b/packages/widget/tests/features/position-details/position-overview-model.test.tsx index b20f0e017..c19a9a006 100644 --- a/packages/widget/tests/features/position-details/position-overview-model.test.tsx +++ b/packages/widget/tests/features/position-details/position-overview-model.test.tsx @@ -2,9 +2,11 @@ import BigNumber from "bignumber.js"; import { DateTime, Schema } from "effect"; import type { TFunction } from "i18next"; import { describe, expect, it } from "vitest"; -import { ConfidentialHandle } from "../../../src/domain/earn/confidential"; -import type { EarnYieldWithProvider } from "../../../src/domain/earn/models"; -import { EarnBalance } from "../../../src/domain/earn/models"; +import { + ConfidentialHandle, + EarnBalance, + type EarnYieldWithProvider, +} from "../../../src/domain/earn/models"; import { TokenAddress } from "../../../src/domain/identity/identifiers"; import { RewardsSummary } from "../../../src/domain/portfolio/models"; import type { PositionBalancesByType } from "../../../src/domain/portfolio/positions"; @@ -484,7 +486,12 @@ describe("getPositionOverviewModel", () => { // A revealed balance keeps the handle it was decrypted from. { ...balance({ amount: "1", amountUsd: "1", type: "exiting" }), - revealedHandle: cancelling, + shares: { + _tag: "Revealed", + amount: new BigNumber("1"), + amountRaw: 1_000_000n, + handle: cancelling, + }, }, balance({ amount: "2", @@ -1080,13 +1087,7 @@ describe("getPositionOverviewModel", () => { positionBalancesByType: makePositionBalances(), providersDetails: [{ name: "Rocket Pool", status: "active" }], rewardsSummary: Schema.decodeSync(RewardsSummary)({ - rewards: { - last24H: "0", - last30D: "0", - last7D: "0", - lastYear: "0", - total: "1.5", - }, + rewards: { total: "1.5" }, token: yieldApiYieldFixture().token, }), t: t as TFunction, @@ -1167,8 +1168,7 @@ describe("getPositionOverviewModel", () => { }, state: { pricePerShareState: { - price: 1.06274537, - quoteToken: baseYield.token, + price: new BigNumber("1.06274537"), shareToken: baseYield.token, }, }, @@ -1203,8 +1203,7 @@ describe("getPositionOverviewModel", () => { integrationData: makeYield({ state: { pricePerShareState: { - price: 1.06274537, - quoteToken: yieldApiYieldFixture().token, + price: new BigNumber("1.06274537"), shareToken: yieldApiYieldFixture().token, }, }, diff --git a/packages/widget/tests/features/position-details/position-source-details.browser.test.tsx b/packages/widget/tests/features/position-details/position-source-details.browser.test.tsx index 3a00e80f0..526096790 100644 --- a/packages/widget/tests/features/position-details/position-source-details.browser.test.tsx +++ b/packages/widget/tests/features/position-details/position-source-details.browser.test.tsx @@ -1,3 +1,4 @@ +import BigNumber from "bignumber.js"; import { I18nextProvider } from "react-i18next"; import { describe, expect, it } from "vitest"; import { userEvent } from "vitest/browser"; @@ -31,8 +32,7 @@ const integration: EarnYieldWithProvider = { rewardRate: yieldRewardRateFixture({ total: 0.04 }), state: { pricePerShareState: { - price: 1.06274537, - quoteToken: baseYield.token, + price: new BigNumber("1.06274537"), shareToken: baseYield.token, }, }, @@ -92,7 +92,7 @@ describe("Position source yield details", () => { ...integration, mechanics: { ...integration.mechanics, - fee: { management: "0.02" }, + fee: { management: new BigNumber("0.02") }, }, }} showRewardRate={false} diff --git a/packages/widget/tests/features/yield-flow-confidential.test.ts b/packages/widget/tests/features/yield-flow-confidential.test.ts index 041c82cea..5b25ea2f9 100644 --- a/packages/widget/tests/features/yield-flow-confidential.test.ts +++ b/packages/widget/tests/features/yield-flow-confidential.test.ts @@ -16,9 +16,16 @@ import { Atom, AtomRegistry } from "effect/reactivity"; import * as Reactivity from "effect/reactivity/Reactivity"; import { appRuntime } from "../../src/app/runtime/app-runtime"; import { walletRuntime } from "../../src/app/runtime/wallet-runtime"; -import type { DirectTransaction } from "../../src/domain/action/direct-transaction"; -import { ConfidentialHandle } from "../../src/domain/earn/confidential"; -import { WalletAddress, YieldId } from "../../src/domain/identity/identifiers"; +import { + type DirectTransaction, + DirectTransactionId, +} from "../../src/domain/action/direct-transaction"; +import { ConfidentialHandle } from "../../src/domain/earn/models"; +import { + SignedTransactionPayload, + WalletAddress, + YieldId, +} from "../../src/domain/identity/identifiers"; import { Token } from "../../src/domain/token/token"; import { WalletScopeKey } from "../../src/domain/wallet/wallet-scope"; import { @@ -41,7 +48,11 @@ import type { EvmRpc } from "../../src/services/evm-rpc/evm-rpc"; import type { TransactionWorkflowHandle } from "../../src/services/transaction-workflow/transaction-workflow-service"; import { WalletRequestCancelledError } from "../../src/services/wallet/wallet-errors"; import type { WalletState } from "../../src/services/wallet/wallet-state"; -import type { WalletSignTransactionInput } from "../../src/services/wallet/wallet-transactions"; +import type { + WalletBroadcastResult, + WalletSignedPayloadResult, + WalletSignTransactionInput, +} from "../../src/services/wallet/wallet-transactions"; import type { ZamaService } from "../../src/services/zama/zama-service"; import { confidentialFixture, @@ -57,6 +68,10 @@ const address = Schema.decodeSync(WalletAddress)( "0x1234567890123456789012345678901234567890" ); const walletScope = new WalletScopeKey({ address, network: "ethereum" }); +const signedPayload = { + broadcasted: false, + signedTx: SignedTransactionPayload.make("0x02aa"), +} as const; const enterStart = ( pick: "asset" | "underlying" @@ -110,7 +125,7 @@ const exitStart = ( }, mount: { _tag: "PositionExit", - balanceId: "b", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("i"), }, }; @@ -140,7 +155,7 @@ const withdrawUnwrapStart = (): NavigatingYieldTransactionFlowStart => { }, mount: { _tag: "PositionManage", - balanceId: "b", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("i"), }, }; @@ -167,7 +182,7 @@ const direct = (type: DirectTransaction["type"]): DirectTransaction => ({ approval: null, chainId: 11155111, data: "0x00", - id: `conversion-${type.toLowerCase()}`, + id: DirectTransactionId.make(`conversion-${type.toLowerCase()}`), network: "ethereum", to: cUsdc.address as `0x${string}`, type, @@ -194,8 +209,7 @@ const depositAction = yieldApiActionFixture({ const openConfidentialFlow = Effect.fn("test.openConfidentialFlow")(function* ({ evmRpc = {}, previewAction = () => Effect.succeed(depositAction), - signTransaction = () => - Effect.succeed({ broadcasted: false, signedTx: "0x02aa" }), + signTransaction = () => Effect.succeed(signedPayload), start, zama = {}, }: { @@ -206,7 +220,7 @@ const openConfidentialFlow = Effect.fn("test.openConfidentialFlow")(function* ({ readonly signTransaction?: ( input: WalletSignTransactionInput ) => Effect.Effect< - { broadcasted: boolean; signedTx: string }, + WalletBroadcastResult | WalletSignedPayloadResult, WalletRequestCancelledError >; readonly start: NavigatingYieldTransactionFlowStart; @@ -302,7 +316,7 @@ const awaitState = ( const signedIds = (signed: Array) => (input: WalletSignTransactionInput) => Effect.sync(() => signed.push(input.txMeta.txId)).pipe( - Effect.as({ broadcasted: false, signedTx: "0x02aa" }) + Effect.as(signedPayload) ); const convertingRpc: Partial = { diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-completion-urls.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-completion-urls.test.ts index aa3086321..51770ff6f 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-completion-urls.test.ts +++ b/packages/widget/tests/features/yield-transaction-flow/yield-completion-urls.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { Effect, Fiber, Layer, Option, Schema, Stream } from "effect"; import type { TransactionType } from "../../../src/domain/action/rules"; import { + SignedTransactionPayload, WalletAddress, + WalletSignature, YieldId, } from "../../../src/domain/identity/identifiers"; import { WalletScopeKey } from "../../../src/domain/wallet/wallet-scope"; @@ -42,9 +44,12 @@ const runYieldToCompletion = ( const kit = yield* makeTransactionWorkflowTestKit({ initialWalletState: makeConnectedWalletState(yieldWalletScope), wallet: { - signMessage: () => Effect.succeed("0xsigned"), + signMessage: () => Effect.succeed(WalletSignature.make("0xsigned")), signTransaction: () => - Effect.succeed({ broadcasted: false, signedTx: "0xsigned" }), + Effect.succeed({ + broadcasted: false, + signedTx: SignedTransactionPayload.make("0xsigned"), + }), }, yieldOperations: { getTransactionStatus: () => diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts index 4b25b89bb..b0f1560b4 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts +++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-operations.test.ts @@ -175,7 +175,7 @@ const exitStart = (): NavigatingYieldTransactionFlowStart => ({ intake: makeExitIntake(), mount: { _tag: "PositionExit", - balanceId: "balance", + balanceId: "default", integrationId, }, }); @@ -601,7 +601,7 @@ describe("handOffYieldFlowSource", () => { { _tag: "Push", to: { - path: "/positions/integration/balance/unstake/steps", + path: "/positions/integration/default/unstake/steps", state: { executionHandoff: handoff, yieldFlowSession: handle }, }, }, @@ -979,7 +979,7 @@ describe("leaveYieldFlowExecution", () => { intake: makeManageIntake(), mount: { _tag: "PositionManage", - balanceId: "balance", + balanceId: "default", integrationId, }, }), @@ -990,7 +990,7 @@ describe("leaveYieldFlowExecution", () => { intake: makeEnterIntake(), mount: { _tag: "PositionStake", - balanceId: "balance", + balanceId: "default", integrationId, }, }), diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts index b245d6989..17f50d408 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts +++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-review-page.test.ts @@ -255,7 +255,7 @@ describe("Yield flow Review page", () => { intake: makeExitIntake(integration), mount: { _tag: "PositionExit", - balanceId: "balance", + balanceId: "default", integrationId, }, }, diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx b/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx index b3f55bd95..6c7baf9a3 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx +++ b/packages/widget/tests/features/yield-transaction-flow/yield-flow-route.dom.test.tsx @@ -81,7 +81,7 @@ const makeExitSession = () => { }, mount: { _tag: "PositionExit", - balanceId: "balance", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("yield"), }, }, diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-steps-view.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-steps-view.test.ts index 1a172bcb7..d1dd40da4 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-steps-view.test.ts +++ b/packages/widget/tests/features/yield-transaction-flow/yield-steps-view.test.ts @@ -2,9 +2,11 @@ import { Schema } from "effect"; import { describe, expect, it } from "vitest"; import { type DirectTransaction, + DirectTransactionId, makeErc20Approval, } from "../../../src/domain/action/direct-transaction"; import { + TransactionHash, TransactionWorkflowId, WalletAddress, YieldId, @@ -44,19 +46,19 @@ const approval = { } as const; const reset = makeErc20Approval({ ...approval, - id: "conversion-approval", + id: DirectTransactionId.make("conversion-approval"), type: "RESET_APPROVAL", }); const approve = makeErc20Approval({ ...approval, - id: "conversion-approval", + id: DirectTransactionId.make("conversion-approval"), type: "APPROVAL", }); const wrap: DirectTransaction = { approval: null, chainId: 11155111, data: "0x00", - id: "conversion-wrap", + id: DirectTransactionId.make("conversion-wrap"), network: "ethereum", to: approval.spender, type: "WRAP", @@ -152,8 +154,12 @@ describe("Yield Steps view", () => { makeInput([wrap]), new Map([ [ - "conversion-wrap", - { _tag: "Confirmed", hash: "0x01", transaction: wrap }, + wrap.id, + { + _tag: "Confirmed", + hash: TransactionHash.make("0x01"), + transaction: wrap, + }, ], ]) ); @@ -205,7 +211,7 @@ describe("Yield Steps view", () => { batchId: "yield", message: "receipt unavailable", network: "ethereum", - transactionId: "conversion-wrap", + transactionId: wrap.id, workflowId: TransactionWorkflowId.make("w"), }), }) diff --git a/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts b/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts index 0186f49a1..fc327cd06 100644 --- a/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts +++ b/packages/widget/tests/features/yield-transaction-flow/yield-transaction-flow-interface.test.ts @@ -187,49 +187,49 @@ const startCases: ReadonlyArray< stepsPath: "/steps", }, { - completePath: "/positions/yield/balance/stake/complete", + completePath: "/positions/yield/default/stake/complete", finishPath: "/positions", makeCommand: () => ({ intake: makeEnterIntake(), mount: { _tag: "PositionStake", - balanceId: "balance", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("yield"), }, }), name: "position Stake", - reviewPath: "/positions/yield/balance/stake/review", - stepsPath: "/positions/yield/balance/stake/steps", + reviewPath: "/positions/yield/default/stake/review", + stepsPath: "/positions/yield/default/stake/steps", }, { - completePath: "/positions/yield/balance/unstake/complete", + completePath: "/positions/yield/default/unstake/complete", finishPath: "/positions", makeCommand: () => ({ intake: makeExitIntake(), mount: { _tag: "PositionExit", - balanceId: "balance", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("yield"), }, }), name: "position Exit", - reviewPath: "/positions/yield/balance/unstake/review", - stepsPath: "/positions/yield/balance/unstake/steps", + reviewPath: "/positions/yield/default/unstake/review", + stepsPath: "/positions/yield/default/unstake/steps", }, { - completePath: "/positions/yield/balance/pending-action/complete", + completePath: "/positions/yield/default/pending-action/complete", finishPath: "/positions", makeCommand: () => ({ intake: makeManageIntake(), mount: { _tag: "PositionManage", - balanceId: "balance", + balanceId: "default", integrationId: Schema.decodeSync(YieldId)("yield"), }, }), name: "position Manage", - reviewPath: "/positions/yield/balance/pending-action/review", - stepsPath: "/positions/yield/balance/pending-action/steps", + reviewPath: "/positions/yield/default/pending-action/review", + stepsPath: "/positions/yield/default/pending-action/steps", }, ]; @@ -304,27 +304,27 @@ describe("Yield Flow route group", () => { ["/steps", "/"], ["/complete", "/"], ["/complete/", "/"], - ["/positions/yield/balance/stake/review", "/positions/yield/balance/stake"], - ["/positions/yield/balance/stake/steps", "/positions/yield/balance/stake"], + ["/positions/yield/default/stake/review", "/positions/yield/default/stake"], + ["/positions/yield/default/stake/steps", "/positions/yield/default/stake"], [ - "/positions/yield/balance/stake/complete/", - "/positions/yield/balance/stake", + "/positions/yield/default/stake/complete/", + "/positions/yield/default/stake", ], [ - "/positions/yield/balance/unstake/review", - "/positions/yield/balance/unstake", + "/positions/yield/default/unstake/review", + "/positions/yield/default/unstake", ], [ - "/positions/yield/balance/unstake/complete", - "/positions/yield/balance/unstake", + "/positions/yield/default/unstake/complete", + "/positions/yield/default/unstake", ], [ - "/positions/yield/balance/pending-action/steps", - "/positions/yield/balance/pending-action", + "/positions/yield/default/pending-action/steps", + "/positions/yield/default/pending-action", ], [ - "/positions/yield/other/pending-action/steps", - "/positions/yield/other/pending-action", + "/positions/yield/validators/pending-action/steps", + "/positions/yield/validators/pending-action", ], ["/activity/action-1", "/activity/action-1"], ["/activity/action-1/", "/activity/action-1"], @@ -338,7 +338,7 @@ describe("Yield Flow route group", () => { it.each([ "/", "/positions", - "/positions/yield/balance", + "/positions/yield/default", "/activity", "/activity/", "/borrow/review", @@ -351,11 +351,11 @@ describe("Yield Flow route group", () => { new Set(paths.map(getYieldFlowRouteGroup)); const mounts = [ "", - "/positions/yield/balance/stake", - "/positions/yield/balance/unstake", - "/positions/yield/balance/pending-action", - "/positions/yield/other/stake", - "/positions/yield/other/pending-action", + "/positions/yield/default/stake", + "/positions/yield/default/unstake", + "/positions/yield/default/pending-action", + "/positions/yield/validators/stake", + "/positions/yield/validators/pending-action", ]; for (const base of mounts) { diff --git a/packages/widget/tests/fixtures/index.ts b/packages/widget/tests/fixtures/index.ts index 4ac249a39..5dc419fed 100644 --- a/packages/widget/tests/fixtures/index.ts +++ b/packages/widget/tests/fixtures/index.ts @@ -446,7 +446,7 @@ export const yieldApiActionFixture = ( const intent = overrides?.intent ?? getYieldActionIntent(type); const { completedAt, createdAt, transactions, ...rest } = overrides ?? {}; - return Schema.decodeSync(YieldAction)({ + const action = Schema.decodeSync(YieldAction)({ ...yieldApiActionDtoFixture(), ...rest, ...(completedAt === undefined @@ -459,15 +459,11 @@ export const yieldApiActionFixture = ( ? {} : { createdAt: DateTime.formatIso(createdAt) }), intent, - ...(transactions - ? { - transactions: transactions.map((transaction) => - Schema.encodeSync(ActionTransaction)(transaction) - ), - } - : {}), type, }); + + // Overridden transactions are already decoded. + return transactions ? { ...action, transactions } : action; }; const getYieldActionIntent = (type: YieldAction["type"]) => { diff --git a/packages/widget/tests/services/api/api-retries.test.ts b/packages/widget/tests/services/api/api-retries.test.ts index bbbb94fec..d2e82484f 100644 --- a/packages/widget/tests/services/api/api-retries.test.ts +++ b/packages/widget/tests/services/api/api-retries.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; import { Context, Effect, Layer, Schema } from "effect"; import { FetchHttpClient } from "effect/http"; -import { TransactionId as BorrowTransactionId } from "../../../src/domain/borrow/ids"; import { + ActionId as BorrowActionId, + TransactionId as BorrowTransactionId, +} from "../../../src/domain/borrow/ids"; +import { + SignedTransactionPayload, + TransactionHash, TransactionId, WalletAddress, YieldId, @@ -31,6 +36,7 @@ const transactionId = Schema.decodeSync(TransactionId)("transaction-1"); const borrowTransactionId = Schema.decodeSync(BorrowTransactionId)( "borrow-transaction-1" ); +const borrowActionId = Schema.decodeSync(BorrowActionId)("borrow-action"); type ApiContext = Context.Context< | BorrowOperations @@ -78,7 +84,7 @@ const writes: ReadonlyArray = [ name: "transaction hash submission", run: (context) => Context.get(context, YieldOperations).submitTransactionHash({ - payload: { hash: "0xhash" }, + payload: { hash: TransactionHash.make("0xhash") }, transactionId, }), }, @@ -86,7 +92,9 @@ const writes: ReadonlyArray = [ name: "signed transaction submission", run: (context) => Context.get(context, YieldOperations).submitSignedTransaction({ - payload: { signedTransaction: "0xsigned" }, + payload: { + signedTransaction: SignedTransactionPayload.make("0xsigned"), + }, transactionId, }), }, @@ -103,7 +111,7 @@ const writes: ReadonlyArray = [ { name: "Borrow action step", run: (context) => - Context.get(context, BorrowOperations).stepAction("borrow-action"), + Context.get(context, BorrowOperations).stepAction(borrowActionId), }, { name: "Borrow transaction submission", @@ -145,7 +153,7 @@ const reads: ReadonlyArray = [ { name: "Borrow action lookup", run: (context) => - Context.get(context, BorrowOperations).getAction("borrow-action"), + Context.get(context, BorrowOperations).getAction(borrowActionId), }, ]; diff --git a/packages/widget/tests/services/api/api-service.test.ts b/packages/widget/tests/services/api/api-service.test.ts index 8a2f01363..c32dcc32b 100644 --- a/packages/widget/tests/services/api/api-service.test.ts +++ b/packages/widget/tests/services/api/api-service.test.ts @@ -8,6 +8,7 @@ import { BorrowFeatureDisabled } from "../../../src/domain/borrow/availability"; import { ActionId as BorrowActionId } from "../../../src/domain/borrow/ids"; import { ActionId, + TransactionHash, TransactionId, WalletAddress, YieldId, @@ -49,6 +50,7 @@ const config = { borrowApiUrl: "https://borrow.example.com", yieldsApiUrl: "https://yield.example.com", }; +const borrowActionId = Schema.decodeSync(BorrowActionId)("borrow-action"); const address = Schema.decodeSync(WalletAddress)("0xWallet"); const firstYieldId = Schema.decodeSync(YieldId)("ethereum-eth-native-staking"); @@ -107,7 +109,7 @@ describe("application API services", () => { MissingBorrowApiConfig ); expect( - yield* Effect.flip(operations.getAction("borrow-action")) + yield* Effect.flip(operations.getAction(borrowActionId)) ).toBeInstanceOf(MissingBorrowApiConfig); }) ); @@ -156,7 +158,7 @@ describe("application API services", () => { { present: () => Effect.void } as never ); - expect(yield* operations.getAction("borrow-action")).toBe(null); + expect(yield* operations.getAction(borrowActionId)).toBe(null); expect(getAction).toHaveBeenCalledWith("borrow-action", undefined); }) ); @@ -174,7 +176,7 @@ describe("application API services", () => { ); expect( - yield* Effect.flip(operations.getAction("borrow-action")) + yield* Effect.flip(operations.getAction(borrowActionId)) ).toBeInstanceOf(BorrowFeatureDisabled); expect(getAction).not.toHaveBeenCalled(); }) @@ -292,7 +294,7 @@ describe("application API services", () => { { present } as never ); const command = { - payload: { hash: "0xhash" }, + payload: { hash: TransactionHash.make("0xhash") }, transactionId, }; @@ -363,7 +365,7 @@ describe("application API services", () => { expect(hidden).toMatchObject({ isEncrypted: true, - shareAmountRaw: handle, + shares: { _tag: "Encrypted", handle }, }); expect(hidden?.amount.toFixed()).toBe("0"); }) diff --git a/packages/widget/tests/services/navigation/widget-routes.test.ts b/packages/widget/tests/services/navigation/widget-routes.test.ts index facc593c7..6fae76691 100644 --- a/packages/widget/tests/services/navigation/widget-routes.test.ts +++ b/packages/widget/tests/services/navigation/widget-routes.test.ts @@ -1,6 +1,7 @@ import { Option, Schema } from "effect"; import { describe, expect, it } from "vitest"; import { YieldId } from "../../../src/domain/identity/identifiers"; +import { BalanceDataKey } from "../../../src/domain/portfolio/positions"; import { isWidgetPath, widgetRoutes, @@ -10,12 +11,12 @@ import { describe("widget route registry", () => { it("round-trips params that need encoding", () => { const params = { - balanceId: "a/b c?%", + balanceId: Schema.decodeSync(BalanceDataKey)("validator::a/b c?%"), integrationId: Schema.decodeSync(YieldId)("eth-lido#1"), }; const path = widgetRoutes.positionDetails.build(params); - expect(path).toBe("/positions/eth-lido%231/a%2Fb%20c%3F%25"); + expect(path).toBe("/positions/eth-lido%231/validator%3A%3Aa%2Fb%20c%3F%25"); expect(widgetRoutes.positionDetails.match(path)).toEqual( Option.some(params) ); @@ -32,6 +33,26 @@ describe("widget route registry", () => { ).toBe(true); }); + it("decodes a position balance key and rejects one outside its grammar", () => { + const integrationId = Schema.decodeSync(YieldId)("yield-1"); + + expect( + widgetRoutes.positionDetails.match( + "/positions/yield-1/validator::val-1:7" + ) + ).toEqual(Option.some({ balanceId: "validator::val-1:7", integrationId })); + expect( + widgetRoutes.positionDetails.match("/positions/yield-1/default") + ).toEqual(Option.some({ balanceId: "default", integrationId })); + for (const balanceId of ["balance-1", "validator::", "validator:val-1"]) { + expect( + Option.isNone( + widgetRoutes.positionDetails.match(`/positions/yield-1/${balanceId}`) + ) + ).toBe(true); + } + }); + it("matches exactly, tolerating a trailing slash, or as a section prefix", () => { const route = widgetRoutes.positions; @@ -49,11 +70,14 @@ describe("widget route registry", () => { it("keys a flow mount's Review, Steps, and Complete alike", () => { const flow = widgetYieldFlowRoutes.PositionExit; const params = { - balanceId: "balance-1", + balanceId: Schema.decodeSync(BalanceDataKey)("default"), integrationId: Schema.decodeSync(YieldId)("yield-1"), }; const { completePath, reviewPath, stepsPath } = flow.at(params); - const other = flow.at({ ...params, balanceId: "balance-2" }); + const other = flow.at({ + ...params, + balanceId: Schema.decodeSync(BalanceDataKey)("validators"), + }); const key = flow.mountKey(reviewPath); expect(key).toEqual( @@ -66,4 +90,23 @@ describe("widget route registry", () => { Option.isNone(flow.mountKey(widgetRoutes.positionUnstake.build(params))) ).toBe(true); }); + + it("decodes a borrow position action key and rejects an unknown one", () => { + const route = widgetRoutes.borrowPositionAction; + + expect( + route.match("/positions/borrow/market-1/action/repay-0xabc") + ).toEqual(Option.some({ actionId: "repay-0xabc", marketId: "market-1" })); + expect( + Option.isSome(route.match("/positions/borrow/market-1/action/withdraw")) + ).toBe(true); + expect( + Option.isNone( + route.match("/positions/borrow/market-1/action/borrow-0xabc") + ) + ).toBe(true); + expect( + Option.isNone(route.match("/positions/borrow/market-1/action/repay-")) + ).toBe(true); + }); }); diff --git a/packages/widget/tests/services/semantic-invalidation.test.ts b/packages/widget/tests/services/semantic-invalidation.test.ts index a51d69e31..eb9cb716b 100644 --- a/packages/widget/tests/services/semantic-invalidation.test.ts +++ b/packages/widget/tests/services/semantic-invalidation.test.ts @@ -277,13 +277,19 @@ describe("semantic resource invalidation", () => { yield* Effect.promise(() => vi.waitFor(() => { expect( - AsyncResult.getOrThrow(registry.get(aTokens))[0]?.amount + AsyncResult.getOrThrow( + registry.get(aTokens) + )[0]?.amount?.toFixed() ).toBe("1"); expect( - AsyncResult.getOrThrow(registry.get(bTokens))[0]?.amount + AsyncResult.getOrThrow( + registry.get(bTokens) + )[0]?.amount?.toFixed() ).toBe("2"); expect( - AsyncResult.getOrThrow(registry.get(currentTokens))[0]?.amount + AsyncResult.getOrThrow( + registry.get(currentTokens) + )[0]?.amount?.toFixed() ).toBe("1"); expect(positionCalls).toEqual( new Map([ @@ -308,7 +314,9 @@ describe("semantic resource invalidation", () => { expect(balanceCalls.get(sameWalletCachedScope.address)).toBe(4); expect(positionCalls.get(sameWalletCachedScope.address)).toBe(2); expect( - AsyncResult.getOrThrow(registry.get(currentTokens))[0]?.amount + AsyncResult.getOrThrow( + registry.get(currentTokens) + )[0]?.amount?.toFixed() ).toBe("10"); }) ); @@ -317,7 +325,9 @@ describe("semantic resource invalidation", () => { yield* Effect.promise(() => vi.waitFor(() => expect( - AsyncResult.getOrThrow(registry.get(aTokens))[0]?.amount + AsyncResult.getOrThrow( + registry.get(aTokens) + )[0]?.amount?.toFixed() ).toBe("10") ) ); diff --git a/packages/widget/tests/services/transaction-workflow/confidential-transaction-workflow.test.ts b/packages/widget/tests/services/transaction-workflow/confidential-transaction-workflow.test.ts index 73d0e2dfa..281ce70b6 100644 --- a/packages/widget/tests/services/transaction-workflow/confidential-transaction-workflow.test.ts +++ b/packages/widget/tests/services/transaction-workflow/confidential-transaction-workflow.test.ts @@ -15,15 +15,18 @@ import { TestClock } from "effect/testing"; import { keccak256 } from "viem"; import { type DirectTransaction, + DirectTransactionId, makeErc20Approval, } from "../../../src/domain/action/direct-transaction"; import type { ActionTransaction } from "../../../src/domain/action/models"; +import { zeroEncryptedBalance } from "../../../src/domain/earn/confidential"; import { ConfidentialHandle, - zeroEncryptedBalance, -} from "../../../src/domain/earn/confidential"; -import { EarnPosition } from "../../../src/domain/earn/models"; + EarnPosition, +} from "../../../src/domain/earn/models"; import { + SignedTransactionPayload, + TransactionHash, WalletAddress, YieldId, } from "../../../src/domain/identity/identifiers"; @@ -65,6 +68,10 @@ const address = Schema.decodeSync(WalletAddress)( ); const yieldId = Schema.decodeSync(YieldId)("ethereum-sepolia-zama-vault"); const walletScope = new WalletScopeKey({ address, network: "ethereum" }); +const signedPayload = { + broadcasted: false, + signedTx: SignedTransactionPayload.make("0x02aa"), +} as const; const command = { action: "WITHDRAW", @@ -101,7 +108,7 @@ const wrap: DirectTransaction = { approval: null, chainId: 11155111, data: "0xea598cb0", - id: "conversion-wrap", + id: DirectTransactionId.make("conversion-wrap"), network: "ethereum", to: "0x00000000000000000000000000000000000000c0", type: "WRAP", @@ -117,12 +124,12 @@ const approval = { } as const; const approve = makeErc20Approval({ ...approval, - id: "conversion-approval", + id: DirectTransactionId.make("conversion-approval"), type: "APPROVAL", }); const reset = makeErc20Approval({ ...approval, - id: "conversion-approval", + id: DirectTransactionId.make("conversion-approval"), type: "RESET_APPROVAL", }); @@ -145,8 +152,7 @@ const makeInput = ({ const capabilities = ({ evmRpc, - signTransaction = () => - Effect.succeed({ broadcasted: false, signedTx: "0x02aa" }), + signTransaction = () => Effect.succeed(signedPayload), yieldOperations, zama = { decryptCommand: () => Effect.succeed(decryptedCommand) }, }: Pick< @@ -367,7 +373,7 @@ describe("confidential transaction workflow", () => { }, signTransaction: (input) => Effect.sync(() => signed.push(input.txMeta.txId)).pipe( - Effect.as({ broadcasted: false, signedTx: "0x02aa" }) + Effect.as(signedPayload) ), }), makeInput({ conversion: [wrap], ledger }) @@ -415,7 +421,7 @@ describe("confidential transaction workflow", () => { }, signTransaction: (input) => Effect.sync(() => signed.push(input.txMeta.txId)).pipe( - Effect.as({ broadcasted: false, signedTx: "0x02aa" }) + Effect.as(signedPayload) ), }) ); @@ -437,7 +443,7 @@ describe("confidential transaction workflow", () => { awaitState(workflow, (state) => state._tag === "Completed") ); - expect(held.get("conversion-wrap")?._tag).toBe("Sent"); + expect(held.get(wrap.id)?._tag).toBe("Sent"); expect(signed).toEqual(["conversion-wrap", "deposit"]); }).pipe(Effect.scoped) ); @@ -450,8 +456,12 @@ describe("confidential transaction workflow", () => { ledger, new Map([ [ - "conversion-wrap", - { _tag: "Confirmed", hash: "0x01", transaction: wrap }, + wrap.id, + { + _tag: "Confirmed", + hash: TransactionHash.make("0x01"), + transaction: wrap, + }, ], ]) ); @@ -459,7 +469,7 @@ describe("confidential transaction workflow", () => { capabilities({ signTransaction: (input) => Effect.sync(() => signed.push(input.txMeta.txId)).pipe( - Effect.as({ broadcasted: false, signedTx: "0x02aa" }) + Effect.as(signedPayload) ), }), makeInput({ conversion: [wrap], ledger }) @@ -492,7 +502,7 @@ describe("confidential transaction workflow", () => { }, signTransaction: (input) => Effect.sync(() => signed.push(input.txMeta.txId)).pipe( - Effect.as({ broadcasted: false, signedTx: "0x02aa" }) + Effect.as(signedPayload) ), }), makeInput({ conversion: [wrap], ledger }) @@ -534,7 +544,7 @@ describe("confidential transaction workflow", () => { }, signTransaction: (input) => Effect.sync(() => signed.push(input.txMeta.txId)).pipe( - Effect.as({ broadcasted: false, signedTx: "0x02aa" }) + Effect.as(signedPayload) ), }), makeInput({ conversion: [wrap] }) @@ -572,8 +582,11 @@ describe("confidential transaction workflow", () => { signTransaction: (input) => Effect.succeed( input.txMeta.txId === "conversion-wrap" - ? { broadcasted: true, signedTx: "0xwallethash" } - : { broadcasted: false, signedTx: "0x02aa" } + ? ({ + broadcasted: true, + signedTx: TransactionHash.make("0xeeee"), + } as const) + : signedPayload ), }), makeInput({ conversion: [wrap], ledger }) @@ -581,10 +594,10 @@ describe("confidential transaction workflow", () => { yield* awaitState(workflow, (state) => state._tag === "Completed"); - expect(awaited).toEqual(["0xwallethash"]); - expect((yield* Ref.get(ledger)).get("conversion-wrap")).toEqual({ + expect(awaited).toEqual(["0xeeee"]); + expect((yield* Ref.get(ledger)).get(wrap.id)).toEqual({ _tag: "Confirmed", - hash: "0xwallethash", + hash: "0xeeee", transaction: wrap, }); }).pipe(Effect.scoped) @@ -607,7 +620,7 @@ describe("confidential transaction workflow", () => { }, signTransaction: (input) => Effect.sync(() => signed.push(input.txMeta.txId)).pipe( - Effect.as({ broadcasted: false, signedTx: "0x02aa" }) + Effect.as(signedPayload) ), }), makeInput({ conversion: [wrap], ledger }) @@ -649,7 +662,7 @@ describe("confidential transaction workflow", () => { }, signTransaction: (input) => Effect.sync(() => signed.push(input.txMeta.txId)).pipe( - Effect.as({ broadcasted: false, signedTx: "0x02aa" }) + Effect.as(signedPayload) ), }), makeInput({ conversion: [wrap], ledger }) @@ -663,7 +676,7 @@ describe("confidential transaction workflow", () => { yield* workflow.dispatch({ _tag: "Retry" }); yield* awaitState(workflow, (state) => state._tag === "Completed"); - expect(held.get("conversion-wrap")?._tag).toBe("Sent"); + expect(held.get(wrap.id)?._tag).toBe("Sent"); expect(broadcast).toEqual(["0x02aa", "0x02aa"]); expect(signed).toEqual(["conversion-wrap", "deposit"]); }).pipe(Effect.scoped) @@ -687,7 +700,7 @@ describe("confidential transaction workflow", () => { operation: "transaction", }) ) - : Effect.succeed({ broadcasted: false, signedTx: "0x02aa" }) + : Effect.succeed(signedPayload) ), }), makeInput({ conversion: [wrap] }) @@ -771,7 +784,7 @@ describe("confidential transaction workflow", () => { else if (input.txMeta.txId.includes("approval")) { allowance = approval.amount; } - return { broadcasted: false, signedTx: "0x02aa" }; + return signedPayload; }), }), makeInput({ conversion, ledger }) @@ -790,7 +803,11 @@ describe("confidential transaction workflow", () => { new Map([ [ approve.id, - { _tag: "Confirmed", hash: "0x01", transaction: approve }, + { + _tag: "Confirmed", + hash: TransactionHash.make("0x01"), + transaction: approve, + }, ], ]) ); @@ -811,9 +828,20 @@ describe("confidential transaction workflow", () => { new Map([ [ approve.id, - { _tag: "Confirmed", hash: "0x01", transaction: approve }, + { + _tag: "Confirmed", + hash: TransactionHash.make("0x01"), + transaction: approve, + }, + ], + [ + wrap.id, + { + _tag: "Sent", + hash: TransactionHash.make("0x02"), + transaction: wrap, + }, ], - [wrap.id, { _tag: "Sent", hash: "0x02", transaction: wrap }], ]) ); @@ -834,7 +862,14 @@ describe("confidential transaction workflow", () => { yield* Ref.set( ledger, new Map([ - [reset.id, { _tag: "Sent", hash: "0x01", transaction: reset }], + [ + reset.id, + { + _tag: "Sent", + hash: TransactionHash.make("0x01"), + transaction: reset, + }, + ], ]) ); const signed: Array = []; @@ -856,7 +891,7 @@ describe("confidential transaction workflow", () => { signTransaction: (input) => Effect.sync(() => { signed.push(input.txMeta.txId); - return { broadcasted: false, signedTx: "0x02aa" }; + return signedPayload; }), }), // Planned upfront this time: the same reset as the one sent. @@ -1059,7 +1094,7 @@ describe("confidential transaction workflow", () => { signTransaction: ({ txMeta }) => Effect.sync(() => signed.push([txMeta.txId, txMeta.actionId, txMeta.actionType]) - ).pipe(Effect.as({ broadcasted: false, signedTx: "0x02aa" })), + ).pipe(Effect.as(signedPayload)), }), instantInput ); diff --git a/packages/widget/tests/services/transaction-workflow/transaction-workflow-model.test.ts b/packages/widget/tests/services/transaction-workflow/transaction-workflow-model.test.ts index daf94d4a2..d5bb4c481 100644 --- a/packages/widget/tests/services/transaction-workflow/transaction-workflow-model.test.ts +++ b/packages/widget/tests/services/transaction-workflow/transaction-workflow-model.test.ts @@ -4,6 +4,7 @@ import type { ActionTransaction } from "../../../src/domain/action/models"; import { Action } from "../../../src/domain/borrow/execution/action"; import { Transaction } from "../../../src/domain/borrow/execution/transaction"; import { + TransactionId, TransactionWorkflowId, WalletAddress, YieldId, @@ -287,7 +288,7 @@ describe("transaction workflow model", () => { const common = { batchId: "yield", message: "failed", - transactionId: "yield-1", + transactionId: TransactionId.make("yield-1"), workflowId: TransactionWorkflowId.make("action-1"), }; const states = [ @@ -360,7 +361,7 @@ describe("transaction workflow model", () => { }), operation: "transaction", }, - transactionId: "yield-1", + transactionId: TransactionId.make("yield-1"), workflowId: TransactionWorkflowId.make("action-1"), }); expect(withCustom.message).toBe("Transaction signing failed."); @@ -372,7 +373,7 @@ describe("transaction workflow model", () => { batchId: "yield", network: "ethereum", reason: { _tag: "WalletUnavailable", detail: "disconnected" }, - transactionId: "yield-1", + transactionId: TransactionId.make("yield-1"), workflowId: TransactionWorkflowId.make("action-1"), }); expect(withoutCustom.message).toBe( @@ -396,7 +397,7 @@ describe("transaction workflow model", () => { }), operation: "message", }, - transactionId: "yield-1", + transactionId: TransactionId.make("yield-1"), workflowId: TransactionWorkflowId.make("action-1"), }); @@ -424,7 +425,7 @@ describe("transaction workflow model", () => { const common = { batchId: "yield", message: "failed", - transactionId: "yield-1", + transactionId: TransactionId.make("yield-1"), workflowId: TransactionWorkflowId.make("action-1"), }; const phases = ( @@ -565,7 +566,7 @@ describe("transaction workflow model", () => { message: "declined", network: "ethereum", reason: { _tag: "WalletRequestCancelled", operation: "transaction" }, - transactionId: "yield-2", + transactionId: TransactionId.make("yield-2"), workflowId: TransactionWorkflowId.make("action-1"), }); diff --git a/packages/widget/tests/services/transaction-workflow/transaction-workflow-runtime.test.ts b/packages/widget/tests/services/transaction-workflow/transaction-workflow-runtime.test.ts index f0e4e615b..bd7e1403d 100644 --- a/packages/widget/tests/services/transaction-workflow/transaction-workflow-runtime.test.ts +++ b/packages/widget/tests/services/transaction-workflow/transaction-workflow-runtime.test.ts @@ -20,7 +20,10 @@ import type { ActionTransaction } from "../../../src/domain/action/models"; import { Action } from "../../../src/domain/borrow/execution/action"; import { Transaction } from "../../../src/domain/borrow/execution/transaction"; import { + SignedTransactionPayload, + TransactionHash, WalletAddress, + WalletSignature, YieldId, } from "../../../src/domain/identity/identifiers"; import { @@ -73,9 +76,11 @@ const yieldWalletScope = new WalletScopeKey({ network: "ethereum", }); const borrowWalletScope = new WalletScopeKey({ address, network: "base" }); -const signedPayload = "0xsigned-payload"; -const transactionHash = - "0x1111111111111111111111111111111111111111111111111111111111111111"; +const signedPayload = SignedTransactionPayload.make("0xsigned-payload"); +const messageSignature = WalletSignature.make("0xsigned-payload"); +const transactionHash = TransactionHash.make( + "0x1111111111111111111111111111111111111111111111111111111111111111" +); const actionMeta = { actionId: "yield-action-1", actionType: "stake", @@ -179,9 +184,9 @@ const makeCapabilities = ( initialWalletState: overrides.initialWalletState ?? makeConnectedWalletState(yieldWalletScope), wallet: { - signMessage: () => Effect.succeed(signedPayload), + signMessage: () => Effect.succeed(messageSignature), signTransaction: () => - Effect.succeed({ broadcasted: false, signedTx: signedPayload }), + Effect.succeed({ broadcasted: false as const, signedTx: signedPayload }), ...overrides.wallet, }, yieldOperations: { @@ -919,7 +924,10 @@ describe("transaction workflow runtime", () => { ], }); const signTransaction = vi.fn(() => - Effect.succeed({ broadcasted: false, signedTx: signedPayload }) + Effect.succeed({ + broadcasted: false as const, + signedTx: signedPayload, + }) ); const submit = vi.fn(() => Effect.succeed({ diff --git a/packages/widget/tests/services/wallet/evm-wallet-driver.test.ts b/packages/widget/tests/services/wallet/evm-wallet-driver.test.ts index b80e50acd..fb391f41b 100644 --- a/packages/widget/tests/services/wallet/evm-wallet-driver.test.ts +++ b/packages/widget/tests/services/wallet/evm-wallet-driver.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { Effect } from "effect"; -import type { Hash } from "viem"; import { zeroAddress } from "viem"; +import { TransactionHash } from "../../../src/domain/identity/identifiers"; import { decodeBorrowEvmTransaction, decodeEvmTransaction, @@ -108,7 +108,7 @@ describe("EVM wallet driver", () => { it.effect("returns a broadcast result from the core send command", () => Effect.gen(function* () { - const hash = `0x${"1".repeat(64)}` as Hash; + const hash = TransactionHash.make(`0x${"1".repeat(64)}`); const sendTransaction = vi.fn(() => Effect.succeed({ broadcasted: true as const, signedTx: hash }) ); diff --git a/packages/widget/tests/services/wallet/external-provider-contract.test.ts b/packages/widget/tests/services/wallet/external-provider-contract.test.ts index ddbd9927c..53d086558 100644 --- a/packages/widget/tests/services/wallet/external-provider-contract.test.ts +++ b/packages/widget/tests/services/wallet/external-provider-contract.test.ts @@ -4,6 +4,7 @@ import type { RefObject } from "react"; import { ActionId as BorrowActionId, IntegrationId as BorrowIntegrationId, + MarketId as BorrowMarketId, TransactionId as BorrowTransactionId, } from "../../../src/domain/borrow/ids"; import { @@ -87,7 +88,7 @@ const borrowTransactionMeta = { integrationId: Schema.decodeSync(BorrowIntegrationId)("aave-v3"), rawArguments: { amount: "1", - marketId: "aave-v3-ethereum-usdc", + marketId: Schema.decodeSync(BorrowMarketId)("aave-v3-ethereum-usdc"), }, txId: Schema.decodeSync(BorrowTransactionId)("borrow-transaction-id"), txType: "BORROW", diff --git a/packages/widget/tests/services/wallet/external-provider-wallet-driver.test.ts b/packages/widget/tests/services/wallet/external-provider-wallet-driver.test.ts index 1edebb720..4f94c0227 100644 --- a/packages/widget/tests/services/wallet/external-provider-wallet-driver.test.ts +++ b/packages/widget/tests/services/wallet/external-provider-wallet-driver.test.ts @@ -9,6 +9,7 @@ import { import { ActionId as BorrowActionId, IntegrationId as BorrowIntegrationId, + MarketId as BorrowMarketId, TransactionId as BorrowTransactionId, } from "../../../src/domain/borrow/ids"; import { WalletAddress } from "../../../src/domain/identity/identifiers"; @@ -52,7 +53,7 @@ const borrowTxMeta = { integrationId: Schema.decodeSync(BorrowIntegrationId)("aave-v3"), rawArguments: { amount: "1", - marketId: "aave-v3-ethereum-usdc", + marketId: Schema.decodeSync(BorrowMarketId)("aave-v3-ethereum-usdc"), }, txId: Schema.decodeSync(BorrowTransactionId)("borrow-transaction-id"), txType: "BORROW", diff --git a/packages/widget/tests/services/wallet/wallet-atoms.test.ts b/packages/widget/tests/services/wallet/wallet-atoms.test.ts index 6e9fb7680..3b4684a9d 100644 --- a/packages/widget/tests/services/wallet/wallet-atoms.test.ts +++ b/packages/widget/tests/services/wallet/wallet-atoms.test.ts @@ -204,7 +204,7 @@ describe("wallet Effect Atom boundaries", () => { }) ).toMatchObject({ network: null }); expect( - Schema.decodeSync(InitParams)({ + Schema.decodeUnknownSync(InitParams)({ ...emptyInitParams, pendingaction: "unstake", }) diff --git a/packages/widget/tests/services/wallet/wallet-router.test.ts b/packages/widget/tests/services/wallet/wallet-router.test.ts index 92a6048ef..302982f07 100644 --- a/packages/widget/tests/services/wallet/wallet-router.test.ts +++ b/packages/widget/tests/services/wallet/wallet-router.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from "@effect/vitest"; import { Effect, Schema } from "effect"; -import { type Chain, type Hash, type Hex, zeroAddress } from "viem"; +import { type Chain, zeroAddress } from "viem"; import type { Connector } from "wagmi"; -import { WalletAddress } from "../../../src/domain/identity/identifiers"; +import { + TransactionHash, + WalletAddress, + WalletSignature, +} from "../../../src/domain/identity/identifiers"; import type { Network } from "../../../src/domain/network/network"; import { routeWalletAccountSwitch, @@ -75,11 +79,13 @@ const actions = () => sendEvmTransaction: vi.fn(() => Effect.succeed({ broadcasted: true as const, - signedTx: "0xhash" as Hash, + signedTx: TransactionHash.make("0xhash"), }) ), - signMessage: vi.fn(() => Effect.succeed("0xsignature" as Hex)), - signTypedData: vi.fn(() => Effect.succeed("0xtyped" as Hex)), + signMessage: vi.fn(() => + Effect.succeed(WalletSignature.make("0xsignature")) + ), + signTypedData: vi.fn(() => Effect.succeed(WalletSignature.make("0xtyped"))), switchChain: vi.fn(() => Effect.die("unused")), }) satisfies WagmiActions; diff --git a/packages/widget/tests/services/zama-service.test.ts b/packages/widget/tests/services/zama-service.test.ts index e9c83cfef..42b8cf2d6 100644 --- a/packages/widget/tests/services/zama-service.test.ts +++ b/packages/widget/tests/services/zama-service.test.ts @@ -10,11 +10,12 @@ import { import { decodeFunctionData, erc20Abi } from "viem"; import { sepolia } from "viem/chains"; import { vi } from "vitest"; +import { ConfidentialTokenBalanceKey } from "../../src/domain/earn/confidential"; +import { ConfidentialHandle } from "../../src/domain/earn/models"; import { - ConfidentialHandle, - ConfidentialTokenBalanceKey, -} from "../../src/domain/earn/confidential"; -import { WalletAddress } from "../../src/domain/identity/identifiers"; + WalletAddress, + WalletSignature, +} from "../../src/domain/identity/identifiers"; import { WalletScopeKey, walletScopeOwnerKey, @@ -101,6 +102,10 @@ const conversion = { wrapper: cUsdc.address, }; +// The SDK returns 32-byte hex handles. +const amountHandle = `0x${"01".repeat(32)}`; +const legHandles = [`0x${"02".repeat(32)}`, `0x${"03".repeat(32)}`]; + const revealedHandle = Schema.decodeSync(ConfidentialHandle)( `0x${"cd".repeat(32)}` ); @@ -128,7 +133,7 @@ const makeLayer = Effect.fn("makeZamaTestLayer")(function* (options: { readonly request: (args: unknown) => Promise; }; readonly wagmiChains?: ReadonlyArray<{ readonly id: number }>; - readonly signPermit?: (input: unknown) => Effect.Effect<`0x${string}`>; + readonly signPermit?: (input: unknown) => Effect.Effect; }) { const wallet = yield* makeTestWallet({ canSignPermit: Effect.succeed(options.canSignPermit ?? true), @@ -269,10 +274,8 @@ describe("ZamaService", () => { Effect.gen(function* () { sdk.balanceOf.mockResolvedValueOnce(5_000_000n); sdk.encrypt - .mockImplementationOnce(() => encryptAs(["0xamount"], "0xap")) - .mockImplementationOnce(() => - encryptAs(["0xleg0", "0xleg1"], "0xlp") - ); + .mockImplementationOnce(() => encryptAs([amountHandle], "0xap")) + .mockImplementationOnce(() => encryptAs(legHandles, "0xlp")); const { layer } = yield* makeLayer({}); const command = yield* ZamaService.use((zama) => @@ -289,9 +292,9 @@ describe("ZamaService", () => { [router, [0n, 5_000_000n]], ]); expect(command.arguments).toEqual({ - encryptedAmount: "0xamount", + encryptedAmount: amountHandle, encryptedAmountProof: "0xap", - encryptedLegAmounts: ["0xleg0", "0xleg1"], + encryptedLegAmounts: legHandles, encryptedLegProof: "0xlp", inputToken: cUsdc.address, }); @@ -301,7 +304,7 @@ describe("ZamaService", () => { it.effect("encrypts the wrapped amount of an underlying deposit", () => Effect.gen(function* () { client.readContract.mockResolvedValueOnce(2n); - sdk.encrypt.mockImplementation(() => encryptAs(["0xh"], "0xp")); + sdk.encrypt.mockImplementation(() => encryptAs([amountHandle], "0xp")); const { layer } = yield* makeLayer({}); yield* ZamaService.use((zama) => @@ -393,7 +396,7 @@ describe("ZamaService", () => { signPermit: (input) => Effect.sync(() => { signed.push(input); - return "0xsig" as const; + return WalletSignature.make("0x5167"); }), }); @@ -428,7 +431,7 @@ describe("ZamaService", () => { relayerUrl: "https://api.example.test/v1/zama/relayer/11155111/", }); expect(values.handles.get(handle)).toBe(42n); - expect(signature).toBe("0xsig"); + expect(signature).toBe("0x5167"); expect(signed).toEqual([ { expectedOwner: { address: owner, network: "ethereum-sepolia" }, diff --git a/packages/widget/tests/utils/transaction-workflow-test-kit.test.ts b/packages/widget/tests/utils/transaction-workflow-test-kit.test.ts index 5952ab9d7..185edac1f 100644 --- a/packages/widget/tests/utils/transaction-workflow-test-kit.test.ts +++ b/packages/widget/tests/utils/transaction-workflow-test-kit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Context, Effect, Exit, Layer } from "effect"; +import { Cause, Context, Effect, Exit, Layer, Schema } from "effect"; +import { ActionId as BorrowActionId } from "../../src/domain/borrow/ids"; import type { WalletAddress } from "../../src/domain/identity/identifiers"; import { WalletScopeOwnerKey } from "../../src/domain/wallet/wallet-scope"; import { BorrowOperations } from "../../src/services/api/operations"; @@ -16,6 +17,7 @@ const owner = new WalletScopeOwnerKey({ address: "0x0000000000000000000000000000000000000001" as WalletAddress, network: "ethereum", }); +const borrowActionId = Schema.decodeSync(BorrowActionId)("id"); describe("makeTransactionWorkflowTestKit", () => { it.effect("provides a fresh Workflow layer and adapter controls", () => @@ -51,7 +53,7 @@ describe("makeTransactionWorkflowTestKit", () => { const context = yield* Layer.build(kit.layer); expect( - yield* Context.get(context, BorrowOperations).getAction("id") + yield* Context.get(context, BorrowOperations).getAction(borrowActionId) ).toBe(null); }) ); @@ -63,7 +65,7 @@ describe("makeTransactionWorkflowTestKit", () => { }); const context = yield* Layer.build(kit.layer); const exit = yield* Effect.exit( - Context.get(context, BorrowOperations).getAction("id") + Context.get(context, BorrowOperations).getAction(borrowActionId) ); expect(Exit.isFailure(exit)).toBe(true); From 16333d03b02ef39d8effaa34d2c07ae4de2e8b13 Mon Sep 17 00:00:00 2001 From: Petar Todorovic Date: Wed, 28 Oct 2026 15:55:10 +0100 Subject: [PATCH 2/8] refactor(widget): ban type assertions and enforce exact optional types Casts, non-null assertions and optional keys set to undefined let values bypass the branded types the previous change introduced. - Enable Biome noUnsafeTypeAssertion for widget src, noNonNullAssertion, exhaustive switches, promise misuse rules, and tsconfig noImplicitReturns and exactOptionalPropertyTypes. Remaining assertions are single-line suppressions at third-party library seams. - Add the no-unbranded-domain-identifier ast-grep rule: id-like domain fields must use a brand or literal union. - Type token helpers from Token, WalletScopeOwner.address as WalletAddress, yield-id predicates as YieldId, EVM RPC chain ids as EvmChainIds, and wallet routing ids honestly instead of strings disguised as numbers. - Add ContractAddress and WalletConnect topic, CAIP chain and account brands; the route registry alone mints WidgetPath. - Check tracking properties per event and page at compile time. - Host config: an unknown network key or malformed entry in validatorsConfig, preferredTokenYieldsPerNetwork, chainIconMapping or yieldProviderOrder drops only that entry with a warning. Public types for these settings are keyed by network. --- biome.json | 31 ++- .../examples/with-vite-bundled/src/main.ts | 5 +- packages/examples/with-vite/src/main.tsx | 5 +- packages/widget/playground/panel.tsx | 4 +- .../architecture/check-architecture.ts | 1 - .../dependency-cruiser.config.mts | 9 +- .../generated-api/generated-schema-safety.ts | 2 + .../check-production-host-consumption.ts | 14 +- .../composition/providers/theme-wrapper.tsx | 35 ++- .../providers/widget-presentation.tsx | 17 +- .../dashboard/dashboard-tabs/index.tsx | 4 +- .../layouts/dashboard/dashboard-tabs/tab.tsx | 4 +- .../src/domain/action/action-command.ts | 4 +- .../src/domain/action/direct-transaction.ts | 5 +- .../src/domain/action/pending-action.ts | 2 +- packages/widget/src/domain/borrow/network.ts | 15 +- .../widget/src/domain/borrow/responses.ts | 21 +- .../domain/borrow/risk/collateral-state.ts | 5 +- .../src/domain/borrow/risk/market-risk.ts | 12 +- .../src/domain/decoding/response-schema.ts | 10 +- .../widget/src/domain/earn/confidential.ts | 32 ++- packages/widget/src/domain/earn/kyc.ts | 41 +-- packages/widget/src/domain/earn/models.ts | 38 +-- packages/widget/src/domain/earn/stake.ts | 15 +- .../src/domain/earn/yield-provider-order.ts | 3 +- packages/widget/src/domain/earn/yield.ts | 88 +++--- packages/widget/src/domain/finance/gas.ts | 8 +- packages/widget/src/domain/finance/price.ts | 7 +- packages/widget/src/domain/health/models.ts | 12 +- .../widget/src/domain/identity/identifiers.ts | 6 + .../widget/src/domain/portfolio/positions.ts | 77 +++--- .../widget/src/domain/portfolio/rewards.ts | 11 +- .../src/domain/portfolio/token-balance.ts | 4 - packages/widget/src/domain/token/token.ts | 26 +- packages/widget/src/domain/wallet/models.ts | 16 +- packages/widget/src/domain/wallet/network.ts | 19 +- .../widget/src/domain/wallet/wallet-policy.ts | 4 +- .../widget/src/domain/wallet/wallet-scope.ts | 7 +- .../features/activity/state/page-resources.ts | 2 +- .../model/borrow-completion.ts | 2 +- .../action-preparation/model/open-position.ts | 5 +- .../action-preparation/model/withdraw.ts | 5 +- .../borrow/amount-input/ui/amount-field.tsx | 10 +- .../borrow/borrow-entry/model/details.ts | 2 +- .../ui/components/asset-selector.tsx | 8 +- .../borrow/market-position/model/details.ts | 2 +- .../market-position/ui/actions.page.tsx | 2 +- .../ui/components/metric-cards.tsx | 4 +- .../state/earn-selection/catalog/catalog.ts | 5 +- .../earn/state/earn-selection/model/view.ts | 9 +- .../earn/state/earn-selection/state/atoms.ts | 3 +- .../state/resource-observations.ts | 2 +- .../earn/ui/compact/earn-page/earn.page.tsx | 10 +- .../select-opportunity-list-item/index.tsx | 2 +- .../components/network-detail-value.tsx | 4 +- .../earn-details/earn-details-model.tsx | 12 +- .../earn-details/reward-rate-chart.tsx | 8 +- .../navigation/ui/widget-navigate.tsx | 2 +- .../portfolio/react/use-position-list-item.ts | 12 +- .../state/read-models/position-list-item.ts | 16 ++ .../portfolio/state/read-models/positions.ts | 3 - .../portfolio/state/read-models/summary.ts | 5 +- .../ui/components/earn-position-list-item.tsx | 31 +-- .../model/exit-receive-token.ts | 14 +- .../position-details/model/flow-actions.ts | 4 +- .../model/position-overview.ts | 20 +- .../position-details/state/workflow-view.ts | 2 +- .../compact/components/position-balances.tsx | 13 +- .../ui/components/amount-block.tsx | 7 +- .../position-details-pending-actions.tsx | 22 +- .../ui/components/static-action-block.tsx | 9 +- .../tracking/react/use-track-event.ts | 8 +- .../features/tracking/react/use-track-page.ts | 10 +- .../src/features/tracking/state/commands.ts | 11 +- .../src/features/wallet/state/wallet-modal.ts | 5 +- .../wallet/ui/wallet-dialog/chain-icon.tsx | 2 +- .../wallet/ui/wallet-dialog/index.tsx | 2 +- .../react/use-widget-config.ts | 4 +- .../state/widget-config.ts | 8 +- .../features/widget-shell/react/use-header.ts | 2 +- .../ui/compact/details/components/tab.tsx | 2 +- .../ui/compact/details/components/tabs.tsx | 2 +- .../model/confidential-token-balance.ts | 3 +- .../features/yield-entry/model/yield-entry.ts | 2 +- .../ui/components/select-validator/index.tsx | 4 +- .../components/select-validator/meta-info.tsx | 2 +- .../yield-summary/model/estimated-rewards.ts | 2 +- .../model/yield-details-formatters.ts | 2 +- .../yield-summary/model/yield-summary.ts | 8 +- .../react/use-yield-meta-info.tsx | 10 +- .../ui/components/meta-info/index.tsx | 2 +- .../components/reward-token-details/index.tsx | 15 +- .../model/yield-transaction-flow.ts | 34 +-- .../model/yield-transaction-workflow.ts | 2 +- .../state/review-page.ts | 6 +- .../ui/complete/hooks/use-complete.hook.ts | 4 +- .../ui/complete/pages/common.page.tsx | 40 +-- .../review/hooks/use-pending-review.hook.ts | 14 +- .../review/hooks/use-unstake-review.hook.ts | 2 +- .../components/review-top-section.tsx | 18 +- packages/widget/src/public-api/types.ts | 42 +-- .../borrow-positions/borrow-positions.ts | 17 +- .../earn-token-catalog/earn-token-catalog.ts | 22 +- .../gas-token-balances/gas-token-balances.ts | 2 +- .../resource-failure-presentation.ts | 7 +- .../reward-summaries/reward-summaries.ts | 4 +- .../validator-directory.ts | 17 +- .../services/api/legacy-resource-source.ts | 8 +- .../src/services/api/resource-sources.ts | 4 +- .../src/services/config/theme-decoder.ts | 101 ++++--- .../services/config/widget-config-boundary.ts | 254 +++++++++++++++--- .../src/services/config/widget-config.ts | 100 ++++--- .../widget/src/services/evm-rpc/evm-rpc.ts | 12 +- .../src/services/navigation/widget-routes.ts | 115 ++++---- .../persistence/widget-persistence.ts | 4 +- .../src/services/tracking/tracking-service.ts | 24 +- .../widget/src/services/tracking/types.ts | 140 +++++++++- .../internal/preparation.ts | 3 +- .../transaction-workflow/internal/signing.ts | 18 +- .../transaction-workflow-model.ts | 15 +- .../translation/English/translations.json | 6 - .../translation/French/translations.json | 6 - .../translation/widget-translation.ts | 7 +- .../services/wallet/default-wagmi-config.ts | 6 +- .../adapters/cardano/cardano-connector.ts | 38 ++- .../wallet/internal/adapters/cosmos/chains.ts | 14 +- .../cosmos/chains/get-chain-registry.ts | 137 +++++----- .../internal/adapters/cosmos/chains/index.ts | 49 ++-- .../wallet/internal/adapters/cosmos/config.ts | 62 +++-- .../adapters/cosmos/cosmos-connector.ts | 38 +-- .../adapters/cosmos/extension-wallet.ts | 7 +- .../adapters/cosmos/wallet-connect.ts | 69 +++-- .../adapters/cosmos/wallet-manager.ts | 19 +- .../wallet/internal/adapters/evm/chains.ts | 64 +++-- .../wallet/internal/adapters/evm/driver.ts | 30 ++- .../adapters/evm/finery-wallet-list/index.ts | 7 +- .../adapters/evm/injected-availability.ts | 2 +- .../internal/adapters/evm/transaction.ts | 24 +- .../wallet/internal/adapters/evm/utils.ts | 3 +- .../wallet/internal/adapters/evm/wallets.ts | 69 +++-- .../adapters/external-provider/driver.ts | 18 +- .../adapters/external-provider/index.ts | 25 +- .../wallet/internal/adapters/ledger/config.ts | 2 +- .../wallet/internal/adapters/ledger/driver.ts | 2 +- .../adapters/ledger/ledger-connector.ts | 99 +++---- .../ledger/ledger-live-connector-meta.ts | 16 +- .../ledger/polkadot-ledger-transaction.ts | 71 ++--- .../ledger/prepare-ledger-live-transaction.ts | 250 +++++++++-------- .../wallet/internal/adapters/ledger/utils.ts | 223 ++++++--------- .../wallet/internal/adapters/safe/driver.ts | 6 +- .../adapters/solana/solana-connector.ts | 16 +- .../adapters/stellar/stellar-connector.ts | 12 +- .../adapters/substrate/substrate-connector.ts | 16 +- .../substrate/substrate-wallet-connect.ts | 52 ++-- .../internal/adapters/ton/ton-connector.ts | 23 +- .../internal/adapters/ton/transaction.ts | 13 +- .../internal/adapters/tron/tron-connector.ts | 4 +- .../adapters/tron/tron-wallet-connect.ts | 14 +- .../internal/adapters/wagmi-connect-result.ts | 13 +- .../adapters/wagmi-connector-events.ts | 17 +- .../wallet/internal/normalize-chain-id.ts | 7 - .../platform/stellar-wallets-kit-platform.ts | 29 +- .../internal/platform/wagmi-operations.ts | 31 ++- .../platform/wallet-connect-presentation.ts | 67 +++-- .../platform/wallet-connect-protocol.ts | 86 ++++-- .../runtime/external-provider-sync.ts | 8 +- .../internal/runtime/initial-connection.ts | 4 +- .../wallet/internal/runtime/router.ts | 18 +- .../runtime/solana-connector-membership.ts | 2 +- .../wallet/internal/runtime/solana-runtime.ts | 30 +-- .../runtime/solana-wallet-connect-adapter.ts | 60 +++-- .../internal/runtime/state-projection.ts | 2 +- .../services/wallet/internal/runtime/state.ts | 9 +- .../wallet/internal/runtime/wagmi-config.ts | 89 +++--- .../services/wallet/internal/wagmi-seam.ts | 37 +++ .../src/services/wallet/wallet-chain-icons.ts | 13 +- .../src/services/wallet/wallet-commands.ts | 6 +- .../src/services/wallet/wallet-connectors.ts | 7 +- .../src/services/wallet/wallet-descriptors.ts | 4 +- .../services/wallet/wallet-transactions.ts | 10 +- .../widget/src/services/zama/zama-service.ts | 182 ++++++++----- .../widget/src/shared/assets/network-logo.ts | 4 +- .../widget/src/shared/effect/merge-deep.ts | 16 +- .../widget/src/shared/effect/pagination.ts | 2 +- .../widget/src/shared/lib/borrow-token.ts | 4 +- .../src/shared/styles/recipe-variant.ts | 4 +- .../src/shared/styles/theme/atoms.css.ts | 19 +- .../styles/theme/variant-overrides/finery.ts | 9 +- .../src/shared/styles/tokens/typography.ts | 4 - .../components/amount-token-section/index.tsx | 14 +- .../ui/components/collapsible/index.tsx | 4 +- .../ui/components/lazy-component/index.tsx | 2 + .../ui/components/number-input/index.tsx | 6 +- .../ui/components/select-modal/index.tsx | 10 +- .../token-icon/network-icon-image/index.tsx | 2 +- .../hooks/use-variant-network-urls.ts | 4 +- .../hooks/use-variant-token-urls.ts | 2 +- .../token-icon/token-icon-container/index.tsx | 7 +- .../token-icon/token-icon-image/index.tsx | 6 +- .../ui/components/virtual-list/index.tsx | 12 +- .../src/shared/ui/primitives/anchor/index.tsx | 4 +- .../src/shared/ui/primitives/box/index.tsx | 4 +- .../ui/primitives/content-loader/index.tsx | 6 +- .../src/shared/ui/primitives/icons/check.tsx | 4 +- .../src/shared/ui/primitives/icons/copy.tsx | 4 +- .../src/shared/ui/primitives/image/index.tsx | 6 +- .../shared/ui/primitives/list/list-item.tsx | 2 +- .../ui/primitives/typography/styles.css.ts | 70 +---- .../src/shared/ui/widget-presentation.tsx | 3 +- .../app/journeys/deep-links-flow/setup.ts | 37 ++- .../tests/app/journeys/staking-flow/setup.ts | 30 ++- .../app/journeys/trust-incentive-apy/setup.ts | 5 +- .../application-runtime-identity.dom.test.tsx | 2 +- .../runtime/widget-runtime-services.test.ts | 20 +- .../action/action-command-truncation.test.ts | 4 - .../tests/domain/borrow/risk-position.test.ts | 21 +- .../api-forward-compatibility.test.ts | 6 +- .../widget/tests/domain/finance/price.test.ts | 5 +- .../widget/tests/domain/token/token.test.ts | 2 +- .../activity-action-list-item.test.ts | 8 +- .../activity/activity-actions.test.ts | 12 +- .../activity/activity-workflow.dom.test.tsx | 3 +- .../borrow-execution-flow.browser.test.tsx | 11 +- .../borrow/borrow-entry/atoms.test.ts | 4 +- .../features/earn/earn-selection.test.ts | 26 +- .../mount-animation/mount-animation.test.ts | 4 +- .../positions-list-item.browser.test.tsx | 12 +- .../position-details-exit-command.test.ts | 7 +- .../position-overview-model.test.tsx | 22 -- .../tracking/tracking-ingress.dom.test.tsx | 14 +- .../external-provider-lifecycle.dom.test.tsx | 54 ++-- .../features/wallet/sk-wallet.dom.test.tsx | 8 +- .../wallet/wallet-scope-route.dom.test.tsx | 2 +- .../yield-entry-input-token.test.ts | 1 - .../yield-entry-submission.test.ts | 14 +- .../features/yield-flow-confidential.test.ts | 5 +- .../yield-review-fees.browser.test.tsx | 2 +- .../config/widget-config-boundary.test.ts | 208 +++++++++++++- .../tests/services/revealed-balances.test.ts | 11 +- .../services/semantic-invalidation.test.ts | 8 +- .../tracking/tracking-service.test.ts | 38 ++- .../translation/widget-translation.test.ts | 85 ++++-- .../ecosystem-connector-gate.dom.test.ts | 3 - .../wallet/evm-wallet-provider.dom.test.ts | 49 ++-- .../external-provider-connector.test.ts | 8 +- .../prepare-ledger-live-transaction.test.ts | 5 +- ...ana-wallet-connect-adapter.browser.test.ts | 3 +- .../stellar-wallets-kit-platform.test.ts | 4 +- .../wallet/tron-wallet-connect.test.ts | 2 +- .../wallet/wagmi-connector-events.test.ts | 4 +- .../services/wallet/wallet-atoms.test.ts | 4 - .../wallet-connect-presentation.test.ts | 82 +++--- .../wallet/wallet-connect-protocol.test.ts | 28 +- .../services/wallet/wallet-state.test.ts | 7 +- .../tests/services/zama-service.test.ts | 15 +- .../utils/services/tracking-service.test.ts | 14 +- .../tests/utils/services/tracking-service.ts | 23 +- .../tests/utils/wallet-connect-picker.tsx | 8 +- packages/widget/tests/utils/wallet-connect.ts | 13 +- packages/widget/vite/vite.config.base.ts | 2 +- .../no-unbranded-domain-identifier-test.yml | 20 ++ .../rules/no-unbranded-domain-identifier.yml | 32 +++ tsconfig.base.json | 4 +- 263 files changed, 3267 insertions(+), 2249 deletions(-) delete mode 100644 packages/widget/src/domain/portfolio/token-balance.ts delete mode 100644 packages/widget/src/services/wallet/internal/normalize-chain-id.ts create mode 100644 packages/widget/src/services/wallet/internal/wagmi-seam.ts create mode 100644 tools/ast-grep/rule-tests/no-unbranded-domain-identifier-test.yml create mode 100644 tools/ast-grep/rules/no-unbranded-domain-identifier.yml diff --git a/biome.json b/biome.json index 24886b8b5..f7ca42ac5 100644 --- a/biome.json +++ b/biome.json @@ -15,7 +15,8 @@ "!**/region-iso-3166-codes.ts", "!**/mockServiceWorker.js", "!**/package.json", - "!**/.repos" + "!**/.repos", + "!!**/@dedot/api/chaintypes/substrate/tx.d.ts" ] }, "formatter": { @@ -37,7 +38,7 @@ "rules": { "preset": "recommended", "style": { - "noNonNullAssertion": "off", + "noNonNullAssertion": "error", "noParameterAssign": "error", "useAsConstAssertion": "error", "useDefaultParameterLast": "error", @@ -66,7 +67,11 @@ "useIndexOf": "off" }, "nursery": { - "useReactCompiler": "error" + "useReactCompiler": "error", + "useExhaustiveSwitchCases": "error", + "noFloatingPromises": "error", + "noMisusedPromises": "error", + "noUselessTypeConversion": "error" } }, "domains": { @@ -76,6 +81,26 @@ } }, "overrides": [ + { + "includes": ["packages/widget/src/**"], + "linter": { + "rules": { + "nursery": { + "noUnsafeTypeAssertion": "error" + } + } + } + }, + { + "includes": ["packages/widget/tests/**"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + }, { "includes": [ "packages/widget/src/**", diff --git a/packages/examples/with-vite-bundled/src/main.ts b/packages/examples/with-vite-bundled/src/main.ts index 9d5d5c3ff..b32c1382f 100644 --- a/packages/examples/with-vite-bundled/src/main.ts +++ b/packages/examples/with-vite-bundled/src/main.ts @@ -2,8 +2,11 @@ import "./style.css"; import "@stakekit/widget/style.css"; import { darkTheme, renderSKWidget } from "@stakekit/widget/bundle"; +const container = document.querySelector("#app"); +if (!container) throw new Error("Missing #app container"); + renderSKWidget({ - container: document.querySelector("#app")!, + container, apiKey: import.meta.env.VITE_API_KEY, theme: darkTheme, }); diff --git a/packages/examples/with-vite/src/main.tsx b/packages/examples/with-vite/src/main.tsx index 95e2bdc2c..a6e452fa0 100644 --- a/packages/examples/with-vite/src/main.tsx +++ b/packages/examples/with-vite/src/main.tsx @@ -2,7 +2,10 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; -ReactDOM.createRoot(document.getElementById("root")!).render( +const root = document.getElementById("root"); +if (!root) throw new Error("Missing #root element"); + +ReactDOM.createRoot(root).render( diff --git a/packages/widget/playground/panel.tsx b/packages/widget/playground/panel.tsx index 98c902551..4fe825dd2 100644 --- a/packages/widget/playground/panel.tsx +++ b/packages/widget/playground/panel.tsx @@ -40,7 +40,7 @@ const Choice = ({ value, }: { readonly disabled?: boolean; - readonly hint?: string; + readonly hint?: string | undefined; readonly label: string; readonly onChange: (value: Value) => void; readonly options: ReadonlyArray; @@ -75,7 +75,7 @@ const Toggle = ({ }: { readonly checked: boolean; readonly disabled?: boolean; - readonly hint?: string; + readonly hint?: string | undefined; readonly label: string; readonly onChange: (checked: boolean) => void; }) => ( diff --git a/packages/widget/scripts/architecture/check-architecture.ts b/packages/widget/scripts/architecture/check-architecture.ts index cd70482ee..efab11b94 100644 --- a/packages/widget/scripts/architecture/check-architecture.ts +++ b/packages/widget/scripts/architecture/check-architecture.ts @@ -25,7 +25,6 @@ const { options: configuredOptions = {}, ...ruleSet } = const cruiseOutput = await cruise(["src"], { ...configuredOptions, baseDir: widgetRoot, - outputType: undefined, ruleSet, validate: true, }); diff --git a/packages/widget/scripts/architecture/dependency-cruiser.config.mts b/packages/widget/scripts/architecture/dependency-cruiser.config.mts index 294e2f40f..9a3165327 100644 --- a/packages/widget/scripts/architecture/dependency-cruiser.config.mts +++ b/packages/widget/scripts/architecture/dependency-cruiser.config.mts @@ -271,10 +271,11 @@ const compileCollection = ( sameOwnerInterfacePath: `^${declaration.root}/$1/${interfacePattern}\\.ts$`, interfacePaths, interfaceImporters, - parentInterfacePath: - declaration.parent === undefined - ? undefined - : `^${declaration.parent}/${interfacePattern}\\.ts$`, + ...(declaration.parent === undefined + ? {} + : { + parentInterfacePath: `^${declaration.parent}/${interfacePattern}\\.ts$`, + }), }; }; diff --git a/packages/widget/scripts/generated-api/generated-schema-safety.ts b/packages/widget/scripts/generated-api/generated-schema-safety.ts index d33813bd8..50e99830b 100644 --- a/packages/widget/scripts/generated-api/generated-schema-safety.ts +++ b/packages/widget/scripts/generated-api/generated-schema-safety.ts @@ -97,6 +97,8 @@ const schemaMemberName = (expression: ts.Expression): string | undefined => { ) { return current.expression.name.text; } + + return undefined; }; const isEmptyStruct = (expression: ts.Expression): boolean => { diff --git a/packages/widget/scripts/smoke/check-production-host-consumption.ts b/packages/widget/scripts/smoke/check-production-host-consumption.ts index 2c4287948..94a1d33e2 100644 --- a/packages/widget/scripts/smoke/check-production-host-consumption.ts +++ b/packages/widget/scripts/smoke/check-production-host-consumption.ts @@ -311,12 +311,18 @@ const assertEncryptWorkerStarts = async ( name: string; }) => Worker) : undefined; - if (scriptUrl === null && InlineWorker === undefined) { + const startWorker = () => { + if (InlineWorker) return new InlineWorker({ name: "zama-fhe-encrypt" }); + if (scriptUrl === null) return null; + return new Worker(scriptUrl, { + name: "zama-fhe-encrypt", + type: "module", + }); + }; + const worker = startWorker(); + if (worker === null) { return `${chunk} does not export an inline worker constructor`; } - const worker = InlineWorker - ? new InlineWorker({ name: "zama-fhe-encrypt" }) - : new Worker(scriptUrl!, { name: "zama-fhe-encrypt", type: "module" }); const { promise, resolve } = Promise.withResolvers(); worker.addEventListener("message", (event) => { if (event.data === "zama-encrypt-worker-ready") resolve("ready"); diff --git a/packages/widget/src/app/composition/providers/theme-wrapper.tsx b/packages/widget/src/app/composition/providers/theme-wrapper.tsx index 6757e6d7f..c513154fc 100644 --- a/packages/widget/src/app/composition/providers/theme-wrapper.tsx +++ b/packages/widget/src/app/composition/providers/theme-wrapper.tsx @@ -1,4 +1,5 @@ import { assignInlineVars } from "@vanilla-extract/dynamic"; +import { Predicate } from "effect"; import type { PropsWithChildren } from "react"; import { useWidgetConfig } from "../../../features/widget-configuration/index"; import type { WidgetConfig } from "../../../services/config/widget-config-model"; @@ -6,7 +7,10 @@ import { mergeDeep } from "../../../shared/effect/merge-deep"; import { vars } from "../../../shared/styles/theme/contract.css"; import { id, rootSelector } from "../../../shared/styles/theme/ids"; import { lightTheme } from "../../../shared/styles/theme/themes"; -import { getFineryThemeOverrides } from "../../../shared/styles/theme/variant-overrides/finery"; +import { + type FineryBaseTheme, + getFineryThemeOverrides, +} from "../../../shared/styles/theme/variant-overrides/finery"; import { portoThemeOverrides } from "../../../shared/styles/theme/variant-overrides/porto"; import { utilaThemeOverrides } from "../../../shared/styles/theme/variant-overrides/utila"; import type { RecursivePartial } from "../../../shared/types/utils"; @@ -15,7 +19,7 @@ export const getThemeOverrides = ({ baseTheme, variant, }: { - baseTheme: typeof lightTheme; + baseTheme: FineryBaseTheme; variant: WidgetConfig["variant"]; }): RecursivePartial => { if (variant === "utila") { @@ -33,6 +37,25 @@ export const getThemeOverrides = ({ return {}; }; +/** + * CSS variable assignments for a merged theme, read through the theme + * contract: each contract leaf (a `var(--…)` reference) takes the merged value + * at the same path. Values off the contract are not emitted. + */ +const themeVarEntries = ( + contract: unknown, + tokens: unknown +): ReadonlyArray => { + if (typeof contract === "string") { + return typeof tokens === "string" ? [[contract, tokens]] : []; + } + if (!Predicate.isObject(contract) || !Predicate.isObject(tokens)) return []; + + return Object.entries(contract).flatMap(([key, node]) => + themeVarEntries(node, tokens[key]) + ); +}; + /** Theme variables for a host theme and variant, read from props. */ export const ThemeStyles = ({ children, @@ -42,19 +65,21 @@ export const ThemeStyles = ({ readonly theme: WidgetConfig["theme"]; readonly variant: WidgetConfig["variant"]; }>) => { - const baseTheme = mergeDeep(lightTheme, theme); const overrides = getThemeOverrides({ - baseTheme, + baseTheme: { + color: { primary: theme?.color?.primary ?? lightTheme.color.primary }, + }, variant, }); const finalTheme = mergeDeep(lightTheme, theme, overrides); + const themeVars = Object.fromEntries(themeVarEntries(vars, finalTheme)); return (