diff --git a/examples/next/src/components/Profile.tsx b/examples/next/src/components/Profile.tsx
index eb6657b0a5..92ea4d3ec7 100644
--- a/examples/next/src/components/Profile.tsx
+++ b/examples/next/src/components/Profile.tsx
@@ -10,10 +10,17 @@ import {
ETH_CONTRACT_ADDRESS,
} from "@cartridge/controller-ui/utils";
-export const BUNDLE_REGISTRY_MAINNET =
+// Nums bundle registries
+export const NUMS_REGISTRY_MAINNET =
"0x1a8516498b484f209aefbbf5af67765a2b1e3889fd00902811f18576a4616b0";
-export const BUNDLE_REGISTRY_SEPOLIA =
+export const NUMS_REGISTRY_SEPOLIA =
"0x3110295929fc665972ae2ea4b99d5fa57547aa56d140dc73a7e85ddcaf5eaf1";
+export const ABYSS_REGISTRY_MAINNET =
+ "0x529c074ad9540d56a8d6e7086bb3d7a3d8ebaf60dbbf4abb1aeae336be833fe";
+
+// legacy starterpack registry (deprecated)
+export const STARTERPACK_REGISTRY_MAINNET =
+ "0x3eb03b8f2be0ec2aafd186d72f6d8f3dd320dbc89f2b6802bca7465f6ccaa43";
export function Profile() {
const { account, connector } = useAccount();
diff --git a/examples/next/src/components/Starterpack.tsx b/examples/next/src/components/Starterpack.tsx
index e6372b9787..3f8a889c8e 100644
--- a/examples/next/src/components/Starterpack.tsx
+++ b/examples/next/src/components/Starterpack.tsx
@@ -5,7 +5,11 @@ import { constants, num } from "starknet";
import { useAccount, useNetwork } from "@starknet-react/core";
import ControllerConnector from "@cartridge/connector/controller";
import { Button, Input } from "@cartridge/controller-ui";
-import { BUNDLE_REGISTRY_MAINNET, BUNDLE_REGISTRY_SEPOLIA } from "./Profile";
+import {
+ ABYSS_REGISTRY_MAINNET,
+ NUMS_REGISTRY_MAINNET,
+ NUMS_REGISTRY_SEPOLIA,
+} from "./Profile";
export const Starterpack = () => {
const { account, connector } = useAccount();
@@ -19,14 +23,14 @@ export const Starterpack = () => {
bundleId: 1,
socialBundleId: 0,
starterpackId: 0,
- registryAddress: BUNDLE_REGISTRY_MAINNET,
+ registryAddress: NUMS_REGISTRY_MAINNET,
};
}
return {
bundleId: 1,
socialBundleId: 0,
starterpackId: 0,
- registryAddress: BUNDLE_REGISTRY_SEPOLIA,
+ registryAddress: NUMS_REGISTRY_SEPOLIA,
};
}, [chain]);
@@ -143,6 +147,16 @@ export const Starterpack = () => {
>
Nums Social Bundle
+
diff --git a/package.json b/package.json
index c4555ad431..8351f84385 100644
--- a/package.json
+++ b/package.json
@@ -39,7 +39,7 @@
"prepare": "husky"
},
"dependencies": {
- "@cartridge/presets": "github:cartridge-gg/presets#e94909f",
+ "@cartridge/presets": "catalog:",
"@cartridge/controller-ui": "workspace:*",
"tailwindcss": "catalog:",
"@graphql-codegen/cli": "^2.6.2",
diff --git a/packages/keychain/src/components/NavigationHeader.tsx b/packages/keychain/src/components/NavigationHeader.tsx
index b0ec8186e5..de8c23d044 100644
--- a/packages/keychain/src/components/NavigationHeader.tsx
+++ b/packages/keychain/src/components/NavigationHeader.tsx
@@ -1,7 +1,8 @@
+import { useCallback } from "react";
import { LayoutHeader, HeaderProps } from "@cartridge/controller-ui";
import { useConnection } from "@/hooks/connection";
import { useNavigation } from "@/context/navigation";
-import { useCallback } from "react";
+import { useCreditsContext } from "@/components/credits/provider";
export type NavigationHeaderProps = {
// Navigation props
@@ -48,11 +49,14 @@ export function NavigationHeader({
navigateToRoot();
}, [onClose, closeModal, navigateToRoot]);
+ const { initiateCreditsDeposit } = useCreditsContext();
+
return (
);
diff --git a/packages/keychain/src/components/connect/create/social/index.ts b/packages/keychain/src/components/connect/create/social/index.ts
index 5361906e48..fb80cebe96 100644
--- a/packages/keychain/src/components/connect/create/social/index.ts
+++ b/packages/keychain/src/components/connect/create/social/index.ts
@@ -14,6 +14,7 @@ export const useSocialAuthentication = (
socialProvider: SocialProvider,
username: string,
isSignup: boolean,
+ forceAccountSelection = false,
) => {
if (!chainId) {
throw new Error("No chainId");
@@ -25,7 +26,10 @@ export const useSocialAuthentication = (
rpcUrl,
socialProvider,
);
- const { account, error, success } = await turnkeyWallet.connect(isSignup);
+ const { account, error, success } = await turnkeyWallet.connect(
+ isSignup,
+ forceAccountSelection,
+ );
if (error?.includes("Account mismatch")) {
setChangeWallet?.(true);
return;
@@ -57,9 +61,15 @@ export const useSocialAuthentication = (
);
return {
- signup: (socialProvider: SocialProvider, username: string) =>
- signup(socialProvider, username, true),
- login: (socialProvider: SocialProvider, username: string) =>
- signup(socialProvider, username, false),
+ signup: (
+ socialProvider: SocialProvider,
+ username: string,
+ forceAccountSelection = false,
+ ) => signup(socialProvider, username, true, forceAccountSelection),
+ login: (
+ socialProvider: SocialProvider,
+ username: string,
+ forceAccountSelection = false,
+ ) => signup(socialProvider, username, false, forceAccountSelection),
};
};
diff --git a/packages/keychain/src/components/connect/create/useCreateController.ts b/packages/keychain/src/components/connect/create/useCreateController.ts
index 10dcfcae16..7350c12d91 100644
--- a/packages/keychain/src/components/connect/create/useCreateController.ts
+++ b/packages/keychain/src/components/connect/create/useCreateController.ts
@@ -267,6 +267,11 @@ export function useCreateController({
>(undefined);
const signupStartedRef = useRef(false);
const signupResolvedRef = useRef(false);
+ // The OAuth (Auth0/Turnkey) redirect callback consumes a single-use
+ // transaction. Guard against StrictMode double-invocation and dependency-
+ // driven re-runs so handleRedirectCallback runs exactly once — otherwise the
+ // second call throws "Invalid state" and the page loops on the login screen.
+ const redirectHandledRef = useRef(false);
const signupStartTimeRef = useRef(performance.now());
const authStepRef = useRef
(AuthenticationStep.FillForm);
@@ -579,7 +584,11 @@ export function useCreateController({
}
case "google":
case "discord":
- signupResponse = await signupWithSocial(authenticationMode, username);
+ signupResponse = await signupWithSocial(
+ authenticationMode,
+ username,
+ changeWallet,
+ );
if (!signupResponse) {
return;
}
@@ -694,6 +703,7 @@ export function useCreateController({
params,
handleCompletion,
searchParams,
+ changeWallet,
],
);
@@ -943,7 +953,11 @@ export function useCreateController({
case "google":
case "discord": {
setWaitingForConfirmation(true);
- loginResponse = await loginWithSocial(authenticationMethod, username);
+ loginResponse = await loginWithSocial(
+ authenticationMethod,
+ username,
+ changeWallet,
+ );
if (!loginResponse) {
return;
}
@@ -1053,15 +1067,28 @@ export function useCreateController({
smsAuth,
smsState,
setWaitingForConfirmation,
+ changeWallet,
],
);
useEffect(() => {
if (!chainId) return;
+ if (redirectHandledRef.current) return;
if (
window.location.search.includes("code") &&
window.location.search.includes("state")
) {
+ redirectHandledRef.current = true;
+ const redirectUrl = window.location.href;
+ // Strip the single-use OAuth params from the address bar immediately.
+ // handleRedirect() consumes them from the captured `redirectUrl`, so a
+ // reload (or a remount that resets the guard) can't replay the already-
+ // consumed callback and fail with "Invalid state" — and the user is left
+ // on a clean URL from which a fresh login can be initiated.
+ const cleanUrl = new URL(redirectUrl);
+ cleanUrl.searchParams.delete("code");
+ cleanUrl.searchParams.delete("state");
+ window.history.replaceState({}, "", cleanUrl.toString());
(async () => {
setIsLoading(true);
try {
@@ -1080,14 +1107,8 @@ export function useCreateController({
searchParams,
chainId,
rpcUrl,
- } = await turnkeyWallet.handleRedirect(
- window.location.href,
- setError,
- );
+ } = await turnkeyWallet.handleRedirect(redirectUrl, setError);
- if (error) {
- throw error;
- }
if (
!username ||
isSignup === undefined ||
@@ -1173,7 +1194,6 @@ export function useCreateController({
})();
}
}, [
- error,
setIsLoading,
finishLogin,
finishSignup,
diff --git a/packages/keychain/src/components/credits/AmountSelectionDrawer.tsx b/packages/keychain/src/components/credits/AmountSelectionDrawer.tsx
new file mode 100644
index 0000000000..7497e1f549
--- /dev/null
+++ b/packages/keychain/src/components/credits/AmountSelectionDrawer.tsx
@@ -0,0 +1,56 @@
+import { useState } from "react";
+import {
+ Drawer,
+ DrawerContent,
+ DepositIcon,
+ Button,
+} from "@cartridge/controller-ui";
+import { CREDITS_DESCRIPTION } from "@/components/inventory/token";
+import { AmountSelection } from "@/components/funding/AmountSelection";
+
+interface AmountSelectionDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ minAmount?: number;
+ maxAmount?: number;
+ isLoading?: boolean;
+ onContinue: (amount: number) => void;
+}
+
+export function AmountSelectionDrawer({
+ isOpen,
+ onClose,
+ minAmount,
+ maxAmount,
+ isLoading,
+ onContinue,
+}: AmountSelectionDrawerProps) {
+ const [amount, setAmount] = useState(0);
+ return (
+
+ }>
+
+
+
+ {CREDITS_DESCRIPTION}
+
+
+
+
+
+ );
+}
diff --git a/packages/keychain/src/components/credits/CheckoutDrawer.tsx b/packages/keychain/src/components/credits/CheckoutDrawer.tsx
new file mode 100644
index 0000000000..aea9fc463b
--- /dev/null
+++ b/packages/keychain/src/components/credits/CheckoutDrawer.tsx
@@ -0,0 +1,270 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ Drawer,
+ DrawerContent,
+ DepositIcon,
+ Button,
+ TokenCard,
+} from "@cartridge/controller-ui";
+import { PaymentMethodSelection } from "@/components/purchase/checkout/onchain/wallet-drawer";
+import { WalletSelector } from "@/components/purchase/checkout/onchain/selector";
+import { CostBreakdown } from "@/components/purchase/review/cost";
+import { ErrorAlert } from "@/components/ErrorAlert";
+import { useTokens } from "@/hooks/token";
+import { useConnection } from "@/hooks/connection";
+import {
+ createStarknetCryptoPayment,
+ waitForCryptoPaymentConfirmation,
+} from "@/hooks/payments/crypto";
+import { USDC_ADDRESSES, USDC_ICON } from "@/utils/ekubo";
+import { formatCredits, usdToCreditUnits, usdToUsdcWei } from "@/utils/credits";
+import type { TokenOption } from "@/context";
+import { CallData, cairo } from "starknet";
+import { ConfirmingTransaction } from "@/components/purchase/pending/confirming-transaction";
+import { ErrorCard } from "@/components/purchase/checkout/onchain/error";
+import { useCreditsContext } from "./provider";
+
+interface CheckoutDrawerProps {
+ isOpen: boolean;
+ onClose: () => void;
+ paymentMethod: PaymentMethodSelection | null;
+ amount: number;
+ onChangeMethod: () => void;
+ onChangeAmount: () => void;
+}
+
+export function CheckoutDrawer({
+ isOpen,
+ onClose,
+ paymentMethod,
+ amount,
+ onChangeMethod,
+ onChangeAmount,
+}: CheckoutDrawerProps) {
+ const { credits } = useTokens();
+ const { controller } = useConnection();
+ const [error, setError] = useState(null);
+ const { onDepositStarted, onDepositFinished, depositInProgress } =
+ useCreditsContext();
+
+ const isController = paymentMethod?.type === "controller";
+
+ // $1 of credits is always $1 of USDC, so while USDC is the only rail we can
+ // build the quote locally (1:1) — no backend quote needed. Future rails
+ // (other tokens, Coinflow, Apple Pay) will swap this for a real quote and an
+ // expanded token list; CostBreakdown stays the same.
+ const usdcToken = useMemo(
+ () => ({
+ name: "USD Coin",
+ symbol: "USDC",
+ decimals: 6,
+ address: controller
+ ? (USDC_ADDRESSES[controller.chainId()] ?? "usdc")
+ : "usdc",
+ icon: USDC_ICON,
+ contract: {} as TokenOption["contract"],
+ }),
+ [controller],
+ );
+
+ const { usdcBalance, handlePurchaseWithController } = useControllerPurchase({
+ usdcToken,
+ amount,
+ });
+
+ const hasInsufficientBalance =
+ usdcBalance !== undefined && usdcBalance < amount;
+
+ // initialize
+ useEffect(() => {
+ setError(null);
+ }, [isOpen]);
+
+ // main payment switcher
+ const handlePurchase = useCallback(async () => {
+ if (!paymentMethod || !amount) return;
+ onDepositStarted(paymentMethod, amount);
+ setError(null);
+ try {
+ if (isController) {
+ await handlePurchaseWithController();
+ }
+ await onDepositFinished();
+ console.log(`USD deposit successful.`);
+ await credits.refetch?.();
+ } catch (e) {
+ console.error(`USD deposit error:`, e);
+ const error = (e instanceof Error ? e : new Error(String(e))).message;
+ await onDepositFinished(error);
+ setError(error);
+ }
+ }, [
+ paymentMethod,
+ amount,
+ isController,
+ credits,
+ handlePurchaseWithController,
+ onDepositStarted,
+ onDepositFinished,
+ setError,
+ ]);
+
+ const isProcessing = depositInProgress?.status === "processing";
+ const isSuccess = depositInProgress?.status === "success";
+
+ // auto close on success
+ useEffect(() => {
+ if (isSuccess && isOpen) {
+ onClose();
+ }
+ }, [isSuccess, isOpen, onClose]);
+
+ const canPurchase = useMemo(() => {
+ if (!amount || !paymentMethod || isProcessing) {
+ return false;
+ }
+ if (isController) {
+ return !hasInsufficientBalance && !!controller;
+ }
+ return false;
+ }, [
+ amount,
+ paymentMethod,
+ isProcessing,
+ isController,
+ hasInsufficientBalance,
+ controller,
+ ]);
+
+ return (
+
+ }>
+ {isProcessing || isSuccess ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+ {hasInsufficientBalance && (
+
+ )}
+
+ {error && (
+
+ )}
+
+ {}}
+ tokenSelectDisabled
+ value={
+ {amount.toFixed(2)}
+ }
+ />
+
+
+ >
+ )}
+
+
+ );
+}
+
+// Encapsulates the controller (USDC) payment rail: the controller's USDC
+// balance used to gate the purchase, and the transaction that fronts USDC to a
+// derived deposit address. Lives here so CheckoutDrawer stays focused on UI.
+function useControllerPurchase({
+ usdcToken,
+ amount,
+}: {
+ usdcToken: TokenOption;
+ amount: number;
+}) {
+ const { tokens } = useTokens();
+ const { controller, isMainnet } = useConnection();
+
+ const amountInUsdcWei = useMemo(() => usdToUsdcWei(amount), [amount]);
+
+ // Paying with the controller means it fronts USDC, so the relevant balance to
+ // gate on is the controller's USDC — mirror the onchain checkout's
+ // insufficient-balance warning. useTokens() keeps balances refreshed, so read
+ // it from there (amount is USD, 1 USDC = $1).
+ const usdcBalance = useMemo(() => {
+ if (usdcToken.address === "usdc") return undefined;
+ const match = tokens.find((t) => {
+ try {
+ return (
+ BigInt(t.metadata.address || "0x0") === BigInt(usdcToken.address)
+ );
+ } catch {
+ return false;
+ }
+ });
+ return match?.balance.amount ?? 0;
+ }, [tokens, usdcToken.address]);
+
+ const handlePurchaseWithController = useCallback(async () => {
+ if (!controller || !amount) return;
+
+ // Deposit model (credits-unification Phase 1b): create a crypto payment,
+ // send USDC from the controller to its derived deposit address, then poll
+ // until the sweeper grants the account credits.
+ const payment = await createStarknetCryptoPayment({
+ tokenAddress: usdcToken.address,
+ tokenAmount: amountInUsdcWei,
+ isMainnet,
+ });
+
+ const calls = [
+ {
+ contractAddress: usdcToken.address,
+ entrypoint: "transfer",
+ calldata: CallData.compile({
+ recipient: payment.depositAddress,
+ amount: cairo.uint256(amountInUsdcWei),
+ }),
+ },
+ ];
+
+ // Pay gas from the controller's own native fee token (STRK) instead of
+ // the paymaster: estimate the fee, then submit a direct V3 invoke with
+ // that maxFee. Passing a feeSource (PAYMASTER/CREDITS) would route this
+ // back through outside-execution, so we deliberately omit it.
+ const maxFee = await controller.estimateInvokeFee(calls);
+ const response = await controller.execute(calls, maxFee);
+
+ const transactionHash = response.transaction_hash;
+ console.log(`Credits purchase transaction:`, transactionHash);
+
+ await waitForCryptoPaymentConfirmation(payment.id);
+
+ // keep for testing workflow...
+ // await new Promise((resolve) => setTimeout(resolve, 2000));
+ // throw new Error("Bump!");
+ }, [controller, amount, usdcToken.address, amountInUsdcWei, isMainnet]);
+
+ return { usdcBalance, handlePurchaseWithController };
+}
diff --git a/packages/keychain/src/components/credits/DepositCredits.tsx b/packages/keychain/src/components/credits/DepositCredits.tsx
new file mode 100644
index 0000000000..26970b87ff
--- /dev/null
+++ b/packages/keychain/src/components/credits/DepositCredits.tsx
@@ -0,0 +1,81 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ WalletSelectionDrawer,
+ type PaymentMethodSelection,
+} from "../purchase/checkout/onchain/wallet-drawer";
+import { AmountSelectionDrawer } from "./AmountSelectionDrawer";
+import { CheckoutDrawer } from "./CheckoutDrawer";
+import { useCreditsContext } from "./provider";
+
+interface DepositCreditsProps {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function DepositCredits({ isOpen, onClose }: DepositCreditsProps) {
+ const [paymentMethod, setPaymentMethod] =
+ useState(null);
+ const [amount, setAmount] = useState(0);
+ const { depositInProgress } = useCreditsContext();
+
+ const isController = paymentMethod?.type == "controller";
+
+ // initialize on open and close
+ useEffect(() => {
+ setPaymentMethod(null);
+ setAmount(0);
+ }, [isOpen]);
+
+ const step = useMemo(() => {
+ if (depositInProgress) return "checkout";
+ if (!paymentMethod) return "method";
+ if (!amount) return "amount";
+ return "checkout";
+ }, [paymentMethod, amount, depositInProgress]);
+
+ return (
+ <>
+ {
+ if (step == "method") {
+ onClose();
+ }
+ }}
+ setSelected={(selection: PaymentMethodSelection) => {
+ setPaymentMethod(selection);
+ }}
+ // showFiatOptions={countryCode === "US"}
+ showFiatOptions={false}
+ showController={true}
+ showCrypto={false}
+ />
+
+ {
+ if (step == "amount") {
+ onClose();
+ }
+ }}
+ onContinue={async (amount: number) => {
+ setAmount(amount);
+ }}
+ />
+
+ {
+ if (step == "checkout") {
+ onClose();
+ }
+ }}
+ paymentMethod={depositInProgress?.paymentMethod || paymentMethod}
+ amount={depositInProgress?.amount || amount}
+ onChangeMethod={() => setPaymentMethod(null)}
+ onChangeAmount={() => setAmount(0)}
+ />
+ >
+ );
+}
diff --git a/packages/keychain/src/components/credits/provider.tsx b/packages/keychain/src/components/credits/provider.tsx
new file mode 100644
index 0000000000..23a29525b7
--- /dev/null
+++ b/packages/keychain/src/components/credits/provider.tsx
@@ -0,0 +1,108 @@
+import {
+ PropsWithChildren,
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+} from "react";
+import { DepositCredits } from "./DepositCredits";
+import { PaymentMethodSelection } from "../purchase/checkout/onchain/wallet-drawer";
+
+export type CreditDepositStatus = "processing" | "success" | "error" | "idle";
+
+export type CreditsContextValue = {
+ initiateCreditsDeposit: (onSuccessCallback?: () => Promise) => void;
+ onDepositStarted: (
+ paymentMethod: PaymentMethodSelection,
+ amount: number,
+ ) => void;
+ onDepositFinished: (error?: string) => Promise;
+ depositInProgress: null | {
+ paymentMethod: PaymentMethodSelection;
+ amount: number;
+ status: CreditDepositStatus;
+ };
+};
+
+type DepositInProgress = CreditsContextValue["depositInProgress"];
+
+export const CreditsContext = createContext({
+ initiateCreditsDeposit: () => {},
+ onDepositStarted: () => {},
+ onDepositFinished: async () => {},
+ depositInProgress: null,
+});
+
+export function CreditsProvider({ children }: PropsWithChildren) {
+ const [isDepositOpen, setIsDepositOpen] = useState(false);
+
+ const [depositInProgress, setDepositInProgress] =
+ useState(null);
+
+ const [depositSuccessCallback, setDepositSuccessCallback] = useState<
+ (() => Promise) | undefined
+ >(undefined);
+ const initiateCreditsDeposit = useCallback(
+ (onSuccessCallback?: () => Promise) => {
+ setDepositSuccessCallback(() => onSuccessCallback);
+ setIsDepositOpen(true);
+ },
+ [],
+ );
+
+ const onDepositStarted = useCallback(
+ async (paymentMethod: PaymentMethodSelection, amount: number) => {
+ setDepositInProgress({
+ paymentMethod,
+ amount,
+ status: "processing",
+ });
+ },
+ [],
+ );
+
+ const onDepositFinished = useCallback(
+ async (error?: string) => {
+ setDepositInProgress((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ status: error ? "error" : "success",
+ };
+ });
+ if (!error) {
+ await depositSuccessCallback?.();
+ }
+ },
+ [depositSuccessCallback, setDepositInProgress],
+ );
+
+ const handleClose = useCallback(() => {
+ setDepositSuccessCallback(undefined);
+ setDepositInProgress(null);
+ setIsDepositOpen(false);
+ }, []);
+
+ return (
+
+ {children}
+
+
+
+ );
+}
+
+export const useCreditsContext = () => {
+ const context = useContext(CreditsContext);
+ if (!context) {
+ throw new Error("useCreditsContext must be used within an CreditsProvider");
+ }
+ return context;
+};
diff --git a/packages/keychain/src/components/funding/AmountSelection.stories.tsx b/packages/keychain/src/components/funding/AmountSelection.stories.tsx
index 7c4d9f360f..9827800d6e 100644
--- a/packages/keychain/src/components/funding/AmountSelection.stories.tsx
+++ b/packages/keychain/src/components/funding/AmountSelection.stories.tsx
@@ -5,7 +5,7 @@ import { AmountSelection } from "./AmountSelection";
const meta = {
component: AmountSelection,
args: {
- enableCustom: true,
+ onChange: () => {},
},
} satisfies Meta;
diff --git a/packages/keychain/src/components/funding/AmountSelection.tsx b/packages/keychain/src/components/funding/AmountSelection.tsx
index 2ab7d5c66d..23912c9538 100644
--- a/packages/keychain/src/components/funding/AmountSelection.tsx
+++ b/packages/keychain/src/components/funding/AmountSelection.tsx
@@ -1,28 +1,40 @@
-import { Button, DollarIcon, Input } from "@cartridge/controller-ui";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { Button, DollarIcon, Input, Error } from "@cartridge/controller-ui";
import { cn } from "@cartridge/controller-ui/utils";
-import { useCallback, useRef, useState } from "react";
-export const USD_AMOUNTS = [10, 25, 50];
+export const CREDIT_AMOUNTS = [10, 20, 50];
+
+const DEFAULT_MIN_AMOUNT = 2;
+const DEFAULT_MAX_AMOUNT = 1000;
type AmountSelectionProps = {
- usdAmounts?: number[];
+ creditAmounts?: number[];
lockSelection?: boolean;
enableCustom?: boolean;
- onChange?: (usdAmount: number) => void;
+ minAmount?: number;
+ maxAmount?: number;
+ onChange: (creditAmount: number) => void;
};
export function AmountSelection({
- usdAmounts = USD_AMOUNTS,
- lockSelection,
- enableCustom,
+ creditAmounts = CREDIT_AMOUNTS,
+ lockSelection = false,
+ enableCustom = true,
+ minAmount,
+ maxAmount,
onChange,
}: AmountSelectionProps) {
- const [selectedUSD, setSelectedUSD] = useState(
- usdAmounts[0],
+ const [selectedAmount, setSelectedAmount] = useState(
+ creditAmounts[0],
);
const [custom, setCustom] = useState(false);
+ const [error, setError] = useState(null);
const inputRef = useRef(null);
+ useEffect(() => {
+ onChange(selectedAmount && !error ? selectedAmount : 0);
+ }, [onChange, selectedAmount, error]);
+
// Focus on the input
const setFocus = useCallback(() => {
// wait for the input to be rendered
@@ -36,79 +48,80 @@ export function AmountSelection({
return (
-
- Amount
-
-
-
- {usdAmounts.map((value) => (
-
- ))}
- {enableCustom && (
-
- )}
-
- {custom && (
-
- {
- const clean = e.target.value.replace(/^0+/, "");
- const amount = Number.parseInt(clean, 10);
- if (!isNaN(amount)) {
- onChange?.(amount);
- setSelectedUSD(amount);
- } else {
- onChange?.(0);
- setSelectedUSD(undefined);
- }
- }}
- onFocus={() => setCustom(true)}
- />
-
-
+
+ {creditAmounts.map((value) => (
+
+ ))}
+ {enableCustom && (
+
)}
+ {custom && (
+
+ {
+ const clean = e.target.value.replace(/^0+/, "");
+ const amount = clean ? Number.parseInt(clean, 10) : undefined;
+ setSelectedAmount(amount);
+ const min = minAmount ?? DEFAULT_MIN_AMOUNT;
+ const max = maxAmount ?? DEFAULT_MAX_AMOUNT;
+ if (amount && amount < min) {
+ setError(`$${min} minimum for purchases`);
+ } else if (amount && amount > max) {
+ setError(`$${max} maximum for purchases`);
+ } else {
+ setError(null);
+ }
+ }}
+ onFocus={() => setCustom(true)}
+ onClear={() => {
+ setSelectedAmount(undefined);
+ setError(null);
+ }}
+ />
+
+
+ )}
+ {error &&
}
);
}
diff --git a/packages/keychain/src/components/funding/Balance.tsx b/packages/keychain/src/components/funding/Balance.tsx
index 253db0c734..c403868eb6 100644
--- a/packages/keychain/src/components/funding/Balance.tsx
+++ b/packages/keychain/src/components/funding/Balance.tsx
@@ -10,6 +10,7 @@ import {
CardTitle,
TokenCard,
TokenSummary,
+ UsdColorIcon,
} from "@cartridge/controller-ui";
import { useCreditBalance } from "@cartridge/controller-ui/utils";
@@ -47,12 +48,12 @@ export function Balance({ types, title, amount }: BalanceProps) {
{types.includes(BalanceType.CREDITS) && (
}
+ title={"USD"}
amount={
amount
? `${amount.toFixed(2).toString()}`
- : `${creditBalance.formatted} CREDITS`
+ : `${creditBalance.formatted} USD`
}
/>
)}
diff --git a/packages/keychain/src/components/funding/Deposit.tsx b/packages/keychain/src/components/funding/Deposit.tsx
index 5b47f4ad73..1e7463e9e4 100644
--- a/packages/keychain/src/components/funding/Deposit.tsx
+++ b/packages/keychain/src/components/funding/Deposit.tsx
@@ -167,6 +167,9 @@ function DepositInner({ onComplete }: DepositProps) {
+
+ Amount
+
{error && (
diff --git a/packages/keychain/src/components/funding/index.tsx b/packages/keychain/src/components/funding/index.tsx
index 3c4d0a4e0b..a7c8cb709e 100644
--- a/packages/keychain/src/components/funding/index.tsx
+++ b/packages/keychain/src/components/funding/index.tsx
@@ -40,7 +40,13 @@ export function Funding({ title, isSlot }: FundingProps) {
{!isSlot && (
-
+
)}
>
diff --git a/packages/keychain/src/components/inventory/collection/collectible-listing.tsx b/packages/keychain/src/components/inventory/collection/collectible-listing.tsx
index 5557f86200..dee1c10b5a 100644
--- a/packages/keychain/src/components/inventory/collection/collectible-listing.tsx
+++ b/packages/keychain/src/components/inventory/collection/collectible-listing.tsx
@@ -26,7 +26,7 @@ import {
STRK_CONTRACT_ADDRESS,
USDC_CONTRACT_ADDRESS,
} from "@cartridge/controller-ui/utils";
-import { useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import placeholder from "/placeholder.svg?url";
import { ListHeader } from "./send/header";
@@ -34,7 +34,7 @@ import { useTokens } from "@/hooks/token";
import { useConnection } from "@/hooks/connection";
import { AllowArray, cairo, Call, CallData, FeeEstimate } from "starknet";
import { useToast } from "@/context/toast";
-import { ArcadeContext } from "@/context/arcade";
+import { useArcadeContext } from "@/context/arcade";
import { useEntrypoints } from "@/hooks/entrypoints";
import { useNavigation } from "@/context/navigation";
import { useCollection } from "@/hooks/collection";
@@ -64,8 +64,7 @@ const EXPIRATIONS = [
];
export function CollectibleListing() {
- const arcadeContext = useContext(ArcadeContext);
- const provider = arcadeContext?.provider;
+ const { marketplaceAddress } = useArcadeContext();
const { controller } = useConnection();
const { goBack } = useNavigation();
const { address: contractAddress, tokenId } = useParams();
@@ -193,13 +192,6 @@ export function CollectibleListing() {
[setSelected],
);
- // Memoize marketplace address
- const marketplaceAddress = useMemo(() => {
- return provider?.manifest.contracts.find((c: { tag: string }) =>
- c.tag?.includes("Marketplace"),
- )?.address;
- }, [provider?.manifest.contracts]);
-
// Build transactions when validation is complete
const buildTransactions = useMemo(() => {
if (
@@ -404,7 +396,7 @@ const ListingConfirmation = ({
}[];
currency: {
name: string;
- image: string;
+ image: string | React.ReactNode;
price: number;
value: string;
};
@@ -622,9 +614,8 @@ const Price = ({
value={selected?.metadata.address}
onValueChange={onChangeToken}
defaultValue={selected?.metadata.address}
- disabled={tokens.length <= 1}
>
-
+
{tokens.map((token) => (
({});
const [amount, setAmount] = useState(0);
- const arcadeContext = useContext(ArcadeContext);
- const provider = arcadeContext?.provider;
+ const { marketplaceAddress } = useArcadeContext();
const { toast } = useToast();
const { data: marketplaceFeeConfig } = useMarketplaceFees();
@@ -244,13 +243,6 @@ export function CollectiblePurchase() {
[setRoyalties],
);
- // Memoize marketplace address
- const marketplaceAddress = useMemo(() => {
- return provider?.manifest.contracts.find((c: { tag: string }) =>
- c.tag?.includes("Marketplace"),
- )?.address;
- }, [provider?.manifest.contracts]);
-
// Build transactions
const buildTransactions = useMemo(() => {
if (
diff --git a/packages/keychain/src/components/inventory/collection/collection-asset.tsx b/packages/keychain/src/components/inventory/collection/collection-asset.tsx
index 666eed7fb1..3dffdcf25e 100644
--- a/packages/keychain/src/components/inventory/collection/collection-asset.tsx
+++ b/packages/keychain/src/components/inventory/collection/collection-asset.tsx
@@ -365,7 +365,13 @@ export function CollectionAsset() {
);
}
-const Price = ({ amount, image }: { amount?: number; image?: string }) => {
+const Price = ({
+ amount,
+ image,
+}: {
+ amount?: number;
+ image?: string | React.ReactNode;
+}) => {
const containerRef = useRef(null);
const [width, setWidth] = useState(0);
diff --git a/packages/keychain/src/components/inventory/collection/collection-listing.tsx b/packages/keychain/src/components/inventory/collection/collection-listing.tsx
index 9ee93a594e..7e1fbb5e0f 100644
--- a/packages/keychain/src/components/inventory/collection/collection-listing.tsx
+++ b/packages/keychain/src/components/inventory/collection/collection-listing.tsx
@@ -23,7 +23,7 @@ import {
STRK_CONTRACT_ADDRESS,
USDC_CONTRACT_ADDRESS,
} from "@cartridge/controller-ui/utils";
-import { useCallback, useContext, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useSearchParams } from "react-router-dom";
import { useCollection } from "@/hooks/collection";
import placeholder from "/placeholder.svg?url";
@@ -31,7 +31,7 @@ import { ListHeader } from "./send/header";
import { useTokens } from "@/hooks/token";
import { AllowArray, cairo, Call, CallData, FeeEstimate } from "starknet";
import { useToast } from "@/context/toast";
-import { ArcadeContext } from "@/context/arcade";
+import { useArcadeContext } from "@/context/arcade";
import { useEntrypoints } from "@/hooks/entrypoints";
import { useConnection } from "@/hooks/connection";
import { useNavigation } from "@/context/navigation";
@@ -61,8 +61,7 @@ const EXPIRATIONS = [
];
export function CollectionListing() {
- const arcadeContext = useContext(ArcadeContext);
- const provider = arcadeContext?.provider;
+ const { marketplaceAddress } = useArcadeContext();
const { controller } = useConnection();
const { goBack } = useNavigation();
const { address: contractAddress, tokenId } = useParams();
@@ -172,13 +171,6 @@ export function CollectionListing() {
[setSelected],
);
- // Memoize marketplace address to prevent infinite useEffect loop
- const marketplaceAddress = useMemo(() => {
- return provider?.manifest.contracts.find((c: { tag: string }) =>
- c.tag?.includes("Marketplace"),
- )?.address;
- }, [provider?.manifest.contracts]);
-
// Build transactions when validation is complete
const buildTransactions = useMemo(() => {
if (
@@ -357,7 +349,7 @@ const ListingConfirmation = ({
}[];
currency: {
name: string;
- image: string;
+ image: string | React.ReactNode;
price: number;
value: string;
};
@@ -502,9 +494,8 @@ const Price = ({
value={selected?.metadata.address}
onValueChange={onChangeToken}
defaultValue={selected?.metadata.address}
- disabled={tokens.length <= 1}
>
-
+
{tokens.map((token) => (
({});
const [amount, setAmount] = useState(0);
- const arcadeContext = useContext(ArcadeContext);
- const provider = arcadeContext?.provider;
+ const { marketplaceAddress } = useArcadeContext();
const { toast } = useToast();
const [searchParams] = useSearchParams();
@@ -231,13 +230,6 @@ export function CollectionPurchase() {
[setRoyalties],
);
- // Memoize marketplace address
- const marketplaceAddress = useMemo(() => {
- return provider?.manifest.contracts.find((c: { tag: string }) =>
- c.tag?.includes("Marketplace"),
- )?.address;
- }, [provider?.manifest.contracts]);
-
// Build transactions
const buildTransactions = useMemo(() => {
if (
diff --git a/packages/keychain/src/components/inventory/token/token.tsx b/packages/keychain/src/components/inventory/token/token.tsx
index 856d7f3f91..2c8d94b10b 100644
--- a/packages/keychain/src/components/inventory/token/token.tsx
+++ b/packages/keychain/src/components/inventory/token/token.tsx
@@ -1,3 +1,4 @@
+import { useCallback, useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import {
LayoutContent,
@@ -7,10 +8,11 @@ import {
ERC20Detail,
ERC20Header,
PaperPlaneIcon,
- InfoIcon,
Skeleton,
Thumbnail,
useDisclosure,
+ UsdColorIcon,
+ ActivityTokenCardAction,
} from "@cartridge/controller-ui";
import { useData } from "@/hooks/data";
@@ -19,16 +21,22 @@ import {
isPublicChain,
useCreditBalance,
} from "@cartridge/controller-ui/utils";
+// import { useNavigation } from "@/context/navigation";
import { useExplorer } from "@starknet-react/core";
import { constants, getChecksumAddress } from "starknet";
import { useAccount, useUsernames } from "@/hooks/account";
+import { useCreditsHistory } from "@/hooks/credits-history";
+import {
+ CreditsHistoryTransactionType,
+ CreditsPaymentMethod,
+} from "@/utils/api";
import { useToken } from "@/hooks/token";
-import { useCallback, useMemo } from "react";
import { useConnection } from "@/hooks/connection";
import { useVersion } from "@/hooks/version";
-import { useNavigation } from "@/context/navigation";
import { EmptyState, LoadingState } from "@/components/activity";
import { SendTokenDrawer } from "./send/send-drawer";
+import { useCreditsContext } from "@/components/credits/provider";
+import { formatCredits } from "@/utils/credits";
export function Token() {
const { address } = useParams<{ address: string }>();
@@ -41,46 +49,131 @@ export function Token() {
}
}
+export const CREDITS_DESCRIPTION =
+ "USD Credits are an account balance that can be used to pay for games and network activity.";
+
+const PAYMENT_METHOD_LABELS: Record = {
+ [CreditsPaymentMethod.Card]: "Card",
+ [CreditsPaymentMethod.Crypto]: "Wallet",
+ [CreditsPaymentMethod.Free]: "Free",
+ [CreditsPaymentMethod.Credits]: "Credits",
+};
+
function Credits() {
// TODO: Get parent from keychain connection if needed
- const { navigate } = useNavigation();
+ // const { navigate } = useNavigation();
const account = useAccount();
const username = account?.username || "";
+ const { initiateCreditsDeposit } = useCreditsContext();
const credit = useCreditBalance({
username,
interval: 30000,
});
+ const { items: history, status: historyStatus } = useCreditsHistory();
+
+ const entries = useMemo<
+ {
+ id: string;
+ transactionHash?: string | null;
+ amount: string;
+ value: string;
+ action: ActivityTokenCardAction;
+ item: string;
+ date: string;
+ timestamp: number;
+ }[]
+ >(() => {
+ return history.map((item) => {
+ const timestamp = new Date(item.createdAt).getTime();
+ const amount = formatCredits(item.amount, 0);
+ const isDeposit =
+ item.transactionType === CreditsHistoryTransactionType.Credit;
+ return {
+ id: item.id,
+ transactionHash: item.transactionHash,
+ amount: `${amount.formatted} USD`,
+ value: `$${amount.usd.toFixed(2)}`,
+ action: isDeposit
+ ? "deposit"
+ : item.paymentMethod === CreditsPaymentMethod.Free
+ ? "claimed"
+ : "spend",
+ item: isDeposit
+ ? PAYMENT_METHOD_LABELS[item.paymentMethod]
+ : item.comment || "Network Fee",
+ date: getDate(timestamp),
+ timestamp,
+ };
+ });
+ }, [history]);
// Show loading state while credits are being fetched
if (credit.balance.value === undefined) {
return ;
}
+ const format = formatCredits(credit.balance.value);
+
return (
<>
-
-
{`${Number(credit.balance.value) / 10 ** 6} CREDITS`}
+
} size="lg" rounded />
+
+
{`${format.formatted} USD`}
+
{`$${format.usd.toFixed(2)}`}
+
-
-
-
- Credits are used to pay for network activity. They are not tokens
- and cannot be transferred or refunded.
-
+
+ {CREDITS_DESCRIPTION}
+
+ {historyStatus === "loading" ? (
+
+ ) : historyStatus === "error" || !entries.length ? (
+
+ ) : (
+
+ {Object.entries(
+ entries.reduce(
+ (acc, entry) => {
+ if (!acc[entry.date]) {
+ acc[entry.date] = [];
+ }
+ acc[entry.date].push(entry);
+ return acc;
+ },
+ {} as Record
,
+ ),
+ ).map(([date, items]) => (
+
+
+ {date}
+
+ {items.map((item) => (
+
}
+ action={item.action}
+ timestamp={item.timestamp}
+ />
+ ))}
+
+ ))}
+
+ )}
- {children}
+
+ {children}
+
>
);
};
diff --git a/packages/ui/src/components/primitives/toast/specialized-toasts.tsx b/packages/ui/src/components/primitives/toast/specialized-toasts.tsx
index b43abcc47e..92a6a899d8 100644
--- a/packages/ui/src/components/primitives/toast/specialized-toasts.tsx
+++ b/packages/ui/src/components/primitives/toast/specialized-toasts.tsx
@@ -199,7 +199,7 @@ interface MarketplaceToastProps extends Omit {
title: string;
collectionName: string;
itemNames: string[];
- itemImages: string[];
+ itemImages: (string | React.ReactNode)[];
progress?: number;
preset?: string;
}
diff --git a/packages/ui/src/components/primitives/toast/types.ts b/packages/ui/src/components/primitives/toast/types.ts
index 58ec891d1e..99bba538f0 100644
--- a/packages/ui/src/components/primitives/toast/types.ts
+++ b/packages/ui/src/components/primitives/toast/types.ts
@@ -66,7 +66,7 @@ export interface QuestToastOptions extends BaseToastOptions {
export interface MarketplaceToastOptions extends BaseToastOptions {
variant: "marketplace";
itemNames: string[];
- itemImages: string[];
+ itemImages: (string | React.ReactNode)[];
collectionName: string;
action: "purchased" | "sold" | "sent" | "listed" | "unlisted";
}
diff --git a/packages/ui/src/stories/marketplace-toast.stories.tsx b/packages/ui/src/stories/marketplace-toast.stories.tsx
index e6760d9ba8..e5fe8b81ad 100644
--- a/packages/ui/src/stories/marketplace-toast.stories.tsx
+++ b/packages/ui/src/stories/marketplace-toast.stories.tsx
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MarketplaceToast } from "@/components/primitives/toast/specialized-toasts";
+import { UsdColorIcon } from "@/components";
const meta: Meta = {
title: "Primitives/Toast/Marketplace Toast",
@@ -107,3 +108,12 @@ export const SentLords: Story = {
],
},
};
+
+export const DepositedCredits: Story = {
+ args: {
+ title: "Deposited",
+ collectionName: "USD",
+ itemNames: ["10.00 USD"],
+ itemImages: [],
+ },
+};
diff --git a/packages/ui/src/utils/api/cartridge/generated.ts b/packages/ui/src/utils/api/cartridge/generated.ts
index 35dbf71f52..2af07cb8b2 100644
--- a/packages/ui/src/utils/api/cartridge/generated.ts
+++ b/packages/ui/src/utils/api/cartridge/generated.ts
@@ -29,6 +29,13 @@ export type Account = Node & {
createdAt: Scalars['Time'];
credentials: Credentials;
credits: Credits;
+ /**
+ * This account's credit ledger — deposits (credits bought or granted) and spends
+ * (credits used) — most recent first by default. Private: only ever returns rows
+ * for the authenticated caller's own account; requesting it for any other account
+ * yields an empty connection.
+ */
+ creditsHistory: CreditsHistoryConnection;
creditsPlain: Scalars['Int'];
email?: Maybe;
id: Scalars['ID'];
@@ -74,6 +81,16 @@ export type AccountControllersArgs = {
};
+export type AccountCreditsHistoryArgs = {
+ after?: InputMaybe;
+ before?: InputMaybe;
+ first?: InputMaybe;
+ last?: InputMaybe;
+ orderBy?: InputMaybe;
+ where?: InputMaybe;
+};
+
+
export type AccountMembershipArgs = {
after?: InputMaybe;
before?: InputMaybe;
@@ -1024,6 +1041,20 @@ export type BroadcastNotificationInput = {
title: Scalars['String'];
};
+export type BundleCreditsQuote = {
+ __typename?: 'BundleCreditsQuote';
+ /** USDC cost in 6-decimal wei (includes swap slippage for non-USDC bundles). */
+ costInUsdc: Scalars['Long'];
+ /** True when the bundle is priced in a non-USDC token; the executor re-quotes the swap at execution time, so the final cost may drift slightly from this quote. */
+ needsSwap: Scalars['Boolean'];
+ /** The registry's payment token address — what the bundle is priced in. */
+ paymentToken: Scalars['String'];
+ /** Bundle price in the registry's payment token units. */
+ paymentTokenAmount: Scalars['Long'];
+ /** Credit units that will be debited (1 credit unit = 1e-8 USD; 100 credits = $1). */
+ requiredCredits: Scalars['Long'];
+};
+
export type CoinbaseAmount = {
__typename?: 'CoinbaseAmount';
/** The amount value as a string. */
@@ -1683,6 +1714,13 @@ export type CreditsHistory = Node & {
comment?: Maybe;
createdAt: Scalars['Time'];
id: Scalars['ID'];
+ /**
+ * How the credits in this entry moved: for deposits (transactionType CREDIT) it is
+ * how they were acquired — CARD, CRYPTO, or FREE; for spends (transactionType DEBIT)
+ * it is always CREDITS, debited from the balance. The reason for a spend (bundle
+ * purchase, transaction fee, team transfer) is carried by `comment`.
+ */
+ paymentMethod: CreditsPaymentMethod;
/** Transaction hash for debit transactions */
transactionHash?: Maybe;
/** Type of transaction: credit or debit */
@@ -1690,6 +1728,26 @@ export type CreditsHistory = Node & {
updatedAt: Scalars['Time'];
};
+/** A connection to a list of items. */
+export type CreditsHistoryConnection = {
+ __typename?: 'CreditsHistoryConnection';
+ /** A list of edges. */
+ edges?: Maybe>>;
+ /** Information to aid in pagination. */
+ pageInfo: PageInfo;
+ /** Identifies the total count of items in the connection. */
+ totalCount: Scalars['Int'];
+};
+
+/** An edge in a connection. */
+export type CreditsHistoryEdge = {
+ __typename?: 'CreditsHistoryEdge';
+ /** A cursor for use in pagination. */
+ cursor: Scalars['Cursor'];
+ /** The item at the end of the edge. */
+ node?: Maybe;
+};
+
/** Ordering options for CreditsHistory connections */
export type CreditsHistoryOrder = {
/** The ordering direction. */
@@ -1813,6 +1871,17 @@ export type CreditsInput = {
decimals: Scalars['Int'];
};
+export enum CreditsPaymentMethod {
+ /** Deposit paid by card (Stripe). */
+ Card = 'CARD',
+ /** Spend debited from the account's credit balance. */
+ Credits = 'CREDITS',
+ /** Deposit paid with crypto — onramp (Coinbase/Layerswap) or a direct transfer. */
+ Crypto = 'CRYPTO',
+ /** Deposit granted at no cost (e.g. a booster-pack claim). */
+ Free = 'FREE'
+}
+
export type CryptoPayment = {
__typename?: 'CryptoPayment';
depositAddress: Scalars['String'];
@@ -3189,6 +3258,16 @@ export type Mutation = {
finalizeLogin: Scalars['String'];
finalizeRegistration: Account;
increaseBudget: Paymaster;
+ /**
+ * Spend the authenticated account's off-chain credit balance to purchase a
+ * starterpack bundle. Mirrors createCoinflowStarterpackIntent's pricing and
+ * fulfillment creation, but replaces the external card payment with a synchronous,
+ * in-transaction credit debit: the bundle is created directly at QUEUED and issued
+ * by the same operator-paid on-chain flow as the card rails. The credits are debited
+ * upfront and refunded automatically if fulfillment terminally fails. Returns the
+ * PurchaseFulfillment for status polling.
+ */
+ purchaseBundleWithCredits: PurchaseFulfillment;
register: Account;
registerNotificationDevice: NotificationDevice;
removeAllPolicies: Scalars['Boolean'];
@@ -3452,6 +3531,11 @@ export type MutationIncreaseBudgetArgs = {
};
+export type MutationPurchaseBundleWithCreditsArgs = {
+ input: PurchaseBundleWithCreditsInput;
+};
+
+
export type MutationRegisterArgs = {
chainId: Scalars['String'];
owner: SignerInput;
@@ -4805,6 +4889,16 @@ export type Project = {
project: Scalars['String'];
};
+export type PurchaseBundleWithCreditsInput = {
+ clientPercentage?: InputMaybe;
+ isMainnet?: InputMaybe;
+ quantity: Scalars['Int'];
+ referral?: InputMaybe;
+ referralGroup?: InputMaybe;
+ registryAddress: Scalars['String'];
+ starterpackId: Scalars['String'];
+};
+
export type PurchaseFulfillment = {
__typename?: 'PurchaseFulfillment';
id: Scalars['ID'];
@@ -4832,6 +4926,13 @@ export type Query = {
activities: ActivityResult;
balance: Balance;
balances: BalanceConnection;
+ /**
+ * Quote a bundle purchase paid from the account's credit balance. Reuses the exact
+ * pricing path as purchaseBundleWithCredits, so the credit cost shown to the user
+ * matches what will be debited. Call this before purchasing to display the price — the
+ * bundle's payment token and amount, plus the credit cost — and to pre-check the balance.
+ */
+ bundleCreditsQuote: BundleCreditsQuote;
/**
* Get the authenticated user's current Coinbase onramp spending limits along
* with any available limits-upgrade option. Clients should only show the
@@ -4905,6 +5006,16 @@ export type Query = {
price: Array;
priceByAddresses: Array;
pricePeriodByAddresses: Array;
+ /**
+ * Fetch a purchase fulfillment by id for status polling (owner only). Every
+ * starterpack purchase — paid by card, crypto, or credits — creates a
+ * PurchaseFulfillment that is issued on-chain asynchronously; this lets the client
+ * re-fetch it to track status, tx hash, and any failure (refund) message until it
+ * reaches CONFIRMED/FAILED. Card and crypto purchases can also reach their
+ * fulfillment via stripePayment/coinflowPayment, but credits purchases have no
+ * payment row, so this is the only path for them.
+ */
+ purchaseFulfillment: PurchaseFulfillment;
rpcApiKeys?: Maybe;
rpcCorsDomains?: Maybe;
rpcLogs?: Maybe;
@@ -4966,6 +5077,11 @@ export type QueryBalancesArgs = {
};
+export type QueryBundleCreditsQuoteArgs = {
+ input: PurchaseBundleWithCreditsInput;
+};
+
+
export type QueryCoinbaseOnrampOrderArgs = {
orderId: Scalars['String'];
};
@@ -5217,6 +5333,11 @@ export type QueryPricePeriodByAddressesArgs = {
};
+export type QueryPurchaseFulfillmentArgs = {
+ id: Scalars['ID'];
+};
+
+
export type QueryRpcApiKeysArgs = {
after?: InputMaybe;
before?: InputMaybe;
diff --git a/packages/ui/src/utils/hooks/balance.ts b/packages/ui/src/utils/hooks/balance.ts
index 3c29c55316..90aac90902 100644
--- a/packages/ui/src/utils/hooks/balance.ts
+++ b/packages/ui/src/utils/hooks/balance.ts
@@ -166,6 +166,7 @@ export type FetchState = {
isFetching: boolean;
isLoading: boolean;
error: Error | null;
+ refetch: () => Promise;
};
export type UseCreditBalanceReturn = {
@@ -179,7 +180,7 @@ export function useCreditBalance({
username?: string;
interval: number | undefined;
}): UseCreditBalanceReturn {
- const { data, isFetching, isLoading, error } = useCreditQuery<
+ const { data, isFetching, isLoading, error, refetch } = useCreditQuery<
CreditQuery,
Error
>(
@@ -207,5 +208,6 @@ export function useCreditBalance({
isFetching,
isLoading,
error,
+ refetch,
};
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 871fe45c37..e6717a7ca2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,6 +15,9 @@ catalogs:
'@cartridge/penpal':
specifier: ^6.2.4
version: 6.2.4
+ '@cartridge/presets':
+ specifier: github:cartridge-gg/presets#1cb4177
+ version: 0.0.1
'@eslint/js':
specifier: ^9.18.0
version: 9.25.1
@@ -189,8 +192,8 @@ importers:
specifier: workspace:*
version: link:packages/ui
'@cartridge/presets':
- specifier: github:cartridge-gg/presets#e94909f
- version: https://codeload.github.com/cartridge-gg/presets/tar.gz/e94909f
+ specifier: 'catalog:'
+ version: https://codeload.github.com/cartridge-gg/presets/tar.gz/1cb4177
'@graphql-codegen/cli':
specifier: ^2.6.2
version: 2.16.5(@babel/core@7.27.1)(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@16.18.11)(bufferutil@4.0.9)(encoding@0.1.13)(graphql@16.12.0)(typescript@5.8.3)(utf-8-validate@5.0.10)
@@ -521,7 +524,7 @@ importers:
version: 5.14.0(rollup@4.40.2)
ts-jest:
specifier: ^29.2.5
- version: 29.3.2(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(esbuild@0.25.4)(jest@29.7.0(@types/node@18.19.87)(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@18.19.87)(typescript@5.8.3)))(typescript@5.8.3)
+ version: 29.3.2(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(jest@29.7.0(@types/node@18.19.87)(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@18.19.87)(typescript@5.8.3)))(typescript@5.8.3)
tsup:
specifier: 'catalog:'
version: 8.4.0(@microsoft/api-extractor@7.52.6(@types/node@18.19.87))(@swc/core@1.11.24(@swc/helpers@0.5.17))(jiti@1.21.7)(postcss@8.5.3)(tsx@4.19.4)(typescript@5.8.3)(yaml@2.7.1)
@@ -849,8 +852,8 @@ importers:
packages/ui:
dependencies:
'@cartridge/presets':
- specifier: github:cartridge-gg/presets#90a5fe0
- version: https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0
+ specifier: 'catalog:'
+ version: https://codeload.github.com/cartridge-gg/presets/tar.gz/1cb4177
'@radix-ui/react-accordion':
specifier: 1.2.1
version: 1.2.1(@types/react-dom@18.3.7(@types/react@18.3.20))(@types/react@18.3.20)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1595,12 +1598,8 @@ packages:
'@cartridge/penpal@6.2.4':
resolution: {integrity: sha512-tdpOnSJJBFMlgLZ1+z9Ho5e6cG5EgMAb1Cmmh1lGT2tmplogU/XPMjLE6CwvKAPDoe6a38iMnbH+ySTAWWIOKA==}
- '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0':
- resolution: {tarball: https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0}
- version: 0.0.1
-
- '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/e94909f':
- resolution: {tarball: https://codeload.github.com/cartridge-gg/presets/tar.gz/e94909f}
+ '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/1cb4177':
+ resolution: {tarball: https://codeload.github.com/cartridge-gg/presets/tar.gz/1cb4177}
version: 0.0.1
'@cbor-extract/cbor-extract-darwin-arm64@2.2.0':
@@ -11856,11 +11855,7 @@ snapshots:
'@cartridge/penpal@6.2.4': {}
- '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/90a5fe0':
- dependencies:
- '@starknet-io/types-js': 0.8.4
-
- '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/e94909f':
+ '@cartridge/presets@https://codeload.github.com/cartridge-gg/presets/tar.gz/1cb4177':
dependencies:
'@starknet-io/types-js': 0.8.4
@@ -18692,7 +18687,7 @@ snapshots:
'@typescript-eslint/parser': 8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
eslint: 9.25.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7)))(eslint@9.25.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@1.21.7))
eslint-plugin-import: 2.31.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.25.1(jiti@1.21.7))
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.25.1(jiti@1.21.7))
eslint-plugin-react: 7.37.5(eslint@9.25.1(jiti@1.21.7))
@@ -18716,7 +18711,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7)))(eslint@9.25.1(jiti@1.21.7)):
+ eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@1.21.7)):
dependencies:
'@nolyfill/is-core-module': 1.0.39
debug: 4.4.3(supports-color@5.5.0)
@@ -18731,14 +18726,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
- eslint-module-utils@2.12.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7)))(eslint@9.25.1(jiti@1.21.7)))(eslint@9.25.1(jiti@1.21.7)):
+ eslint-module-utils@2.12.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.25.1(jiti@1.21.7)):
dependencies:
debug: 3.2.7
optionalDependencies:
'@typescript-eslint/parser': 8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3)
eslint: 9.25.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7)))(eslint@9.25.1(jiti@1.21.7))
+ eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.31.0)(eslint@9.25.1(jiti@1.21.7))
transitivePeerDependencies:
- supports-color
@@ -18753,7 +18748,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.25.1(jiti@1.21.7)
eslint-import-resolver-node: 0.3.9
- eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.31.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint@9.25.1(jiti@1.21.7)))(eslint@9.25.1(jiti@1.21.7)))(eslint@9.25.1(jiti@1.21.7))
+ eslint-module-utils: 2.12.0(@typescript-eslint/parser@8.31.1(eslint@9.25.1(jiti@1.21.7))(typescript@5.8.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.25.1(jiti@1.21.7))
hasown: 2.0.2
is-core-module: 2.16.1
is-glob: 4.0.3
@@ -23181,7 +23176,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
- ts-jest@29.3.2(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(esbuild@0.25.4)(jest@29.7.0(@types/node@18.19.87)(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@18.19.87)(typescript@5.8.3)))(typescript@5.8.3):
+ ts-jest@29.3.2(@babel/core@7.27.1)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.27.1))(jest@29.7.0(@types/node@18.19.87)(ts-node@10.9.2(@swc/core@1.11.24(@swc/helpers@0.5.17))(@types/node@18.19.87)(typescript@5.8.3)))(typescript@5.8.3):
dependencies:
bs-logger: 0.2.6
ejs: 3.1.10
@@ -23200,7 +23195,6 @@ snapshots:
'@jest/transform': 29.7.0
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.27.1)
- esbuild: 0.25.4
ts-log@2.2.7: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index adb6ce5688..8f7ca79821 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -8,6 +8,7 @@ catalog:
"@cartridge/arcade": "0.3.12"
"@cartridge/controller-wasm": "0.10.1"
"@cartridge/penpal": "^6.2.4"
+ "@cartridge/presets": "github:cartridge-gg/presets#1cb4177"
"@eslint/js": "^9.18.0"
"@noble/curves": "^1.9.0"
"@noble/hashes": "^1.8.0"