diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 000000000..a2595e30c
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,131 @@
+# Credit Card Credits Flow Implementation Plan
+
+## Overview
+
+Route starter-pack credit-card purchases through the existing USD credits
+deposit flow instead of creating a direct Coinflow starter-pack checkout. After
+credits settle, resume the original bundle purchase automatically.
+
+## Goals
+
+- Always open the credits deposit flow when "Credit Card" is selected.
+- Default to the Credits rail whenever the existing balance covers the purchase.
+- Display the card-backed purchase amount in USD, not USDC.
+- Preserve automatic purchase completion after the deposited credits become
+ available.
+
+## Non-Goals
+
+- Change Apple Pay, wallet, or direct credits behavior.
+- Remove Coinflow support from the standalone credits deposit flow.
+
+## Assumptions and Constraints
+
+- The existing credits quote remains the authoritative bundle price.
+- Existing minimum and maximum credits purchase limits remain unchanged.
+- Coinflow sandbox deposits do not produce spendable credits and retain the
+ current warning/error behavior.
+
+## Requirements
+
+### Functional
+
+- Credit-card checkout starts a Coinflow-backed credits deposit.
+- The deposit amount covers the credits shortfall and respects the minimum
+ deposit.
+- Successful settlement purchases the requested bundle with credits.
+- The review total uses the USD pseudo-token and credits quote.
+
+### Non-Functional
+
+- Keep analytics method attribution as `coinflow` for card selection.
+- Reuse the existing stale-purchase and duplicate-completion guards.
+
+## Technical Design
+
+### Data Model
+
+No schema changes.
+
+### API Design
+
+No API changes. Reuse bundle credits quote, credits deposit, balance refresh,
+and credits purchase operations.
+
+### Architecture
+
+`Credit Card selection -> credits deposit drawer -> credits settlement -> bundle credits purchase -> success`
+
+### UX Flow
+
+The review screen shows USD. Continue opens the credits amount/deposit flow with
+Coinflow preferred; completion resumes the original purchase.
+
+---
+
+## Implementation Plan
+
+### Serial Dependencies (Must Complete First)
+
+#### Phase 0: Shared Calculation
+
+**Prerequisite for:** Checkout routing
+
+| Task | Description | Output |
+| ---- | ------------------------------------------------------------ | ---------------------------- |
+| 0.1 | Centralize shortfall-to-USD rounding and minimum enforcement | Tested credits top-up helper |
+
+---
+
+### Parallel Workstreams
+
+#### Workstream A: Checkout Routing
+
+**Dependencies:** Phase 0 **Can parallelize with:** Workstream B
+
+| Task | Description | Output |
+| ---- | ------------------------------------------------------------- | ------------------------ |
+| A.1 | Route card purchases into credits deposit and resume purchase | Updated checkout handler |
+
+#### Workstream B: USD Presentation
+
+**Dependencies:** Phase 0 **Can parallelize with:** Workstream A
+
+| Task | Description | Output |
+| ---- | ----------------------------------------------------------- | ---------------------- |
+| B.1 | Render card-backed review pricing with the USD pseudo-token | Updated cost breakdown |
+
+---
+
+### Merge Phase
+
+#### Phase 2: Integration
+
+**Dependencies:** Workstreams A, B
+
+| Task | Description | Output |
+| ---- | -------------------------------------------------------------------------------------- | -------------------- |
+| 2.1 | Remove direct starter-pack Coinflow drawer restrictions and validate the combined flow | Focused, linted diff |
+
+---
+
+## Testing and Validation
+
+- Unit-test top-up rounding, existing-balance subtraction, and minimum
+ enforcement.
+- Run keychain unit tests, lint/format checks, and TypeScript build.
+
+## Rollout and Migration
+
+No migration. Ship with the next controller/keychain release; rollback is a
+normal code revert.
+
+## Risks and Mitigations
+
+- Credits settlement delay: retain polling and stale-purchase guards.
+- Sandbox cannot mint spendable credits: retain the explicit sandbox failure
+ message.
+
+## Open Questions
+
+None; the Slack thread defines the required routing and currency behavior.
diff --git a/examples/next/src/components/providers/StarknetProvider.tsx b/examples/next/src/components/providers/StarknetProvider.tsx
index c26eb908c..5f820a0f3 100644
--- a/examples/next/src/components/providers/StarknetProvider.tsx
+++ b/examples/next/src/components/providers/StarknetProvider.tsx
@@ -169,29 +169,48 @@ const provider = jsonRpcProvider({
});
const getKeychainUrl = () => {
+ const configuredUrl = process.env.NEXT_PUBLIC_KEYCHAIN_FRAME_URL;
+
if (
process.env.NEXT_PUBLIC_VERCEL_ENV === "preview" &&
process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF
) {
- let branchName: string;
+ let branchName = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF.replace(
+ /[^a-zA-Z0-9-]/g,
+ "-",
+ );
+
+ // Some Vercel builds report the fallback branch name "update-ui". In
+ // that case, recover a short, unhashed branch name from the current URL.
+ if (branchName === "update-ui") {
+ const currentPreviewLabel = window.location.hostname.split(".")[0];
+
+ if (currentPreviewLabel.length === 63) {
+ return configuredUrl;
+ }
+
+ const match = window.location.href.match(/git-([a-zA-Z0-9-]+)\.preview/);
+
+ if (match && match[1]) {
+ branchName = match[1];
+ }
+ }
- const url = window.location.href;
- const match = url.match(/git-([a-zA-Z0-9-]+)\.preview/);
+ const keychainPreviewLabel = `keychain-git-${branchName}`;
- if (match && match[1]) {
- branchName = match[1];
- } else {
- branchName = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_REF.replace(
- /[^a-zA-Z0-9-]/g,
- "-",
- );
+ // Vercel truncates DNS labels longer than 63 characters and appends a
+ // project-specific hash. A hash copied from this app's alias does not
+ // resolve to the matching keychain deployment, so use the configured
+ // keychain instead of leaving the connector waiting on a 404 iframe.
+ if (keychainPreviewLabel.length > 63) {
+ return configuredUrl;
}
- const keychainUrl = `https://keychain-git-${branchName}.preview.cartridge.gg/`;
+ const keychainUrl = `https://${keychainPreviewLabel}.preview.cartridge.gg/`;
return keychainUrl;
} else {
- return process.env.NEXT_PUBLIC_KEYCHAIN_FRAME_URL;
+ return configuredUrl;
}
};
diff --git a/packages/keychain/src/components/purchase/checkout/onchain/index.tsx b/packages/keychain/src/components/purchase/checkout/onchain/index.tsx
index 6514bd169..094d59e1c 100644
--- a/packages/keychain/src/components/purchase/checkout/onchain/index.tsx
+++ b/packages/keychain/src/components/purchase/checkout/onchain/index.tsx
@@ -41,14 +41,15 @@ import {
type PaymentMethodSelection,
} from "./wallet-drawer";
import { SocialClaimCheckout } from "./social-claim";
-import { CoinflowDrawer } from "../coinflow/drawer";
import { CoinbaseDrawer } from "../coinbase/drawer";
import { VerificationDrawer } from "../../verification/drawer";
-import { USDC_ADDRESSES } from "@/utils/ekubo";
import { useGeoLocation } from "@/hooks/geo";
-import { num } from "starknet";
import { useIdentityContext } from "@/components/identity/provider";
import { useCreditsContext } from "@/components/credits/provider";
+import {
+ creditsTopupAmountUsd,
+ shouldOpenCreditsDeposit,
+} from "@/utils/credits-topup";
import {
MAX_CREDITS_PURCHASE_USD,
MIN_CREDITS_PURCHASE_USD,
@@ -121,8 +122,6 @@ export function OnchainCheckout() {
onCreditsSelect,
} = useOnchainPurchaseContext();
const {
- onCreditCardPurchase,
- isCoinflowLoading,
creditsQuote,
isCreditsQuoteLoading,
creditsQuoteError,
@@ -148,7 +147,6 @@ export function OnchainCheckout() {
const [isLoading, setIsLoading] = useState(false);
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
- const [isCoinflowDrawerOpen, setIsCoinflowDrawerOpen] = useState(false);
const [isCoinbaseDrawerOpen, setIsCoinbaseDrawerOpen] = useState(false);
const [verificationMethod, setVerificationMethod] = useState<
"apple-pay" | "identity" | null
@@ -249,17 +247,6 @@ export function OnchainCheckout() {
return quote ? quote.totalCost === BigInt(0) : undefined;
}, [quote]);
- const isCoinflowStarterpackSupported = useMemo(() => {
- if (!controller || !quote) {
- return true;
- }
-
- const usdcAddress = USDC_ADDRESSES[controller.chainId()];
- return (
- !!usdcAddress && num.toHex(quote.paymentToken) === num.toHex(usdcAddress)
- );
- }, [controller, quote]);
-
const {
balanceError,
bridgeFrom,
@@ -374,8 +361,7 @@ export function OnchainCheckout() {
credits: creditsResolution,
hasSufficientCredits,
cardTopupAvailable: configuredCoinflowAvailable,
- directCardAvailable:
- configuredCoinflowAvailable && isCoinflowStarterpackSupported,
+ directCardAvailable: configuredCoinflowAvailable,
});
if (resolution.status === "pending") return;
@@ -414,7 +400,6 @@ export function OnchainCheckout() {
tokenFundingStatus,
hasSufficientCredits,
configuredCoinflowAvailable,
- isCoinflowStarterpackSupported,
onCreditsSelect,
onCoinflowSelect,
clearSelectedWallet,
@@ -433,7 +418,7 @@ export function OnchainCheckout() {
const showConfiguredCreditsTopup = configuredCard && showInsufficientCredits;
const globalDisabled = useMemo(() => {
- if (isCreditsSelected) {
+ if (isCreditsSelected || isCoinflowSelected) {
return (
isCreditsQuoteLoading ||
!!creditsQuoteError ||
@@ -444,10 +429,6 @@ export function OnchainCheckout() {
);
}
- if (isCoinflowSelected) {
- return !isCoinflowStarterpackSupported || isCoinflowLoading;
- }
-
// Disable if there's a fee estimation error (e.g., bridge amount too low)
if (feeEstimationError) return true;
@@ -479,8 +460,6 @@ export function OnchainCheckout() {
isApplePaySelected,
isApplePayAmountTooLow,
isCoinflowSelected,
- isCoinflowStarterpackSupported,
- isCoinflowLoading,
isCreditsSelected,
isCreditsQuoteLoading,
isCreditsBalanceLoading,
@@ -615,23 +594,17 @@ export function OnchainCheckout() {
? "credits"
: "onchain";
- if (
- method === "coinflow" &&
- (!isUS || !isCoinflowEnabled || !isCoinflowStarterpackSupported)
- ) {
+ if (method === "coinflow" && (!isUS || !isCoinflowEnabled)) {
return;
}
- if (method === "credits" && !hasSufficientCredits) {
+ if (shouldOpenCreditsDeposit(method, hasSufficientCredits)) {
if (!creditsQuote || !purchaseKey) return;
const requiredCredits = BigInt(creditsQuote.requiredCredits);
- const shortfall =
- requiredCredits > creditsBalance
- ? requiredCredits - creditsBalance
- : 0n;
- // $1 = 1e8 raw credit units. Round upward to a cent so the top-up can
- // never land a fraction below the authoritative bundle quote.
- const shortfallUsd = Number((shortfall + 999_999n) / 1_000_000n) / 100;
- const topupAmount = Math.max(MIN_CREDITS_PURCHASE_USD, shortfallUsd);
+ const topupAmount = creditsTopupAmountUsd({
+ requiredCredits,
+ creditsBalance,
+ minimumAmount: MIN_CREDITS_PURCHASE_USD,
+ });
if (topupAmount > MAX_CREDITS_PURCHASE_USD) {
setDisplayError(
new Error(
@@ -644,9 +617,10 @@ export function OnchainCheckout() {
const originatingPurchaseKey = purchaseKey;
initiateCreditsDeposit({
- preferredMethod: configuredCoinflowAvailable
- ? { type: "coinflow" }
- : undefined,
+ preferredMethod:
+ method === "coinflow" || configuredCoinflowAvailable
+ ? { type: "coinflow" }
+ : undefined,
minimumAmount: topupAmount,
purchaseKey: originatingPurchaseKey,
onSuccess: async () => {
@@ -723,9 +697,6 @@ export function OnchainCheckout() {
if (method === "credits") {
await onCreditsPurchase();
navigate("/purchase/success", { reset: true });
- } else if (method === "coinflow") {
- await onCreditCardPurchase();
- setIsCoinflowDrawerOpen(true);
} else if (method === "apple-pay") {
resetCoinbasePurchase();
@@ -778,13 +749,11 @@ export function OnchainCheckout() {
isFree,
isCoinflowSelected,
isCoinflowEnabled,
- isCoinflowStarterpackSupported,
isUS,
isApplePaySelected,
applePayLimitExceeded,
fetchCoinbaseLimits,
resetCoinbasePurchase,
- onCreditCardPurchase,
isPhoneNumberVerified,
isEmailVerified,
onCreateCoinbaseOrder,
@@ -951,25 +920,18 @@ export function OnchainCheckout() {
/>
)}
- {isCoinflowSelected && !isCoinflowStarterpackSupported && (
-
- )}
-
- {isCreditsSelected && creditsQuoteError && (
-
- )}
+ {(isCreditsSelected || isCoinflowSelected) &&
+ creditsQuoteError && (
+
+ )}
{showInsufficientCredits && (
- setIsCoinflowDrawerOpen(false)}
- />
-
setIsCoinbaseDrawerOpen(false)}
diff --git a/packages/keychain/src/components/purchase/review/cost.tsx b/packages/keychain/src/components/purchase/review/cost.tsx
index 83d3c5342..02f749ec1 100644
--- a/packages/keychain/src/components/purchase/review/cost.tsx
+++ b/packages/keychain/src/components/purchase/review/cost.tsx
@@ -167,21 +167,8 @@ export function OnchainCostBreakdown({
coinbaseQuote,
isFetchingCoinbaseQuote,
} = useOnchainPurchaseContext();
- const {
- coinflowQuote,
- isCoinflowQuoteLoading,
- creditsQuote,
- isCreditsQuoteLoading,
- } = useCreditPurchaseContext();
+ const { creditsQuote, isCreditsQuoteLoading } = useCreditPurchaseContext();
const { decimals } = quote.paymentTokenMetadata;
- // When credit card is selected, use the Coinflow backend quote so that
- // pricing is correct even for non-USDC starterpacks (handles Ekubo swap).
- const coinflowCostDetails = useMemo(() => {
- if (!isCoinflowSelected) {
- return undefined;
- }
- return coinflowQuote?.pricing;
- }, [isCoinflowSelected, coinflowQuote]);
// Get default token (matching quote if available) or fallback to the first available token
const defaultToken =
@@ -190,7 +177,9 @@ export function OnchainCostBreakdown({
) || availableTokens[0];
// Use selectedToken or fallback to defaultToken for display
- const displayToken = selectedToken || defaultToken;
+ const displayToken = isCoinflowSelected
+ ? CREDITS_TOKEN
+ : selectedToken || defaultToken;
// Auto-select defaultToken if none is selected (for initial load)
useEffect(() => {
@@ -286,65 +275,58 @@ export function OnchainCostBreakdown({
{`($${totalUsd.toFixed(2)})`}
);
- const value = isCreditsSelected ? (
- isCreditsQuoteLoading ? (
-
- ) : creditsQuote ? (
-
- {formatCredits(creditsQuote.requiredCredits).formatted}
-
- ) : (
- —
- )
- ) : isCoinflowSelected ? (
- isCoinflowQuoteLoading ? (
-
- ) : coinflowCostDetails ? (
-
- {formatAmount(coinflowCostDetails.totalInCents / 100)}
-
- ) : (
- —
- )
- ) : isApplePaySelected ? (
- coinbaseQuote ? (
-
- {`$${Number(coinbaseQuote.paymentTotal.amount).toFixed(2)}`}
-
- ) : (
- —
- )
- ) : isUsingLayerswap ? (
- feeEstimationError ? (
- —
- ) : layerswapTotal !== null && displayToken ? (
-
- {formatAmount(layerswapTotal)}
-
- ) : (
-
- )
- ) : isPaymentTokenSameAsSelected ? (
- <>
- {usdEquivalent}
- {formatAmount(paymentAmount)}
- >
- ) : (
- convertedEquivalent !== null &&
- displayToken && (
+ const value =
+ isCreditsSelected || isCoinflowSelected ? (
+ isCreditsQuoteLoading ? (
+
+ ) : creditsQuote ? (
+
+ {formatCredits(creditsQuote.requiredCredits).formatted}
+
+ ) : (
+ —
+ )
+ ) : isApplePaySelected ? (
+ coinbaseQuote ? (
+
+ {`$${Number(coinbaseQuote.paymentTotal.amount).toFixed(2)}`}
+
+ ) : (
+ —
+ )
+ ) : isUsingLayerswap ? (
+ feeEstimationError ? (
+ —
+ ) : layerswapTotal !== null && displayToken ? (
+
+ {formatAmount(layerswapTotal)}
+
+ ) : (
+
+ )
+ ) : isPaymentTokenSameAsSelected ? (
<>
{usdEquivalent}
-
- {formatAmount(convertedEquivalent)}
+
+ {formatAmount(paymentAmount)}
>
- )
- );
+ ) : (
+ convertedEquivalent !== null &&
+ displayToken && (
+ <>
+ {usdEquivalent}
+
+ {formatAmount(convertedEquivalent)}
+
+ >
+ )
+ );
return (
}
/>
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 d385c01d9..258f9ed33 100644
--- a/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx
+++ b/packages/keychain/src/components/purchase/review/onchain-cost.stories.tsx
@@ -30,9 +30,11 @@ const mockUsdcToken = {
const MockOnchainPurchaseProvider = ({
children,
overrides,
+ creditOverrides,
}: {
children: ReactNode;
overrides?: Partial;
+ creditOverrides?: Partial;
}) => {
const mockValue: OnchainPurchaseContextType = {
purchaseItems: [],
@@ -122,7 +124,9 @@ const MockOnchainPurchaseProvider = ({
return (
-
+
{children}
@@ -133,7 +137,10 @@ const meta = {
component: OnchainCostBreakdown,
decorators: [
(Story, { parameters }) => (
-
+
),
@@ -189,6 +196,37 @@ export const USDCPaymentWithUsdPrefix: Story = {
},
};
+export const CreditCardPurchase: Story = {
+ parameters: {
+ mockOverrides: {
+ isCoinflowSelected: true,
+ } satisfies Partial,
+ mockCreditOverrides: {
+ creditsQuote: {
+ requiredCredits: "200000000",
+ costInUsdc: "2000000",
+ paymentToken: USDC_ADDRESS,
+ paymentTokenAmount: "2000000",
+ needsSwap: false,
+ },
+ } satisfies Partial,
+ },
+ args: {
+ quote: {
+ basePrice: 2000000n,
+ protocolFee: 0n,
+ referralFee: 0n,
+ totalCost: 2000000n,
+ paymentToken: USDC_ADDRESS,
+ paymentTokenMetadata: {
+ symbol: "USDC",
+ decimals: 6,
+ },
+ },
+ platform: "starknet",
+ },
+};
+
// ETH payment example (18 decimals)
export const ETHPayment: Story = {
args: {
diff --git a/packages/keychain/src/utils/credits-topup.ts b/packages/keychain/src/utils/credits-topup.ts
new file mode 100644
index 000000000..81c638700
--- /dev/null
+++ b/packages/keychain/src/utils/credits-topup.ts
@@ -0,0 +1,32 @@
+export type CreditsBackedPaymentMethod =
+ | "coinflow"
+ | "credits"
+ | "apple-pay"
+ | "onchain";
+
+/** Card purchases always deposit credits; credits purchases only top up a shortfall. */
+export function shouldOpenCreditsDeposit(
+ method: CreditsBackedPaymentMethod,
+ hasSufficientCredits: boolean,
+): boolean {
+ return (
+ method === "coinflow" || (method === "credits" && !hasSufficientCredits)
+ );
+}
+
+/** Calculate the USD deposit needed to cover a raw credit-unit shortfall. */
+export function creditsTopupAmountUsd({
+ requiredCredits,
+ creditsBalance,
+ minimumAmount,
+}: {
+ requiredCredits: bigint;
+ creditsBalance: bigint;
+ minimumAmount: number;
+}): number {
+ const shortfall =
+ requiredCredits > creditsBalance ? requiredCredits - creditsBalance : 0n;
+ // Round upward to a cent so the deposit cannot land below the quote.
+ const shortfallUsd = Number((shortfall + 999_999n) / 1_000_000n) / 100;
+ return Math.max(minimumAmount, shortfallUsd);
+}
diff --git a/packages/keychain/src/utils/credits.test.ts b/packages/keychain/src/utils/credits.test.ts
new file mode 100644
index 000000000..ffbd16bb3
--- /dev/null
+++ b/packages/keychain/src/utils/credits.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import {
+ creditsTopupAmountUsd,
+ shouldOpenCreditsDeposit,
+} from "./credits-topup";
+
+describe("shouldOpenCreditsDeposit", () => {
+ it("always routes card purchases through credits deposit", () => {
+ expect(shouldOpenCreditsDeposit("coinflow", false)).toBe(true);
+ expect(shouldOpenCreditsDeposit("coinflow", true)).toBe(true);
+ });
+
+ it("only tops up direct credits purchases when their balance is short", () => {
+ expect(shouldOpenCreditsDeposit("credits", false)).toBe(true);
+ expect(shouldOpenCreditsDeposit("credits", true)).toBe(false);
+ expect(shouldOpenCreditsDeposit("onchain", false)).toBe(false);
+ });
+});
+
+describe("creditsTopupAmountUsd", () => {
+ it("subtracts the existing balance and rounds the shortfall up to a cent", () => {
+ expect(
+ creditsTopupAmountUsd({
+ requiredCredits: 1_000_000_001n,
+ creditsBalance: 450_000_000n,
+ minimumAmount: 5,
+ }),
+ ).toBe(5.51);
+ });
+
+ it("enforces the deposit minimum when the shortfall is smaller", () => {
+ expect(
+ creditsTopupAmountUsd({
+ requiredCredits: 200_000_000n,
+ creditsBalance: 0n,
+ minimumAmount: 5,
+ }),
+ ).toBe(5);
+ });
+
+ it("still returns the minimum when the account already has enough credits", () => {
+ expect(
+ creditsTopupAmountUsd({
+ requiredCredits: 200_000_000n,
+ creditsBalance: 300_000_000n,
+ minimumAmount: 5,
+ }),
+ ).toBe(5);
+ });
+});
diff --git a/packages/keychain/src/utils/payment-preference.test.ts b/packages/keychain/src/utils/payment-preference.test.ts
index 8f7682882..e5c20529b 100644
--- a/packages/keychain/src/utils/payment-preference.test.ts
+++ b/packages/keychain/src/utils/payment-preference.test.ts
@@ -60,7 +60,7 @@ describe("payment preference", () => {
).toBeUndefined();
});
- it("keeps funded Controller ahead of the configured default", () => {
+ it("defaults to credits when the balance covers the purchase", () => {
expect(
resolveInitialPaymentMethod({
configuredDefault: true,
@@ -70,7 +70,7 @@ describe("payment preference", () => {
cardTopupAvailable: true,
directCardAvailable: true,
}),
- ).toEqual({ status: "resolved", method: "controller" });
+ ).toEqual({ status: "resolved", method: "credits" });
});
it("preserves legacy Controller behavior when no default is configured", () => {
@@ -78,7 +78,7 @@ describe("payment preference", () => {
resolveInitialPaymentMethod({
configuredDefault: false,
funding: "pending",
- credits: "pending",
+ credits: "unavailable",
hasSufficientCredits: false,
cardTopupAvailable: false,
directCardAvailable: false,
@@ -86,6 +86,34 @@ describe("payment preference", () => {
).toEqual({ status: "resolved", method: "controller" });
});
+ it("waits for credits before applying another default", () => {
+ expect(
+ resolveInitialPaymentMethod({
+ remembered: "coinflow",
+ configuredDefault: true,
+ funding: "funded",
+ credits: "pending",
+ hasSufficientCredits: false,
+ cardTopupAvailable: true,
+ directCardAvailable: true,
+ }),
+ ).toEqual({ status: "pending" });
+ });
+
+ it("prefers sufficient credits over a remembered card preference", () => {
+ expect(
+ resolveInitialPaymentMethod({
+ remembered: "coinflow",
+ configuredDefault: true,
+ funding: "funded",
+ credits: "available",
+ hasSufficientCredits: true,
+ cardTopupAvailable: true,
+ directCardAvailable: true,
+ }),
+ ).toEqual({ status: "resolved", method: "credits" });
+ });
+
it("selects credits only after Controller funding is exhausted", () => {
expect(
resolveInitialPaymentMethod({
diff --git a/packages/keychain/src/utils/payment-preference.ts b/packages/keychain/src/utils/payment-preference.ts
index 1205e0b47..0949ea680 100644
--- a/packages/keychain/src/utils/payment-preference.ts
+++ b/packages/keychain/src/utils/payment-preference.ts
@@ -107,6 +107,14 @@ export function resolveInitialPaymentMethod({
cardTopupAvailable: boolean;
directCardAvailable: boolean;
}): InitialPaymentResolution {
+ // Spend an existing credits balance before applying remembered or configured
+ // funding preferences. Wait for the quote and balance so a faster funding
+ // check cannot incorrectly select Controller or card checkout first.
+ if (credits === "pending") return { status: "pending" };
+ if (credits === "available" && hasSufficientCredits) {
+ return { status: "resolved", method: "credits" };
+ }
+
if (remembered === "controller") {
return { status: "resolved", method: "controller" };
}
@@ -122,11 +130,7 @@ export function resolveInitialPaymentMethod({
}
if (remembered === "credits") {
- if (credits === "pending") return { status: "pending" };
- if (
- credits === "available" &&
- (hasSufficientCredits || cardTopupAvailable)
- ) {
+ if (credits === "available" && cardTopupAvailable) {
return { status: "resolved", method: "credits" };
}
return {
@@ -144,8 +148,7 @@ export function resolveInitialPaymentMethod({
if (funding === "funded") {
return { status: "resolved", method: "controller" };
}
- if (credits === "pending") return { status: "pending" };
- if (credits === "available" && (hasSufficientCredits || cardTopupAvailable)) {
+ if (credits === "available" && cardTopupAvailable) {
return { status: "resolved", method: "credits" };
}
return {