diff --git a/apps/ai-credits-web/src/App.tsx b/apps/ai-credits-web/src/App.tsx
index 6b49a6eb..ff52950e 100644
--- a/apps/ai-credits-web/src/App.tsx
+++ b/apps/ai-credits-web/src/App.tsx
@@ -730,10 +730,36 @@ http://localhost:8377/v1/responses`}
)
}
+// Widget-only view for partner integrations (e.g. AntSeed) that embed the purchase flow
+// directly without the marketing landing page around it.
+function PurchaseOnlyView() {
+ return (
+
+
+
+ )
+}
+
+function readSourceParam(): string | null {
+ if (typeof window === 'undefined') return null
+ return new URLSearchParams(window.location.search).get('source')
+}
+
export function App() {
+ const isWidgetOnlySource = readSourceParam() === 'antseed'
+
return (
-
+ {isWidgetOnlySource ? : }
)
}
diff --git a/apps/ai-credits-web/tests/purchase-only.spec.ts b/apps/ai-credits-web/tests/purchase-only.spec.ts
new file mode 100644
index 00000000..8267ae86
--- /dev/null
+++ b/apps/ai-credits-web/tests/purchase-only.spec.ts
@@ -0,0 +1,34 @@
+import { expect, test } from '@playwright/test'
+
+const deepLinkQuery =
+ '&buyerAddress=0x1111111111111111111111111111111111111111' +
+ `&operatorSignature=0x${'ab'.repeat(64)}`
+
+test('source=antseed renders only the purchase widget, without landing-page sections', async ({
+ page,
+}) => {
+ await page.goto('/?source=antseed')
+
+ await expect(page.getByTestId('ai-credits-purchase-only')).toBeVisible()
+ await expect(page.getByTestId('purchase-frame')).toBeVisible()
+ await expect(page.getByTestId('ai-credits-landing-page')).toHaveCount(0)
+ await expect(page.getByTestId('benefits-strip')).toHaveCount(0)
+ await expect(page.getByTestId('agent-skills')).toHaveCount(0)
+})
+
+test('omitting source keeps the full landing page unchanged', async ({ page }) => {
+ await page.goto('/')
+
+ await expect(page.getByTestId('ai-credits-landing-page')).toBeVisible()
+ await expect(page.getByTestId('ai-credits-purchase-only')).toHaveCount(0)
+})
+
+test('source=antseed composes with buyerAddress and operatorSignature deep-link params', async ({
+ page,
+}) => {
+ await page.goto(`/?source=antseed${deepLinkQuery}`)
+
+ await expect(page.getByTestId('ai-credits-purchase-only')).toBeVisible()
+ await expect(page.getByTestId('purchase-frame')).toBeVisible()
+ await expect(page.getByTestId('ai-credits-landing-page')).toHaveCount(0)
+})
diff --git a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx
index 7b5bf8e3..45a08e8c 100644
--- a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx
+++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx
@@ -19,6 +19,7 @@ import {
AppKitConnectWalletStory,
MultiBuyerManageStory,
DeepLinkBuyerStory,
+ DeepLinkConsentPendingStory,
MultiBuyerHistoryStory,
} from '../helpers/aiCreditsWidgetStories'
@@ -104,6 +105,11 @@ export const DeepLinkBuyer: Story = {
render: () => ,
}
+/** Deep-link buyer reaching the buy-flow consent gate: signature prefilled, not yet consented. */
+export const DeepLinkConsentPending: Story = {
+ render: () => ,
+}
+
/** History tab with buyer filter dropdown. */
export const MultiBuyerHistory: Story = {
render: () => ,
diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
index b3040a7b..807b7806 100644
--- a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
+++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
@@ -481,6 +481,27 @@ export function DeepLinkBuyerStory() {
)
}
+/**
+ * Deep-link partner buyer reaching the buy-flow consent step: a pre-signed
+ * operatorSignature is prefilled but operatorConsented is still false, so the
+ * explicit "Sign Operator Consent" gate must render instead of auto-advancing.
+ */
+export function DeepLinkConsentPendingStory() {
+ return (
+
+ )
+}
+
/** History tab with multi-buyer filter options available. */
export function MultiBuyerHistoryStory() {
return (
diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts
index e118419d..1bbb7af0 100644
--- a/packages/ai-credits-widget/src/adapter.ts
+++ b/packages/ai-credits-widget/src/adapter.ts
@@ -916,6 +916,10 @@ export function useAiCreditsAdapter({
return
}
+ // Pre-fill the buyer identity and the pending signature only. Consent must never be
+ // submitted here — it is only ever submitted from handleSignOperatorConsent, in
+ // response to an explicit user click on OperatorConsentStep. A deep-link-supplied
+ // signature is not itself user approval; it just saves the user from re-signing.
storeDeepLinkParams({
buyerAddress: trimmedAddress,
operatorSignature: trimmedSignature,
@@ -939,85 +943,12 @@ export function useAiCreditsAdapter({
totalGdDepositedG: null,
monthlyStreamG: null,
activeTab: 'buy',
- operatorConsentPending: true,
+ operatorConsentPending: false,
error: null,
}),
)
-
- const ref: AccountRef = { payer: address, buyer: trimmedAddress }
-
- try {
- const operatorStatus = await chainClient.getBuyerOperatorStatus(ref)
-
- if (!operatorStatus.enabled) {
- throw new Error('Operator consent is not available for this deep-link buyer')
- }
-
- if (!operatorStatus.operatorAccepted) {
- await backendClient.submitOperatorConsent(ref.buyer, {
- nonce: operatorStatus.consentNonce,
- signature: trimmedSignature,
- })
- await waitForOperatorConsent(chainClient, ref)
- }
-
- setBuyerOperatorConsented(address, trimmedAddress, true)
- const buyerList = resolveBuyerList(address, trimmedAddress)
- let accountPatch: Partial = {}
- try {
- const view = await buildAccountView(address, backendClient, chainClient, {
- buyerAddress: trimmedAddress,
- })
- const enriched = await enrichAccountView(view, chainClient)
- accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, {
- balanceMode: 'always',
- })
- if (accountPatch.operatorConsented !== undefined) {
- setBuyerOperatorConsented(address, trimmedAddress, accountPatch.operatorConsented)
- }
- } catch {
- accountPatch = {}
- }
- const nextBuyerFields = buildBuyerStateFields(
- address,
- buyerList.buyers,
- buyerList.selected,
- )
- setState((prev) =>
- withDerivedStatus(
- prev,
- {
- ...accountPatch,
- ...nextBuyerFields,
- operatorConsented: true,
- operatorConsentPending: false,
- activeTab: 'buy',
- error: null,
- },
- true,
- ),
- )
- clearDeepLinkArtifacts()
- } catch (err: unknown) {
- setState((prev) =>
- withDerivedStatus(
- prev,
- {
- error: deepLinkManualFallbackMessage(
- err instanceof Error
- ? err.message
- : 'Could not apply deep-link operator approval.',
- ),
- activeTab: 'buy',
- status: 'purchase_setup',
- operatorConsentPending: false,
- },
- true,
- ),
- )
- }
},
- [address, backendClient, chainClient, resolveBuyerList],
+ [address],
)
const handleSignOperatorConsent = useCallback(async () => {
@@ -1083,6 +1014,7 @@ export function useAiCreditsAdapter({
...(!onNonBuyTab ? { status: 'purchase_setup' } : {}),
}),
)
+ clearDeepLinkArtifacts()
return
}
@@ -1126,6 +1058,7 @@ export function useAiCreditsAdapter({
...(!onNonBuyTab ? { status: 'purchase_setup' } : {}),
}),
)
+ clearDeepLinkArtifacts()
} catch (err: unknown) {
setState((prev) => ({
...prev,
diff --git a/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx b/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx
index bfde0344..573634f9 100644
--- a/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx
+++ b/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx
@@ -28,11 +28,12 @@ export function OperatorConsentStep({
return (
- Authorize AntSeed Operator
+ Authorize Operator
- Your buyer key signs an EIP-712 SetOperator message. The backend submits it to
- AntseedDeposits so the funding vault can act as your operator. No gas is required from
- you.
+ Granting consent gives the operator control of your signer funds. This is required to
+ prevent fraud in bonus distribution. You can revoke consent at any time, but revoking
+ makes you ineligible for future bonuses and removes any existing bonuses from your
+ account.
{buyerPubKey && (
diff --git a/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx b/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx
index 7ba2220a..afb7ccf1 100644
--- a/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx
+++ b/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx
@@ -29,7 +29,13 @@ export function AiCreditsPurchaseFlow({
const [buyerPubKeySaved, setBuyerPubKeySaved] = useState(false)
const activeStep = getAiCreditsActiveFlowStep(state, buyerPubKeySaved)
const [drawerOpen, setDrawerOpen] = useState(false)
- const [drawerStep, setDrawerStep] = useState(activeStep)
+ // Starts unset (not `activeStep`): the Drawer stays closed until the user opens it,
+ // and Tamagui's Sheet keeps its Frame mounted (off-screen, not removed) while closed.
+ // Eagerly setting this to the current step would mount that step's interactive content
+ // (e.g. the consent panel's "Sign Operator Consent" button) into the DOM before the
+ // Drawer is ever opened, duplicating the visible trigger button below with an
+ // identically-named, off-screen element.
+ const [drawerStep, setDrawerStep] = useState(null)
const prevActiveStepRef = useRef(null)
const goodIdTabPendingRef = useRef(false)
@@ -53,11 +59,13 @@ export function AiCreditsPurchaseFlow({
const previousStep = prevActiveStepRef.current
prevActiveStepRef.current = activeStep
- setDrawerStep(activeStep)
- if (previousStep == null) {
- setDrawerOpen(false)
- } else if (previousStep !== activeStep) {
+ // Only follow the flow into the drawer when it advances past a step the user has
+ // already reached (e.g. buyer_key -> consent). On the very first step of a session
+ // (previousStep is null) we leave drawerStep unset so nothing renders into the
+ // closed Drawer -- the user reveals it explicitly via the trigger button/stepper.
+ if (previousStep != null && previousStep !== activeStep) {
+ setDrawerStep(activeStep)
setDrawerOpen(true)
}
}, [activeStep])
diff --git a/tests/widgets/ai-credits-widget/states.spec.ts b/tests/widgets/ai-credits-widget/states.spec.ts
index 6e6f3de5..d56a984c 100644
--- a/tests/widgets/ai-credits-widget/states.spec.ts
+++ b/tests/widgets/ai-credits-widget/states.spec.ts
@@ -67,6 +67,9 @@ test('AiCreditsWidget purchase_setup', async ({ page }) => {
test('AiCreditsWidget quote_ready', async ({ page }) => {
await gotoStory(page, STORY_IDS.quoteReady)
await expect(page.getByTestId('AiCreditsWidget-quote-ready')).toBeVisible()
+ // The pay step's content (and its "Buy AI Credits" submit button) only mounts once the
+ // drawer is opened; open it via the outer trigger before asserting on drawer content.
+ await page.getByRole('button', { name: 'Set Amounts & Pay' }).click()
await expect(page.getByRole('button', { name: 'Buy AI Credits' })).toBeVisible()
await page.screenshot({
path: 'tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png',
@@ -246,6 +249,8 @@ const MULTI_BUYER_STORY_IDS = {
'/iframe.html?id=qa-aicreditswidget-runtime-fixtures--multi-buyer-manage&viewMode=story',
deepLinkBuyer:
'/iframe.html?id=qa-aicreditswidget-runtime-fixtures--deep-link-buyer&viewMode=story',
+ deepLinkConsentPending:
+ '/iframe.html?id=qa-aicreditswidget-runtime-fixtures--deep-link-consent-pending&viewMode=story',
multiBuyerHistory:
'/iframe.html?id=qa-aicreditswidget-runtime-fixtures--multi-buyer-history&viewMode=story',
} as const
@@ -282,6 +287,38 @@ test('AiCreditsWidget deep-link buyer: Sign Consent enabled via operatorSignatur
})
})
+test('AiCreditsWidget deep-link consent pending: OperatorConsentStep requires an explicit click', async ({
+ page,
+}) => {
+ await gotoStory(page, MULTI_BUYER_STORY_IDS.deepLinkConsentPending)
+ const root = widget(page, 'AiCreditsWidget-deep-link-consent-pending')
+ await expect(root).toBeVisible()
+
+ // A pre-filled operatorSignature must never auto-advance past consent: the explicit
+ // "Sign Operator Consent" gate has to render before any consent is granted.
+ const openConsentStepButton = root.getByRole('button', { name: 'Sign Operator Consent' })
+ await expect(openConsentStepButton).toBeVisible()
+ await openConsentStepButton.click()
+
+ // The Drawer renders via a Tamagui Sheet portal outside the widget's root DOM
+ // subtree, so its content must be queried at the page level, not scoped to `root`.
+ await expect(
+ page.getByText(/Granting consent gives the operator control of your signer funds/i),
+ ).toBeVisible()
+ await expect(
+ page.getByText(/ineligible for future bonuses and removes any existing bonuses/i),
+ ).toBeVisible()
+ await expect(page.getByText('Operator consent accepted')).not.toBeVisible()
+
+ const signConsentButton = page.getByRole('button', { name: 'Sign Operator Consent' })
+ await expect(signConsentButton).toBeEnabled()
+
+ await page.screenshot({
+ path: 'tests/widgets/ai-credits-widget/test-results/acw-19-deep-link-consent-pending.png',
+ fullPage: true,
+ })
+})
+
test('AiCreditsWidget multi-buyer history: buyer filter dropdown is visible', async ({ page }) => {
await gotoStory(page, MULTI_BUYER_STORY_IDS.multiBuyerHistory)
const root = widget(page, 'AiCreditsWidget-multi-buyer-history')
diff --git a/tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png b/tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png
index 1d4bac60..97ff1dd1 100644
Binary files a/tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png and b/tests/widgets/ai-credits-widget/test-results/acw-03-quote-ready.png differ
diff --git a/tests/widgets/ai-credits-widget/test-results/acw-19-deep-link-consent-pending.png b/tests/widgets/ai-credits-widget/test-results/acw-19-deep-link-consent-pending.png
new file mode 100644
index 00000000..17ed5060
Binary files /dev/null and b/tests/widgets/ai-credits-widget/test-results/acw-19-deep-link-consent-pending.png differ