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