From a92611d063a5f24a9d69aa9d7998110787f3cb29 Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Thu, 18 Jun 2026 18:36:01 -0400 Subject: [PATCH 1/2] fix(keychain): float->BigInt crash in token send + HMR provider exports C1: SendTokenDrawer computed base units as amount * 10**decimals and passed the result to BigInt(), so a fractional amount produced a non-integer float (0.0045 * 10**18 = 4499999999999999.5) and threw 'cannot be converted to a BigInt because it is not an integer' (seen in prod on /inventory/token). Parse the raw decimal input string into base units exactly with bigint math (parseTokenAmount), thread the input string through amount.tsx, and treat invalid/over-precision input as no-amount. Adds a regression test (0.0045 @ 18). C2: provider modules exported createContext()/hooks/constants alongside the Provider, breaking Vite Fast Refresh and invalidating context identity on hot-reload (cause of dev-only 'useX must be used within XProvider' errors). Enable react-refresh/only-export-components (warn) and split the offenders (starterpack, onchain-purchase, credit-purchase, upgrade, wallets) into context/provider/hook modules. Targeted modules warning-clean; tsc, lint (0 errors), format, and keychain tests (579 passing) green. Co-Authored-By: Claude Opus 4.8 --- packages/keychain/eslint.config.js | 9 ++ packages/keychain/src/components/app.tsx | 2 +- .../src/components/connect/Upgrade.tsx | 2 +- .../connect/buttons/auth-button.tsx | 2 +- .../connect/buttons/change-wallet.tsx | 2 +- .../connect/create/ChooseSignupMethodForm.tsx | 2 +- .../connect/create/CreateController.test.tsx | 2 +- .../connect/create/external-wallet/index.ts | 2 +- .../connect/create/useCreateController.ts | 4 +- .../inventory/token/send/amount.tsx | 11 +- .../inventory/token/send/send-drawer.tsx | 20 ++- .../components/provider/upgrade-context.ts | 128 +++++++++++++++ .../src/components/provider/upgrade.test.tsx | 6 +- .../src/components/provider/upgrade.tsx | 153 ++---------------- .../src/components/provider/use-upgrade.ts | 10 ++ .../purchase/review/onchain-cost.stories.tsx | 2 +- .../purchase/wallet/wallet.stories.tsx | 4 +- .../signers/add-signer/add-signer.tsx | 2 +- .../src/components/slot/crypto-fund.test.ts | 3 +- .../src/components/slot/crypto-fund.tsx | 21 +-- .../transaction/ConfirmTransaction.test.tsx | 2 +- .../starterpack/credit-purchase-context.ts | 29 ++++ .../context/starterpack/credit-purchase.tsx | 53 +----- .../src/context/starterpack/index.tsx | 21 ++- .../starterpack/onchain-purchase-context.ts | 122 ++++++++++++++ .../context/starterpack/onchain-purchase.tsx | 151 ++--------------- .../starterpack/starterpack-context.ts | 55 +++++++ .../src/context/starterpack/starterpack.tsx | 79 +-------- .../use-credit-purchase-context.ts | 12 ++ .../use-onchain-purchase-context.ts | 12 ++ .../starterpack/use-starterpack-context.ts | 12 ++ .../keychain/src/hooks/keychain-wallets.ts | 113 +++++++++++++ .../src/hooks/starterpack/external-wallet.ts | 2 +- packages/keychain/src/hooks/use-wallets.ts | 10 ++ .../keychain/src/hooks/wallets-context.ts | 37 +++++ packages/keychain/src/hooks/wallets.tsx | 153 +----------------- .../src/utils/connection/headless.test.ts | 2 +- .../keychain/src/utils/connection/headless.ts | 2 +- packages/keychain/src/utils/token-amount.ts | 19 +++ 39 files changed, 664 insertions(+), 609 deletions(-) create mode 100644 packages/keychain/src/components/provider/upgrade-context.ts create mode 100644 packages/keychain/src/components/provider/use-upgrade.ts create mode 100644 packages/keychain/src/context/starterpack/credit-purchase-context.ts create mode 100644 packages/keychain/src/context/starterpack/onchain-purchase-context.ts create mode 100644 packages/keychain/src/context/starterpack/starterpack-context.ts create mode 100644 packages/keychain/src/context/starterpack/use-credit-purchase-context.ts create mode 100644 packages/keychain/src/context/starterpack/use-onchain-purchase-context.ts create mode 100644 packages/keychain/src/context/starterpack/use-starterpack-context.ts create mode 100644 packages/keychain/src/hooks/keychain-wallets.ts create mode 100644 packages/keychain/src/hooks/use-wallets.ts create mode 100644 packages/keychain/src/hooks/wallets-context.ts create mode 100644 packages/keychain/src/utils/token-amount.ts diff --git a/packages/keychain/eslint.config.js b/packages/keychain/eslint.config.js index 94cfc5cb74..5e7c9137c2 100644 --- a/packages/keychain/eslint.config.js +++ b/packages/keychain/eslint.config.js @@ -2,4 +2,13 @@ import config from "@cartridge/eslint"; export default [ ...config, { ignores: ["public/**", "src/utils/api/generated.ts"] }, + { + files: ["**/*.{ts,tsx}"], + rules: { + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, ]; diff --git a/packages/keychain/src/components/app.tsx b/packages/keychain/src/components/app.tsx index e40286c535..deb4a58195 100644 --- a/packages/keychain/src/components/app.tsx +++ b/packages/keychain/src/components/app.tsx @@ -52,7 +52,7 @@ import { DeployController } from "./DeployController"; import { useConnection } from "@/hooks/connection"; import { CreateController, Upgrade } from "./connect"; import { HeadlessApprovalRoute } from "./connect/HeadlessApprovalRoute"; -import { useUpgrade } from "./provider/upgrade"; +import { useUpgrade } from "./provider/use-upgrade"; import { Layout } from "@/components/layout"; import { Disconnect } from "./disconnect"; import { OnchainCheckout } from "./purchase/checkout/onchain"; diff --git a/packages/keychain/src/components/connect/Upgrade.tsx b/packages/keychain/src/components/connect/Upgrade.tsx index e45f57ab8c..ebf5809164 100644 --- a/packages/keychain/src/components/connect/Upgrade.tsx +++ b/packages/keychain/src/components/connect/Upgrade.tsx @@ -1,7 +1,7 @@ import { LayoutContent, BoltIcon, CircleIcon } from "@cartridge/controller-ui"; import { ExecutionContainer } from "@/components/ExecutionContainer"; import { useConnection } from "@/hooks/connection"; -import { useUpgrade } from "../provider/upgrade"; +import { useUpgrade } from "../provider/use-upgrade"; export const Upgrade = () => { const { controller } = useConnection(); diff --git a/packages/keychain/src/components/connect/buttons/auth-button.tsx b/packages/keychain/src/components/connect/buttons/auth-button.tsx index 9221f625f6..d3adf675a3 100644 --- a/packages/keychain/src/components/connect/buttons/auth-button.tsx +++ b/packages/keychain/src/components/connect/buttons/auth-button.tsx @@ -1,4 +1,4 @@ -import { useWallets } from "@/hooks/wallets"; +import { useWallets } from "@/hooks/use-wallets"; import { AUTH_METHODS_LABELS } from "@/utils/connection/constants"; import { allUseSameAuth } from "@/utils/controller"; import { AuthOption } from "@cartridge/controller"; diff --git a/packages/keychain/src/components/connect/buttons/change-wallet.tsx b/packages/keychain/src/components/connect/buttons/change-wallet.tsx index 2fccc6c759..475b8e8622 100644 --- a/packages/keychain/src/components/connect/buttons/change-wallet.tsx +++ b/packages/keychain/src/components/connect/buttons/change-wallet.tsx @@ -1,5 +1,5 @@ import { ErrorAlert } from "@/components/ErrorAlert"; -import { useWallets } from "@/hooks/wallets"; +import { useWallets } from "@/hooks/use-wallets"; import { AUTH_METHODS_LABELS } from "@/utils/connection/constants"; import { AuthOption } from "@cartridge/controller"; import { formatAddress } from "@cartridge/controller-ui/utils"; diff --git a/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx b/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx index 2704bb5910..9f3137bcd7 100644 --- a/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx +++ b/packages/keychain/src/components/connect/create/ChooseSignupMethodForm.tsx @@ -15,7 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { SignupButton } from "../buttons/signup-button"; import { credentialToAuth } from "../types"; import { useUsernameValidation } from "./useUsernameValidation"; -import { useWallets } from "@/hooks/wallets"; +import { useWallets } from "@/hooks/use-wallets"; export const INITIAL_OPTIONS: AuthOption[] = ["sms", "webauthn"]; export const WALLET_OPTIONS: AuthOption[] = [ diff --git a/packages/keychain/src/components/connect/create/CreateController.test.tsx b/packages/keychain/src/components/connect/create/CreateController.test.tsx index 9d81b3df38..1ca8f2fc8c 100644 --- a/packages/keychain/src/components/connect/create/CreateController.test.tsx +++ b/packages/keychain/src/components/connect/create/CreateController.test.tsx @@ -58,7 +58,7 @@ vi.mock("@/hooks/connection", () => ({ useControllerTheme: () => mockUseControllerTheme(), })); -vi.mock("@/hooks/wallets", () => ({ +vi.mock("@/hooks/use-wallets", () => ({ useWallets: () => mockUseWallets(), })); diff --git a/packages/keychain/src/components/connect/create/external-wallet/index.ts b/packages/keychain/src/components/connect/create/external-wallet/index.ts index 7cf58b8bf9..f7a999b9c4 100644 --- a/packages/keychain/src/components/connect/create/external-wallet/index.ts +++ b/packages/keychain/src/components/connect/create/external-wallet/index.ts @@ -1,5 +1,5 @@ import { useConnection } from "@/hooks/connection"; -import { useWallets } from "@/hooks/wallets"; +import { useWallets } from "@/hooks/use-wallets"; import { AuthOption, ExternalWalletResponse, diff --git a/packages/keychain/src/components/connect/create/useCreateController.ts b/packages/keychain/src/components/connect/create/useCreateController.ts index 7350c12d91..f5937ed614 100644 --- a/packages/keychain/src/components/connect/create/useCreateController.ts +++ b/packages/keychain/src/components/connect/create/useCreateController.ts @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { STABLE_CONTROLLER } from "@/components/provider/upgrade"; +import { STABLE_CONTROLLER } from "@/components/provider/upgrade-context"; import { DEFAULT_SESSION_DURATION, now } from "@/constants"; import { useConnection } from "@/hooks/connection"; -import { useWallets } from "@/hooks/wallets"; +import { useWallets } from "@/hooks/use-wallets"; import Controller from "@/utils/controller"; import { TurnkeyWallet } from "@/wallets/social/turnkey"; import { diff --git a/packages/keychain/src/components/inventory/token/send/amount.tsx b/packages/keychain/src/components/inventory/token/send/amount.tsx index 955649ad32..f214e4355b 100644 --- a/packages/keychain/src/components/inventory/token/send/amount.tsx +++ b/packages/keychain/src/components/inventory/token/send/amount.tsx @@ -8,12 +8,14 @@ export function SendAmount({ amount, submitted, setAmount, + setAmountInput, setError, }: { token: Token; amount: number | undefined; submitted: boolean; setAmount: (amount: number | undefined) => void; + setAmountInput: (amount: string | undefined) => void; setError: (error: Error | undefined) => void; }) { const conversion = useMemo(() => { @@ -28,17 +30,20 @@ export function SendAmount({ (e: React.MouseEvent) => { e.preventDefault(); if (!token) return; - setAmount(parseFloat(token.balance.amount.toString())); + const max = token.balance.amount.toString(); + setAmount(parseFloat(max)); + setAmountInput(max); }, - [token, setAmount], + [token, setAmount, setAmountInput], ); const handleChange = useCallback( (e: React.ChangeEvent) => { const value = e.target.value; setAmount(value === "" ? undefined : Number(value)); + setAmountInput(value === "" ? undefined : value); }, - [setAmount], + [setAmount, setAmountInput], ); if (!token) { diff --git a/packages/keychain/src/components/inventory/token/send/send-drawer.tsx b/packages/keychain/src/components/inventory/token/send/send-drawer.tsx index abe617eb10..5376fe0a60 100644 --- a/packages/keychain/src/components/inventory/token/send/send-drawer.tsx +++ b/packages/keychain/src/components/inventory/token/send/send-drawer.tsx @@ -15,6 +15,7 @@ import { Link, useSearchParams } from "react-router-dom"; import { SendRecipient } from "@/components/modules/recipient"; import { SendAmount } from "./amount"; import { Disclosure } from "@cartridge/controller-ui"; +import { parseTokenAmount } from "@/utils/token-amount"; export function SendTokenDrawer({ disclosure, @@ -35,6 +36,7 @@ export function SendTokenDrawer({ const [to, setTo] = useState(""); const [amount, setAmount] = useState(); + const [amountInput, setAmountInput] = useState(); const [amountError, setAmountError] = useState(); const [toError, setToError] = useState(); const [selectedToken, setSelectedToken] = useState(token); @@ -52,13 +54,18 @@ export function SendTokenDrawer({ ) { return ""; } else { + const baseUnitAmount = parseTokenAmount( + amountInput, + selectedToken.metadata.decimals, + ); + if (baseUnitAmount === undefined) { + return ""; + } + const sendParams = new URLSearchParams(searchParams); sendParams.set("tokenAddress", tokenAddress); sendParams.set("recipient", to); - sendParams.set( - "amount", - BigInt(amount * 10 ** selectedToken.metadata.decimals).toString(), - ); + sendParams.set("amount", baseUnitAmount.toString()); return `send?${sendParams.toString()}`; } }, [ @@ -68,6 +75,7 @@ export function SendTokenDrawer({ toError, recipientLoading, amount, + amountInput, to, searchParams, tokenAddress, @@ -88,9 +96,10 @@ export function SendTokenDrawer({ (token: Token) => { setSelectedToken(token); setAmount(undefined); + setAmountInput(undefined); userSelectedToken.current = true; }, - [setSelectedToken, setAmount], + [setSelectedToken, setAmount, setAmountInput], ); if (!token) { @@ -133,6 +142,7 @@ export function SendTokenDrawer({ amount={amount} submitted={false} setAmount={setAmount} + setAmountInput={setAmountInput} setError={setAmountError} /> )} diff --git a/packages/keychain/src/components/provider/upgrade-context.ts b/packages/keychain/src/components/provider/upgrade-context.ts new file mode 100644 index 0000000000..5ab434c87e --- /dev/null +++ b/packages/keychain/src/components/provider/upgrade-context.ts @@ -0,0 +1,128 @@ +import { createContext, ReactNode } from "react"; +import { addAddressPadding, Call } from "starknet"; +import type { Chain } from "@cartridge/controller"; +import { ControllerError } from "@/utils/connection"; +import Controller from "@/utils/controller"; + +export enum OutsideExecutionVersion { + V2, + V3, +} + +export type ControllerVersionInfo = { + version: string; + hash: string; + outsideExecutionVersion: OutsideExecutionVersion; + changes: string[]; +}; + +export const CONTROLLER_VERSIONS: ControllerVersionInfo[] = [ + { + version: "1.0.4", + hash: "0x24a9edbfa7082accfceabf6a92d7160086f346d622f28741bf1c651c412c9ab", + outsideExecutionVersion: OutsideExecutionVersion.V2, + changes: [], + }, + { + version: "1.0.5", + hash: "0x32e17891b6cc89e0c3595a3df7cee760b5993744dc8dfef2bd4d443e65c0f40", + outsideExecutionVersion: OutsideExecutionVersion.V2, + changes: ["Improved session token implementation"], + }, + { + version: "1.0.6", + hash: "0x59e4405accdf565112fe5bf9058b51ab0b0e63665d280b816f9fe4119554b77", + outsideExecutionVersion: OutsideExecutionVersion.V3, + changes: [ + "Support session key message signing", + "Support session guardians", + "Improve paymaster nonce management", + ], + }, + { + version: "1.0.7", + hash: "0x3e0a04bab386eaa51a41abe93d8035dccc96bd9d216d44201266fe0b8ea1115", + outsideExecutionVersion: OutsideExecutionVersion.V3, + changes: ["Unified message signature verification"], + }, + { + version: "1.0.8", + hash: "0x511dd75da368f5311134dee2356356ac4da1538d2ad18aa66d57c47e3757d59", + outsideExecutionVersion: OutsideExecutionVersion.V3, + changes: ["Improved session message signature"], + }, + { + version: "1.0.9", + hash: "0x743c83c41ce99ad470aa308823f417b2141e02e04571f5c0004e743556e7faf", + outsideExecutionVersion: OutsideExecutionVersion.V3, + changes: ["Wildcard session support"], + }, +]; + +export const STABLE_CONTROLLER = CONTROLLER_VERSIONS[5]; +export const BETA_CONTROLLER = CONTROLLER_VERSIONS[5]; + +export const findVersion = ( + classHash: string, +): ControllerVersionInfo | undefined => + CONTROLLER_VERSIONS.find( + (v) => addAddressPadding(v.hash) === addAddressPadding(classHash), + ); + +/** + * Determines if an upgrade is available and returns the appropriate controller version + * @param currentVersion The current controller version + * @param isBeta Whether beta features are enabled + * @returns An object containing whether an upgrade is available and the target controller version + */ +export function determineUpgradePath( + currentVersion: ControllerVersionInfo | undefined, + isBeta: boolean, +): { available: boolean; targetVersion: ControllerVersionInfo } { + const targetVersion = isBeta ? BETA_CONTROLLER : STABLE_CONTROLLER; + + if (!currentVersion) { + return { available: false, targetVersion }; + } + + // Find the indices of the current and target versions in the CONTROLLER_VERSIONS array + const currentIndex = CONTROLLER_VERSIONS.findIndex( + (v) => v === currentVersion, + ); + const targetIndex = CONTROLLER_VERSIONS.findIndex((v) => v === targetVersion); + + // Only set available to true if the target controller is newer than the current one + const available = currentIndex !== -1 && targetIndex > currentIndex; + + return { available, targetVersion }; +} + +export interface UpgradeInterface { + available: boolean; + current?: ControllerVersionInfo; + latest: ControllerVersionInfo; + calls: Call[]; + isSynced: boolean; + isUpgrading: boolean; + error?: ControllerError; + onUpgrade: () => Promise; + isBeta: boolean; +} + +export const UpgradeContext = createContext( + undefined, +); + +export interface UpgradeProviderProps { + controller?: Controller; + /** Chains the dapp explicitly configured (see + * `ConnectionContextValue.configuredChains`). The account's class is checked on + * each of them - not just the controller's active chain - so an upgrade is offered + * for e.g. an appchain still on a pre-wildcard class while the account is already + * up to date on the settlement chain. */ + chains?: Chain[]; + children: ReactNode; +} + +/** A chain whose account is behind the target version. */ +export type OutdatedChain = { rpcUrl: string; version: ControllerVersionInfo }; diff --git a/packages/keychain/src/components/provider/upgrade.test.tsx b/packages/keychain/src/components/provider/upgrade.test.tsx index 412d325876..199434f01b 100644 --- a/packages/keychain/src/components/provider/upgrade.test.tsx +++ b/packages/keychain/src/components/provider/upgrade.test.tsx @@ -8,10 +8,10 @@ import { ControllerVersionInfo, OutsideExecutionVersion, STABLE_CONTROLLER, - UpgradeProvider, determineUpgradePath, - useUpgrade, -} from "./upgrade"; +} from "./upgrade-context"; +import { UpgradeProvider } from "./upgrade"; +import { useUpgrade } from "./use-upgrade"; import { ReactNode } from "react"; import { PostHogContext, PostHogWrapper } from "@cartridge/controller-ui/utils"; import Controller from "@/utils/controller"; diff --git a/packages/keychain/src/components/provider/upgrade.tsx b/packages/keychain/src/components/provider/upgrade.tsx index f57a83bb57..b7aa4845e7 100644 --- a/packages/keychain/src/components/provider/upgrade.tsx +++ b/packages/keychain/src/components/provider/upgrade.tsx @@ -1,80 +1,21 @@ -import React, { - createContext, - useContext, - useState, - useEffect, - useMemo, - useCallback, -} from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { JsCall } from "@cartridge/controller-wasm"; -import { addAddressPadding, Call, RpcProvider } from "starknet"; -import type { Chain } from "@cartridge/controller"; +import { RpcProvider } from "starknet"; import { ControllerError } from "@/utils/connection"; import Controller from "@/utils/controller"; import { usePostHog } from "./posthog"; - -export enum OutsideExecutionVersion { - V2, - V3, -} - -export type ControllerVersionInfo = { - version: string; - hash: string; - outsideExecutionVersion: OutsideExecutionVersion; - changes: string[]; -}; - -export const CONTROLLER_VERSIONS: ControllerVersionInfo[] = [ - { - version: "1.0.4", - hash: "0x24a9edbfa7082accfceabf6a92d7160086f346d622f28741bf1c651c412c9ab", - outsideExecutionVersion: OutsideExecutionVersion.V2, - changes: [], - }, - { - version: "1.0.5", - hash: "0x32e17891b6cc89e0c3595a3df7cee760b5993744dc8dfef2bd4d443e65c0f40", - outsideExecutionVersion: OutsideExecutionVersion.V2, - changes: ["Improved session token implementation"], - }, - { - version: "1.0.6", - hash: "0x59e4405accdf565112fe5bf9058b51ab0b0e63665d280b816f9fe4119554b77", - outsideExecutionVersion: OutsideExecutionVersion.V3, - changes: [ - "Support session key message signing", - "Support session guardians", - "Improve paymaster nonce management", - ], - }, - { - version: "1.0.7", - hash: "0x3e0a04bab386eaa51a41abe93d8035dccc96bd9d216d44201266fe0b8ea1115", - outsideExecutionVersion: OutsideExecutionVersion.V3, - changes: ["Unified message signature verification"], - }, - { - version: "1.0.8", - hash: "0x511dd75da368f5311134dee2356356ac4da1538d2ad18aa66d57c47e3757d59", - outsideExecutionVersion: OutsideExecutionVersion.V3, - changes: ["Improved session message signature"], - }, - { - version: "1.0.9", - hash: "0x743c83c41ce99ad470aa308823f417b2141e02e04571f5c0004e743556e7faf", - outsideExecutionVersion: OutsideExecutionVersion.V3, - changes: ["Wildcard session support"], - }, -]; - -export const STABLE_CONTROLLER = CONTROLLER_VERSIONS[5]; -export const BETA_CONTROLLER = CONTROLLER_VERSIONS[5]; - -const findVersion = (classHash: string): ControllerVersionInfo | undefined => - CONTROLLER_VERSIONS.find( - (v) => addAddressPadding(v.hash) === addAddressPadding(classHash), - ); +import { + BETA_CONTROLLER, + ControllerVersionInfo, + determineUpgradePath, + findVersion, + OutdatedChain, + OutsideExecutionVersion, + STABLE_CONTROLLER, + UpgradeContext, + UpgradeInterface, + UpgradeProviderProps, +} from "./upgrade-context"; /** The controller version deployed at `address` on a chain — or, when not deployed * there yet, the version it would counterfactually deploy as (its creation class). */ @@ -93,64 +34,6 @@ async function deployedVersion( } } -/** A chain whose account is behind the target version. */ -type OutdatedChain = { rpcUrl: string; version: ControllerVersionInfo }; - -/** - * Determines if an upgrade is available and returns the appropriate controller version - * @param currentVersion The current controller version - * @param isBeta Whether beta features are enabled - * @returns An object containing whether an upgrade is available and the target controller version - */ -export function determineUpgradePath( - currentVersion: ControllerVersionInfo | undefined, - isBeta: boolean, -): { available: boolean; targetVersion: ControllerVersionInfo } { - const targetVersion = isBeta ? BETA_CONTROLLER : STABLE_CONTROLLER; - - if (!currentVersion) { - return { available: false, targetVersion }; - } - - // Find the indices of the current and target versions in the CONTROLLER_VERSIONS array - const currentIndex = CONTROLLER_VERSIONS.findIndex( - (v) => v === currentVersion, - ); - const targetIndex = CONTROLLER_VERSIONS.findIndex((v) => v === targetVersion); - - // Only set available to true if the target controller is newer than the current one - const available = currentIndex !== -1 && targetIndex > currentIndex; - - return { available, targetVersion }; -} - -export interface UpgradeInterface { - available: boolean; - current?: ControllerVersionInfo; - latest: ControllerVersionInfo; - calls: Call[]; - isSynced: boolean; - isUpgrading: boolean; - error?: ControllerError; - onUpgrade: () => Promise; - isBeta: boolean; -} - -export const UpgradeContext = createContext( - undefined, -); - -export interface UpgradeProviderProps { - controller?: Controller; - /** Chains the dapp explicitly configured (see - * `ConnectionContextValue.configuredChains`). The account's class is checked on - * each of them — not just the controller's active chain — so an upgrade is offered - * for e.g. an appchain still on a pre-wildcard class while the account is already - * up to date on the settlement chain. */ - chains?: Chain[]; - children: React.ReactNode; -} - export const UpgradeProvider: React.FC = ({ controller, chains, @@ -332,11 +215,3 @@ export const UpgradeProvider: React.FC = ({ {children} ); }; - -export const useUpgrade = (): UpgradeInterface => { - const context = useContext(UpgradeContext); - if (!context) { - throw new Error("useUpgrade must be used within an UpgradeProvider"); - } - return context; -}; diff --git a/packages/keychain/src/components/provider/use-upgrade.ts b/packages/keychain/src/components/provider/use-upgrade.ts new file mode 100644 index 0000000000..0290eab541 --- /dev/null +++ b/packages/keychain/src/components/provider/use-upgrade.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { UpgradeContext, UpgradeInterface } from "./upgrade-context"; + +export const useUpgrade = (): UpgradeInterface => { + const context = useContext(UpgradeContext); + if (!context) { + throw new Error("useUpgrade must be used within an UpgradeProvider"); + } + return context; +}; diff --git a/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx b/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx index f8d5f744f2..15c63e7794 100644 --- a/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx +++ b/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx @@ -4,7 +4,7 @@ import { OnchainPurchaseContext, OnchainPurchaseContextType, TokenOption, -} from "@/context/starterpack/onchain-purchase"; +} from "@/context/starterpack/onchain-purchase-context"; import { ReactNode } from "react"; // USDC address with leading zeros (tests normalization) diff --git a/packages/keychain/src/components/purchase/wallet/wallet.stories.tsx b/packages/keychain/src/components/purchase/wallet/wallet.stories.tsx index 68ce5db2a9..f35fdd13de 100644 --- a/packages/keychain/src/components/purchase/wallet/wallet.stories.tsx +++ b/packages/keychain/src/components/purchase/wallet/wallet.stories.tsx @@ -2,12 +2,12 @@ import type { Meta, StoryObj } from "@storybook/react"; import { SelectWallet } from "./wallet"; import { ReactNode, useEffect } from "react"; import { Routes, Route, useNavigate } from "react-router-dom"; -import { StarterpackContext } from "@/context/starterpack/starterpack"; +import { StarterpackContext } from "@/context/starterpack/starterpack-context"; import { OnchainPurchaseContext, OnchainPurchaseContextType, TokenOption, -} from "@/context/starterpack/onchain-purchase"; +} from "@/context/starterpack/onchain-purchase-context"; // Mock starterpack context const mockStarterpackValue = { diff --git a/packages/keychain/src/components/settings/signers/add-signer/add-signer.tsx b/packages/keychain/src/components/settings/signers/add-signer/add-signer.tsx index 48910708e1..34e708b64f 100644 --- a/packages/keychain/src/components/settings/signers/add-signer/add-signer.tsx +++ b/packages/keychain/src/components/settings/signers/add-signer/add-signer.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useController } from "@/hooks/controller"; -import { useWallets } from "@/hooks/wallets"; +import { useWallets } from "@/hooks/use-wallets"; import { credentialToAddress, credentialToAuth, diff --git a/packages/keychain/src/components/slot/crypto-fund.test.ts b/packages/keychain/src/components/slot/crypto-fund.test.ts index 2160ed4ea0..4533aa5bac 100644 --- a/packages/keychain/src/components/slot/crypto-fund.test.ts +++ b/packages/keychain/src/components/slot/crypto-fund.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseTokenAmount } from "./crypto-fund"; +import { parseTokenAmount } from "@/utils/token-amount"; describe("parseTokenAmount", () => { it("parses whole token amounts", () => { @@ -10,6 +10,7 @@ describe("parseTokenAmount", () => { it("parses fractional token amounts", () => { expect(parseTokenAmount("10.25", 6)).toBe(10_250_000n); expect(parseTokenAmount("0.000001", 6)).toBe(1n); + expect(parseTokenAmount("0.0045", 18)).toBe(4_500_000_000_000_000n); }); it("rejects invalid precision", () => { diff --git a/packages/keychain/src/components/slot/crypto-fund.tsx b/packages/keychain/src/components/slot/crypto-fund.tsx index b4e789f1ef..1a6c4d602b 100644 --- a/packages/keychain/src/components/slot/crypto-fund.tsx +++ b/packages/keychain/src/components/slot/crypto-fund.tsx @@ -48,6 +48,7 @@ import { STRK_CONTRACT_ADDRESS } from "@cartridge/controller-ui/utils"; import { ErrorAlert } from "@/components/ErrorAlert"; import { createStarknetCryptoPayment } from "@/hooks/payments/crypto"; import { Team } from "./teams"; +import { parseTokenAmount } from "@/utils/token-amount"; type SlotFundingToken = { key: "USDC" | "STRK"; @@ -493,26 +494,6 @@ function ExternalWalletProvider({ children }: PropsWithChildren) { ); } -export function parseTokenAmount( - value: string, - decimals: number, -): bigint | undefined { - const trimmed = value.trim(); - if (!trimmed || !/^\d+(\.\d*)?$/.test(trimmed)) { - return undefined; - } - - const [whole, fractional = ""] = trimmed.split("."); - if (fractional.length > decimals) { - return undefined; - } - - const base = 10n ** BigInt(decimals); - const wholeAmount = BigInt(whole) * base; - const fractionalAmount = BigInt(fractional.padEnd(decimals, "0") || "0"); - return wholeAmount + fractionalAmount; -} - async function fetchTokenBalance( provider: { callContract: (args: { diff --git a/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx b/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx index 06cbb086ba..e3ca8eddcb 100644 --- a/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx +++ b/packages/keychain/src/components/transaction/ConfirmTransaction.test.tsx @@ -24,7 +24,7 @@ vi.mock("@/hooks/tokens", () => ({ })); // Mock the upgrade provider hook -vi.mock("@/components/provider/upgrade", () => ({ +vi.mock("@/components/provider/use-upgrade", () => ({ useUpgrade: vi.fn(() => ({ isUpgradeAvailable: false, isUpgrading: false, diff --git a/packages/keychain/src/context/starterpack/credit-purchase-context.ts b/packages/keychain/src/context/starterpack/credit-purchase-context.ts new file mode 100644 index 0000000000..eb93b79691 --- /dev/null +++ b/packages/keychain/src/context/starterpack/credit-purchase-context.ts @@ -0,0 +1,29 @@ +import { createContext, ReactNode } from "react"; +import { + CoinflowStarterpackIntent, + CoinflowStarterpackQuote, +} from "@/hooks/payments/coinflow"; + +export interface CreditPurchaseContextType { + // USD amount selection + usdAmount: number; + setUsdAmount: (amount: number) => void; + + // Coinflow state + coinflowIntent: CoinflowStarterpackIntent | undefined; + coinflowQuote: CoinflowStarterpackQuote | undefined; + isCoinflowQuoteLoading: boolean; + coinflowEnv: "prod" | "sandbox"; + isCoinflowLoading: boolean; + + // Actions + onCreditCardPurchase: () => Promise; +} + +export const CreditPurchaseContext = createContext< + CreditPurchaseContextType | undefined +>(undefined); + +export interface CreditPurchaseProviderProps { + children: ReactNode; +} diff --git a/packages/keychain/src/context/starterpack/credit-purchase.tsx b/packages/keychain/src/context/starterpack/credit-purchase.tsx index 9a467823ae..dc83c5a863 100644 --- a/packages/keychain/src/context/starterpack/credit-purchase.tsx +++ b/packages/keychain/src/context/starterpack/credit-purchase.tsx @@ -1,46 +1,19 @@ -import { - createContext, - useContext, - useState, - useCallback, - useEffect, - ReactNode, -} from "react"; +import { useState, useCallback, useEffect } from "react"; import { useConnection } from "@/hooks/connection"; import useCoinflowPayment, { CoinflowStarterpackIntent, - CoinflowStarterpackQuote, useCoinflowStarterpackQuote, } from "@/hooks/payments/coinflow"; import { USD_AMOUNTS } from "@/components/funding/AmountSelection"; -import { useStarterpackContext } from "./starterpack"; -import { useOnchainPurchaseContext } from "./onchain-purchase"; +import { useStarterpackContext } from "./use-starterpack-context"; +import { useOnchainPurchaseContext } from "./use-onchain-purchase-context"; import { getCurrentReferral } from "@/utils/referral"; import { isOnchainStarterpack } from "./types"; - -export interface CreditPurchaseContextType { - // USD amount selection - usdAmount: number; - setUsdAmount: (amount: number) => void; - - // Coinflow state - coinflowIntent: CoinflowStarterpackIntent | undefined; - coinflowQuote: CoinflowStarterpackQuote | undefined; - isCoinflowQuoteLoading: boolean; - coinflowEnv: "prod" | "sandbox"; - isCoinflowLoading: boolean; - - // Actions - onCreditCardPurchase: () => Promise; -} - -export const CreditPurchaseContext = createContext< - CreditPurchaseContextType | undefined ->(undefined); - -export interface CreditPurchaseProviderProps { - children: ReactNode; -} +import { + CreditPurchaseContext, + CreditPurchaseContextType, + CreditPurchaseProviderProps, +} from "./credit-purchase-context"; export const CreditPurchaseProvider = ({ children, @@ -150,13 +123,3 @@ export const CreditPurchaseProvider = ({ ); }; - -export const useCreditPurchaseContext = () => { - const context = useContext(CreditPurchaseContext); - if (!context) { - throw new Error( - "useCreditPurchaseContext must be used within CreditPurchaseProvider", - ); - } - return context; -}; diff --git a/packages/keychain/src/context/starterpack/index.tsx b/packages/keychain/src/context/starterpack/index.tsx index d0a34d625a..ed69d69cb1 100644 --- a/packages/keychain/src/context/starterpack/index.tsx +++ b/packages/keychain/src/context/starterpack/index.tsx @@ -18,25 +18,22 @@ export { } from "./types"; // Starterpack context (shared base) -export { StarterpackProvider, useStarterpackContext } from "./starterpack"; -export type { StarterpackContextType } from "./starterpack"; +export { StarterpackProvider } from "./starterpack"; +export { useStarterpackContext } from "./use-starterpack-context"; +export type { StarterpackContextType } from "./starterpack-context"; // Onchain purchase context -export { - OnchainPurchaseProvider, - useOnchainPurchaseContext, -} from "./onchain-purchase"; +export { OnchainPurchaseProvider } from "./onchain-purchase"; +export { useOnchainPurchaseContext } from "./use-onchain-purchase-context"; export type { OnchainPurchaseContextType, TokenOption, -} from "./onchain-purchase"; +} from "./onchain-purchase-context"; // Credit purchase context -export { - CreditPurchaseProvider, - useCreditPurchaseContext, -} from "./credit-purchase"; -export type { CreditPurchaseContextType } from "./credit-purchase"; +export { CreditPurchaseProvider } from "./credit-purchase"; +export { useCreditPurchaseContext } from "./use-credit-purchase-context"; +export type { CreditPurchaseContextType } from "./credit-purchase-context"; // Composed provider for all starterpack contexts import { ReactNode } from "react"; diff --git a/packages/keychain/src/context/starterpack/onchain-purchase-context.ts b/packages/keychain/src/context/starterpack/onchain-purchase-context.ts new file mode 100644 index 0000000000..ac9624dc87 --- /dev/null +++ b/packages/keychain/src/context/starterpack/onchain-purchase-context.ts @@ -0,0 +1,122 @@ +import { createContext, ReactNode } from "react"; +import { ExternalPlatform, ExternalWallet } from "@cartridge/controller"; +import { CoinbaseOnrampStatus } from "@/utils/api"; +import { SubmitCoinbaseLimitsUpgradeInput } from "@/utils/api"; +import { Explorer } from "@/hooks/starterpack/layerswap"; +import { + COINBASE_APPLE_PAY_MIN_USD, + type TokenOption, + type CoinbaseOrderResult, + type CoinbaseTransactionResult, + type CoinbaseQuoteResult, + type CoinbaseLimitsResult, +} from "@/hooks/starterpack"; +import { SwapQuote } from "@/utils/ekubo"; +import { Item } from "./types"; + +export type { TokenOption } from "@/hooks/starterpack"; +export { COINBASE_APPLE_PAY_MIN_USD }; + +export interface OnchainPurchaseContextType { + // Purchase items + purchaseItems: Item[]; + purchaseDescription: string | undefined; + + // Quantity management + quantity: number; + incrementQuantity: () => void; + decrementQuantity: () => void; + + // Conditional bundles / social claim + setIssueSignature: (signature: string[] | undefined) => void; + + // Wallet state + selectedWallet: ExternalWallet | undefined; + selectedPlatform: ExternalPlatform | undefined; + walletAddress: string | undefined; + clearSelectedWallet: () => void; + + // Token selection + availableTokens: TokenOption[]; + selectedToken: TokenOption | undefined; + setSelectedToken: (token: TokenOption | undefined) => void; + convertedPrice: { + amount: bigint; + quantity: number; + tokenMetadata: { symbol: string; decimals: number }; + } | null; + swapQuote: SwapQuote | null; + isFetchingConversion: boolean; + isTokenSelectionLocked: boolean; + conversionError: Error | null; + + // USD amount (derived from quote) + usdAmount: number; + + // Layerswap state (for future use) + layerswapFees: string | undefined; + isFetchingFees: boolean; + isSendingDeposit: boolean; + swapId: string | undefined; + explorer: Explorer | undefined; + requestedAmount: number | undefined; + depositAmount: number | undefined; // Computed: requestedAmount + fees + setRequestedAmount: (amount: number) => void; + feeEstimationError: Error | null; + + // Coinbase / Apple Pay state + isApplePaySelected: boolean; + isCoinflowSelected: boolean; + paymentLink: string | undefined; + isCreatingOrder: boolean; + coinbaseQuote: CoinbaseQuoteResult | undefined; + isFetchingCoinbaseQuote: boolean; + orderId: string | undefined; + orderStatus: CoinbaseOnrampStatus | undefined; + orderTxHash: string | undefined; + popupClosed: boolean; + paymentSuccess: boolean; + coinbaseLsSwapId: string | undefined; + /** Quantity we auto-bumped to so Apple Pay's per-transaction minimum is met. Undefined when no bump is active. */ + applePayMinQuantity: number | undefined; + + // Coinbase limits-upgrade state + coinbaseLimits: CoinbaseLimitsResult | undefined; + isFetchingCoinbaseLimits: boolean; + isSubmittingLimitsUpgrade: boolean; + + // Actions + onOnchainPurchase: () => Promise; + onExternalConnect: ( + wallet: ExternalWallet, + platform: ExternalPlatform, + chainId?: string, + ) => Promise; + onSendDeposit: () => Promise; + waitForDeposit: (swapId: string) => Promise; + onApplePaySelect: () => void; + onCoinflowSelect: () => void; + onCreateCoinbaseOrder: (opts?: { + force?: boolean; + }) => Promise; + openPaymentPopup: (opts?: { + paymentLink?: string; + orderId?: string; + preOpenedPopup?: Window | null; + }) => void; + closePaymentPopup: () => void; + resetCoinbasePurchase: () => void; + getTransactions: (username: string) => Promise; + fetchCoinbaseLimits: () => Promise; + submitCoinbaseLimitsUpgrade: ( + input: SubmitCoinbaseLimitsUpgradeInput, + ) => Promise; +} + +export const OnchainPurchaseContext = createContext< + OnchainPurchaseContextType | undefined +>(undefined); + +export interface OnchainPurchaseProviderProps { + children: ReactNode; +} diff --git a/packages/keychain/src/context/starterpack/onchain-purchase.tsx b/packages/keychain/src/context/starterpack/onchain-purchase.tsx index c96a3f44f2..17c9f02801 100644 --- a/packages/keychain/src/context/starterpack/onchain-purchase.tsx +++ b/packages/keychain/src/context/starterpack/onchain-purchase.tsx @@ -1,12 +1,4 @@ -import { - createContext, - useContext, - useState, - useCallback, - useEffect, - useMemo, - ReactNode, -} from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useLocation } from "react-router-dom"; import { ExternalPlatform, ExternalWallet } from "@cartridge/controller"; import { useConnection } from "@/hooks/connection"; @@ -14,19 +6,11 @@ import { usdcToUsd } from "@/utils/starterpack"; import { uint256, Call, num, cairo, hash, shortString } from "starknet"; import { isOnchainStarterpack } from "./types"; import { getCurrentReferral } from "@/utils/referral"; -import { - prepareSwapCalls, - fetchSwapQuote, - isQuoteChain, - type SwapQuote, -} from "@/utils/ekubo"; +import { prepareSwapCalls, fetchSwapQuote, isQuoteChain } from "@/utils/ekubo"; import { Item } from "./types"; -import { useStarterpackContext } from "./starterpack"; +import { useStarterpackContext } from "./use-starterpack-context"; import { ExternalWalletError } from "@/utils/errors"; -import { - CoinbaseOnrampStatus, - type SubmitCoinbaseLimitsUpgradeInput, -} from "@/utils/api"; +import { CoinbaseOnrampStatus } from "@/utils/api"; import { useQuantity, useExternalWallet, @@ -34,120 +18,13 @@ import { useTokenSelection, useCoinbase, COINBASE_APPLE_PAY_MIN_USD, - type TokenOption, - type CoinbaseOrderResult, - type CoinbaseTransactionResult, - type CoinbaseQuoteResult, - type CoinbaseLimitsResult, } from "@/hooks/starterpack"; import { useSocialClaimConnection } from "@/hooks/starterpack/social"; -import { Explorer } from "@/hooks/starterpack/layerswap"; - -export type { TokenOption } from "@/hooks/starterpack"; - -export interface OnchainPurchaseContextType { - // Purchase items - purchaseItems: Item[]; - purchaseDescription: string | undefined; - - // Quantity management - quantity: number; - incrementQuantity: () => void; - decrementQuantity: () => void; - - // Conditional bundles / social claim - setIssueSignature: (signature: string[] | undefined) => void; - - // Wallet state - selectedWallet: ExternalWallet | undefined; - selectedPlatform: ExternalPlatform | undefined; - walletAddress: string | undefined; - clearSelectedWallet: () => void; - - // Token selection - availableTokens: TokenOption[]; - selectedToken: TokenOption | undefined; - setSelectedToken: (token: TokenOption | undefined) => void; - convertedPrice: { - amount: bigint; - quantity: number; - tokenMetadata: { symbol: string; decimals: number }; - } | null; - swapQuote: SwapQuote | null; - isFetchingConversion: boolean; - isTokenSelectionLocked: boolean; - conversionError: Error | null; - - // USD amount (derived from quote) - usdAmount: number; - - // Layerswap state (for future use) - layerswapFees: string | undefined; - isFetchingFees: boolean; - isSendingDeposit: boolean; - swapId: string | undefined; - explorer: Explorer | undefined; - requestedAmount: number | undefined; - depositAmount: number | undefined; // Computed: requestedAmount + fees - setRequestedAmount: (amount: number) => void; - feeEstimationError: Error | null; - - // Coinbase / Apple Pay state - isApplePaySelected: boolean; - isCoinflowSelected: boolean; - paymentLink: string | undefined; - isCreatingOrder: boolean; - coinbaseQuote: CoinbaseQuoteResult | undefined; - isFetchingCoinbaseQuote: boolean; - orderId: string | undefined; - orderStatus: CoinbaseOnrampStatus | undefined; - orderTxHash: string | undefined; - popupClosed: boolean; - paymentSuccess: boolean; - coinbaseLsSwapId: string | undefined; - /** Quantity we auto-bumped to so Apple Pay's per-transaction minimum is met. Undefined when no bump is active. */ - applePayMinQuantity: number | undefined; - - // Coinbase limits-upgrade state - coinbaseLimits: CoinbaseLimitsResult | undefined; - isFetchingCoinbaseLimits: boolean; - isSubmittingLimitsUpgrade: boolean; - - // Actions - onOnchainPurchase: () => Promise; - onExternalConnect: ( - wallet: ExternalWallet, - platform: ExternalPlatform, - chainId?: string, - ) => Promise; - onSendDeposit: () => Promise; - waitForDeposit: (swapId: string) => Promise; - onApplePaySelect: () => void; - onCoinflowSelect: () => void; - onCreateCoinbaseOrder: (opts?: { - force?: boolean; - }) => Promise; - openPaymentPopup: (opts?: { - paymentLink?: string; - orderId?: string; - preOpenedPopup?: Window | null; - }) => void; - closePaymentPopup: () => void; - resetCoinbasePurchase: () => void; - getTransactions: (username: string) => Promise; - fetchCoinbaseLimits: () => Promise; - submitCoinbaseLimitsUpgrade: ( - input: SubmitCoinbaseLimitsUpgradeInput, - ) => Promise; -} - -export const OnchainPurchaseContext = createContext< - OnchainPurchaseContextType | undefined ->(undefined); - -export interface OnchainPurchaseProviderProps { - children: ReactNode; -} +import { + OnchainPurchaseContext, + OnchainPurchaseContextType, + OnchainPurchaseProviderProps, +} from "./onchain-purchase-context"; export const OnchainPurchaseProvider = ({ children, @@ -842,13 +719,3 @@ export const OnchainPurchaseProvider = ({ ); }; - -export const useOnchainPurchaseContext = () => { - const context = useContext(OnchainPurchaseContext); - if (!context) { - throw new Error( - "useOnchainPurchaseContext must be used within OnchainPurchaseProvider", - ); - } - return context; -}; diff --git a/packages/keychain/src/context/starterpack/starterpack-context.ts b/packages/keychain/src/context/starterpack/starterpack-context.ts new file mode 100644 index 0000000000..20144ba7ac --- /dev/null +++ b/packages/keychain/src/context/starterpack/starterpack-context.ts @@ -0,0 +1,55 @@ +import { createContext, ReactNode } from "react"; +import { MerkleDropDisplayOptions } from "@/hooks/starterpack"; +import { SocialClaimConditions } from "@/hooks/starterpack/bundle"; +import { SocialClaimOptions } from "@cartridge/controller"; +import { StarterpackDetails, Item } from "./types"; + +export interface StarterpackContextType { + // Registry contract address + registryAddress: string | undefined; + + // Bundle identification (starterpack V2) + bundleId: number | undefined; + setBundle: ( + id: number, + registryAddress: string, + socialClaimOptions?: SocialClaimOptions, + ) => void; + + // Starterpack identification + starterpackId: string | number | undefined; + setStarterpack: (id: string | number, registryAddress: string) => void; + + // Merkle drop identification + merkleDropKeys: string[] | undefined; + setMerkleDrops: (keys: string[], options?: MerkleDropDisplayOptions) => void; + + // Starterpack details (loaded from backend or onchain) + starterpackDetails: StarterpackDetails | undefined; + isStarterpackLoading: boolean; + + // Claim items (can be enriched with quantities for display) + claimItems: Item[]; + setClaimItems: (items: Item[]) => void; + + // Transaction state + transactionHash: string | undefined; + setTransactionHash: (hash: string) => void; + + // Error handling + displayError: Error | undefined; + setDisplayError: (error: Error | undefined) => void; + clearError: () => void; + + // Conditional bundles info + socialClaimOptions: SocialClaimOptions | undefined; + socialClaimConditions: SocialClaimConditions | undefined; +} + +export const StarterpackContext = createContext< + StarterpackContextType | undefined +>(undefined); + +export interface StarterpackProviderProps { + children: ReactNode; +} diff --git a/packages/keychain/src/context/starterpack/starterpack.tsx b/packages/keychain/src/context/starterpack/starterpack.tsx index 7d05aae685..8c61d79b92 100644 --- a/packages/keychain/src/context/starterpack/starterpack.tsx +++ b/packages/keychain/src/context/starterpack/starterpack.tsx @@ -1,11 +1,4 @@ -import { - createContext, - useContext, - useState, - useCallback, - useEffect, - ReactNode, -} from "react"; +import { useState, useCallback, useEffect } from "react"; import { MerkleDropDisplayOptions, useClaimMerkleDrops, @@ -17,61 +10,13 @@ import { Item, ItemType, } from "./types"; -import { - useBundleConditions, - SocialClaimConditions, -} from "@/hooks/starterpack/bundle"; +import { useBundleConditions } from "@/hooks/starterpack/bundle"; import { SocialClaimOptions } from "@cartridge/controller"; - -export interface StarterpackContextType { - // Registry contract address - registryAddress: string | undefined; - - // Bundle identification (starterpack V2) - bundleId: number | undefined; - setBundle: ( - id: number, - registryAddress: string, - socialClaimOptions?: SocialClaimOptions, - ) => void; - - // Starterpack identification - starterpackId: string | number | undefined; - setStarterpack: (id: string | number, registryAddress: string) => void; - - // Merkle drop identification - merkleDropKeys: string[] | undefined; - setMerkleDrops: (keys: string[], options?: MerkleDropDisplayOptions) => void; - - // Starterpack details (loaded from backend or onchain) - starterpackDetails: StarterpackDetails | undefined; - isStarterpackLoading: boolean; - - // Claim items (can be enriched with quantities for display) - claimItems: Item[]; - setClaimItems: (items: Item[]) => void; - - // Transaction state - transactionHash: string | undefined; - setTransactionHash: (hash: string) => void; - - // Error handling - displayError: Error | undefined; - setDisplayError: (error: Error | undefined) => void; - clearError: () => void; - - // Conditional bundles info - socialClaimOptions: SocialClaimOptions | undefined; - socialClaimConditions: SocialClaimConditions | undefined; -} - -export const StarterpackContext = createContext< - StarterpackContextType | undefined ->(undefined); - -export interface StarterpackProviderProps { - children: ReactNode; -} +import { + StarterpackContext, + StarterpackContextType, + StarterpackProviderProps, +} from "./starterpack-context"; export const StarterpackProvider = ({ children }: StarterpackProviderProps) => { const [registryAddress, setRegistryAddress] = useState(); @@ -272,13 +217,3 @@ export const StarterpackProvider = ({ children }: StarterpackProviderProps) => { ); }; - -export const useStarterpackContext = () => { - const context = useContext(StarterpackContext); - if (!context) { - throw new Error( - "useStarterpackContext must be used within StarterpackProvider", - ); - } - return context; -}; diff --git a/packages/keychain/src/context/starterpack/use-credit-purchase-context.ts b/packages/keychain/src/context/starterpack/use-credit-purchase-context.ts new file mode 100644 index 0000000000..5cb2a6f4f8 --- /dev/null +++ b/packages/keychain/src/context/starterpack/use-credit-purchase-context.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { CreditPurchaseContext } from "./credit-purchase-context"; + +export const useCreditPurchaseContext = () => { + const context = useContext(CreditPurchaseContext); + if (!context) { + throw new Error( + "useCreditPurchaseContext must be used within CreditPurchaseProvider", + ); + } + return context; +}; diff --git a/packages/keychain/src/context/starterpack/use-onchain-purchase-context.ts b/packages/keychain/src/context/starterpack/use-onchain-purchase-context.ts new file mode 100644 index 0000000000..57233b7bf8 --- /dev/null +++ b/packages/keychain/src/context/starterpack/use-onchain-purchase-context.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { OnchainPurchaseContext } from "./onchain-purchase-context"; + +export const useOnchainPurchaseContext = () => { + const context = useContext(OnchainPurchaseContext); + if (!context) { + throw new Error( + "useOnchainPurchaseContext must be used within OnchainPurchaseProvider", + ); + } + return context; +}; diff --git a/packages/keychain/src/context/starterpack/use-starterpack-context.ts b/packages/keychain/src/context/starterpack/use-starterpack-context.ts new file mode 100644 index 0000000000..7a85d63c63 --- /dev/null +++ b/packages/keychain/src/context/starterpack/use-starterpack-context.ts @@ -0,0 +1,12 @@ +import { useContext } from "react"; +import { StarterpackContext } from "./starterpack-context"; + +export const useStarterpackContext = () => { + const context = useContext(StarterpackContext); + if (!context) { + throw new Error( + "useStarterpackContext must be used within StarterpackProvider", + ); + } + return context; +}; diff --git a/packages/keychain/src/hooks/keychain-wallets.ts b/packages/keychain/src/hooks/keychain-wallets.ts new file mode 100644 index 0000000000..892dbfdbb3 --- /dev/null +++ b/packages/keychain/src/hooks/keychain-wallets.ts @@ -0,0 +1,113 @@ +import { ExternalWalletResponse, WalletAdapter } from "@cartridge/controller"; +import { getAddress } from "ethers/address"; +import { ParentMethods } from "./connection"; +import { ExternalWalletError } from "@/utils/errors"; + +/** + * Service running in the keychain iframe to handle signing requests. + * It decides whether to use an embedded wallet or delegate to the + * external WalletBridge in the parent controller window via penpal. + */ +export class KeychainWallets { + private parent: ParentMethods; + private embeddedWalletsByAddress: Map = new Map(); + + // Method to set the parent connection once established + constructor(parent: ParentMethods) { + this.parent = parent; + } + + /** + * Adds an embedded wallet to the map of embedded wallets. + * @param address - The address of the embedded wallet. + * @param wallet - The wallet adapter instance. + */ + addEmbeddedWallet(address: string, wallet: WalletAdapter) { + this.embeddedWalletsByAddress.set(getAddress(address), wallet); + } + + /** + * Gets an embedded wallet from the map of embedded wallets. + * @param address - The address of the embedded wallet. + * @returns The wallet adapter instance or undefined if not found. + */ + getEmbeddedWallet(address: string): WalletAdapter | undefined { + return this.embeddedWalletsByAddress.get(getAddress(address)); + } + + /** + * Signs a message using either an embedded wallet or the external bridge. + * @param identifier - The identifier (e.g., address) of the wallet. + * @param message - The message hex string to sign. + * @returns Promise resolving to the signature hex string. + * @throws Error if signing fails or the connection isn't ready. + */ + async signMessage( + identifier: string, + message: string, + ): Promise { + // --- Decision Logic --- + const embeddedWallet = this.getEmbeddedWallet(identifier); + + if (embeddedWallet) { + // --- Embedded Wallet Path --- + const response = await embeddedWallet.signMessage?.(message); + if (!response?.success) { + throw new ExternalWalletError( + `Failed to sign message with embedded wallet. ${response?.error}`, + ); + } + return response; + } else { + // --- External Wallet Path --- + if (!this.parent) { + console.error("KeychainWallets: Parent connection not available."); + throw new Error("Wallet connection not ready."); + } + + try { + // Call the parent window's bridge method via penpal + // Note: Parent's externalSignMessage now expects identifier string + const response = await this.parent.externalSignMessage( + identifier.startsWith("0x") ? getAddress(identifier) : identifier, + message, + ); + + if (response.success && response.result) { + // Assuming response.result is the signature string + // Ensure it's actually a string before returning + if (typeof response.result === "string") { + return response; + } else { + console.error( + "KeychainWallets: Parent response result is not a string:", + response.result, + ); + throw new ExternalWalletError( + "Invalid signature format received from wallet bridge.", + ); + } + } else { + const errorMsg = + response.error || "Unknown error from external wallet bridge."; + console.error( + `KeychainWallets: Error from parent bridge: ${errorMsg}`, + ); + throw new ExternalWalletError(errorMsg); + } + } catch (error) { + console.error( + "KeychainWallets: Failed to call externalSignMessage:", + error, + ); + // Re-throw the error to be caught by the WASM caller + if (error instanceof ExternalWalletError) { + throw error; + } + throw error instanceof Error + ? new ExternalWalletError(error.message) + : new ExternalWalletError(String(error)); + } + } + } +} diff --git a/packages/keychain/src/hooks/starterpack/external-wallet.ts b/packages/keychain/src/hooks/starterpack/external-wallet.ts index 6a0817d1f5..133ffad149 100644 --- a/packages/keychain/src/hooks/starterpack/external-wallet.ts +++ b/packages/keychain/src/hooks/starterpack/external-wallet.ts @@ -1,6 +1,6 @@ import { useState, useCallback } from "react"; import { ExternalPlatform, ExternalWallet } from "@cartridge/controller"; -import { useWallets } from "@/hooks/wallets"; +import { useWallets } from "@/hooks/use-wallets"; import { useConnection } from "../connection"; export interface UseExternalWalletOptions { diff --git a/packages/keychain/src/hooks/use-wallets.ts b/packages/keychain/src/hooks/use-wallets.ts new file mode 100644 index 0000000000..672aedec3b --- /dev/null +++ b/packages/keychain/src/hooks/use-wallets.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { WalletsContext, WalletsContextValue } from "./wallets-context"; + +export const useWallets = (): WalletsContextValue => { + const context = useContext(WalletsContext); + if (context === undefined) { + throw new Error("useWallets must be used within a WalletsProvider"); + } + return context; +}; diff --git a/packages/keychain/src/hooks/wallets-context.ts b/packages/keychain/src/hooks/wallets-context.ts new file mode 100644 index 0000000000..e1d7daf8b6 --- /dev/null +++ b/packages/keychain/src/hooks/wallets-context.ts @@ -0,0 +1,37 @@ +import { createContext } from "react"; +import { + AuthExternalWallet, + ExternalWallet, + ExternalWalletResponse, + ExternalWalletType, +} from "@cartridge/controller"; +import { CredentialMetadata } from "@cartridge/controller-ui/utils/api/cartridge"; +import { KeychainWallets } from "./keychain-wallets"; + +export interface WalletsContextValue { + wallets: ExternalWallet[]; + supportedWalletsForAuth: AuthExternalWallet[]; + isLoading: boolean; + isConnecting: boolean; + error: Error | null; + detectWallets: () => Promise; + connectWallet: ( + type: ExternalWalletType, + ) => Promise; + isExtensionMissing: (signer: CredentialMetadata) => boolean; + switchChain: ( + identifier: ExternalWalletType, + chainId: string, + ) => Promise; + availableWallets: ExternalWalletType[]; +} + +declare global { + interface Window { + keychain_wallets?: KeychainWallets; + } +} + +export const WalletsContext = createContext( + undefined, +); diff --git a/packages/keychain/src/hooks/wallets.tsx b/packages/keychain/src/hooks/wallets.tsx index fa4cdb6fae..499f604281 100644 --- a/packages/keychain/src/hooks/wallets.tsx +++ b/packages/keychain/src/hooks/wallets.tsx @@ -5,49 +5,19 @@ import { ExternalWallet, ExternalWalletResponse, ExternalWalletType, - WalletAdapter, } from "@cartridge/controller"; import { CredentialMetadata } from "@cartridge/controller-ui/utils/api/cartridge"; -import { getAddress } from "ethers/address"; import { ExternalWalletError } from "@/utils/errors"; import React, { - createContext, PropsWithChildren, useCallback, - useContext, useEffect, useMemo, useState, } from "react"; -import { ParentMethods, useConnection } from "./connection"; - -interface WalletsContextValue { - wallets: ExternalWallet[]; - supportedWalletsForAuth: AuthExternalWallet[]; - isLoading: boolean; - isConnecting: boolean; - error: Error | null; - detectWallets: () => Promise; - connectWallet: ( - type: ExternalWalletType, - ) => Promise; - isExtensionMissing: (signer: CredentialMetadata) => boolean; - switchChain: ( - identifier: ExternalWalletType, - chainId: string, - ) => Promise; - availableWallets: ExternalWalletType[]; -} - -declare global { - interface Window { - keychain_wallets?: KeychainWallets; - } -} - -const WalletsContext = createContext( - undefined, -); +import { useConnection } from "./connection"; +import { KeychainWallets } from "./keychain-wallets"; +import { WalletsContext } from "./wallets-context"; export const WalletsProvider: React.FC = ({ children }) => { const { parent } = useConnection(); @@ -241,120 +211,3 @@ export const WalletsProvider: React.FC = ({ children }) => { {children} ); }; - -export const useWallets = (): WalletsContextValue => { - const context = useContext(WalletsContext); - if (context === undefined) { - throw new Error("useWallets must be used within a WalletsProvider"); - } - return context; -}; - -/** - * Service running in the keychain iframe to handle signing requests. - * It decides whether to use an embedded wallet or delegate to the - * external WalletBridge in the parent controller window via penpal. - */ -export class KeychainWallets { - private parent: ParentMethods; - private embeddedWalletsByAddress: Map = new Map(); - - // Method to set the parent connection once established - constructor(parent: ParentMethods) { - this.parent = parent; - } - - /** - * Adds an embedded wallet to the map of embedded wallets. - * @param address - The address of the embedded wallet. - * @param wallet - The wallet adapter instance. - */ - addEmbeddedWallet(address: string, wallet: WalletAdapter) { - this.embeddedWalletsByAddress.set(getAddress(address), wallet); - } - - /** - * Gets an embedded wallet from the map of embedded wallets. - * @param address - The address of the embedded wallet. - * @returns The wallet adapter instance or undefined if not found. - */ - getEmbeddedWallet(address: string): WalletAdapter | undefined { - return this.embeddedWalletsByAddress.get(getAddress(address)); - } - - /** - * Signs a message using either an embedded wallet or the external bridge. - * @param identifier - The identifier (e.g., address) of the wallet. - * @param message - The message hex string to sign. - * @returns Promise resolving to the signature hex string. - * @throws Error if signing fails or the connection isn't ready. - */ - async signMessage( - identifier: string, - message: string, - ): Promise { - // --- Decision Logic --- - const embeddedWallet = this.getEmbeddedWallet(identifier); - - if (embeddedWallet) { - // --- Embedded Wallet Path --- - const response = await embeddedWallet.signMessage?.(message); - if (!response?.success) { - throw new ExternalWalletError( - `Failed to sign message with embedded wallet. ${response?.error}`, - ); - } - return response; - } else { - // --- External Wallet Path --- - if (!this.parent) { - console.error("KeychainWallets: Parent connection not available."); - throw new Error("Wallet connection not ready."); - } - - try { - // Call the parent window's bridge method via penpal - // Note: Parent's externalSignMessage now expects identifier string - const response = await this.parent.externalSignMessage( - identifier.startsWith("0x") ? getAddress(identifier) : identifier, - message, - ); - - if (response.success && response.result) { - // Assuming response.result is the signature string - // Ensure it's actually a string before returning - if (typeof response.result === "string") { - return response; - } else { - console.error( - "KeychainWallets: Parent response result is not a string:", - response.result, - ); - throw new ExternalWalletError( - "Invalid signature format received from wallet bridge.", - ); - } - } else { - const errorMsg = - response.error || "Unknown error from external wallet bridge."; - console.error( - `KeychainWallets: Error from parent bridge: ${errorMsg}`, - ); - throw new ExternalWalletError(errorMsg); - } - } catch (error) { - console.error( - "KeychainWallets: Failed to call externalSignMessage:", - error, - ); - // Re-throw the error to be caught by the WASM caller - if (error instanceof ExternalWalletError) { - throw error; - } - throw error instanceof Error - ? new ExternalWalletError(error.message) - : new ExternalWalletError(String(error)); - } - } - } -} diff --git a/packages/keychain/src/utils/connection/headless.test.ts b/packages/keychain/src/utils/connection/headless.test.ts index 37a8550ab5..14b8ba1311 100644 --- a/packages/keychain/src/utils/connection/headless.test.ts +++ b/packages/keychain/src/utils/connection/headless.test.ts @@ -17,7 +17,7 @@ vi.mock("@/components/connect/create/password/crypto", () => ({ generateStarknetKeypair: () => mockGenerateStarknetKeypair(), })); -vi.mock("@/components/provider/upgrade", () => ({ +vi.mock("@/components/provider/upgrade-context", () => ({ STABLE_CONTROLLER: { hash: "0xclasshash" }, })); diff --git a/packages/keychain/src/utils/connection/headless.ts b/packages/keychain/src/utils/connection/headless.ts index 871833f311..7bd2554da7 100644 --- a/packages/keychain/src/utils/connection/headless.ts +++ b/packages/keychain/src/utils/connection/headless.ts @@ -1,4 +1,4 @@ -import { STABLE_CONTROLLER } from "@/components/provider/upgrade"; +import { STABLE_CONTROLLER } from "@/components/provider/upgrade-context"; import { doSignup } from "@/hooks/account"; import { fetchController } from "@/components/connect/create/utils"; import { diff --git a/packages/keychain/src/utils/token-amount.ts b/packages/keychain/src/utils/token-amount.ts new file mode 100644 index 0000000000..60be1b5526 --- /dev/null +++ b/packages/keychain/src/utils/token-amount.ts @@ -0,0 +1,19 @@ +export function parseTokenAmount( + value: string | undefined, + decimals: number, +): bigint | undefined { + const trimmed = value?.trim(); + if (!trimmed || !/^\d+(\.\d*)?$/.test(trimmed)) { + return undefined; + } + + const [whole, fractional = ""] = trimmed.split("."); + if (fractional.length > decimals) { + return undefined; + } + + const base = 10n ** BigInt(decimals); + const wholeAmount = BigInt(whole) * base; + const fractionalAmount = BigInt(fractional.padEnd(decimals, "0") || "0"); + return wholeAmount + fractionalAmount; +} From 316e33516682b0f0bb6153bec120dfce2baaf99b Mon Sep 17 00:00:00 2001 From: Tarrence van As Date: Thu, 18 Jun 2026 20:39:06 -0400 Subject: [PATCH 2/2] fix(keychain): update .storybook import after provider/context split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The C2 split moved CONTROLLER_VERSIONS/UpgradeContext/UpgradeInterface/ UpgradeProviderProps out of provider/upgrade.tsx into upgrade-context.ts. .storybook/ is outside the package tsconfig (so tsc -b didn't catch it), but the Vercel keychain-storybook build compiles it — fixing the stale import path. Co-Authored-By: Claude Opus 4.8 --- packages/keychain/.storybook/mock.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/keychain/.storybook/mock.tsx b/packages/keychain/.storybook/mock.tsx index a003373ab7..1adbd883be 100644 --- a/packages/keychain/.storybook/mock.tsx +++ b/packages/keychain/.storybook/mock.tsx @@ -10,7 +10,7 @@ import { UpgradeContext, UpgradeInterface, UpgradeProviderProps, -} from "../src/components/provider/upgrade"; +} from "../src/components/provider/upgrade-context"; import { ConnectCtx, ConnectionCtx } from "../src/utils/connection/types"; import { SemVer } from "semver";