From 90b9b351075a6c6fd8638352245bf7a1f0aad222 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:49:11 +0000 Subject: [PATCH 01/30] Initial plan From 804b486e91f9aa497864a3aa07ff91cc14cb9b7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:05:46 +0000 Subject: [PATCH 02/30] feat: add @goodwidget/ai-credits-widget package - widgetRuntimeContract.ts: 13 states, all action types, adapter factory pattern - mockBackendClient.ts: interface + mock + production client, createBackendClient factory - adapter.ts: useAiCreditsAdapter hook with full state machine (EIP-712 consent, buyer key gen, Celo payment) - aiCreditsComponents.tsx: all widget-specific components (AiCreditsHero, BuyerKeyPanel, OperatorConsentStep, AmountPicker, CreditsBalance, SetupSnippet, UsageLog, AiCreditsFlowStepper) - AiCreditsWidget.tsx: main widget with GoodWidgetProvider wrapper and state-driven render - integration.ts: manifest with Celo+Base chain IDs and all 13 states - element.ts + register.ts: web component bridge - index.ts: public exports - Storybook stories: QA + Showcase stories for all 13 states - aiCreditsWidgetStories.tsx: mock state factory and story helpers - Playwright smoke tests: states.spec.ts covering all states - pnpm build passes, pnpm lint passes (0 errors) --- examples/storybook/package.json | 1 + .../ai-credits-widget/AiCreditsWidget.mdx | 60 ++ .../AiCreditsWidgetQA.stories.tsx | 92 +++ .../AiCreditsWidgetShowcase.stories.tsx | 18 + .../helpers/aiCreditsWidgetStories.tsx | 368 +++++++++ packages/ai-credits-widget/package.json | 50 ++ .../ai-credits-widget/src/AiCreditsWidget.tsx | 454 +++++++++++ packages/ai-credits-widget/src/adapter.ts | 710 +++++++++++++++++ .../src/aiCreditsComponents.tsx | 716 ++++++++++++++++++ packages/ai-credits-widget/src/element.ts | 27 + packages/ai-credits-widget/src/index.ts | 31 + packages/ai-credits-widget/src/integration.ts | 25 + .../src/mockBackendClient.ts | 217 ++++++ packages/ai-credits-widget/src/register.ts | 27 + .../src/widgetRuntimeContract.ts | 213 ++++++ .../ai-credits-widget/tsconfig.build.json | 11 + packages/ai-credits-widget/tsconfig.json | 14 + packages/ai-credits-widget/tsup.config.ts | 15 + pnpm-lock.yaml | 37 + .../widgets/ai-credits-widget/states.spec.ts | 172 +++++ 20 files changed, 3258 insertions(+) create mode 100644 examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx create mode 100644 examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx create mode 100644 examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetShowcase.stories.tsx create mode 100644 examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx create mode 100644 packages/ai-credits-widget/package.json create mode 100644 packages/ai-credits-widget/src/AiCreditsWidget.tsx create mode 100644 packages/ai-credits-widget/src/adapter.ts create mode 100644 packages/ai-credits-widget/src/aiCreditsComponents.tsx create mode 100644 packages/ai-credits-widget/src/element.ts create mode 100644 packages/ai-credits-widget/src/index.ts create mode 100644 packages/ai-credits-widget/src/integration.ts create mode 100644 packages/ai-credits-widget/src/mockBackendClient.ts create mode 100644 packages/ai-credits-widget/src/register.ts create mode 100644 packages/ai-credits-widget/src/widgetRuntimeContract.ts create mode 100644 packages/ai-credits-widget/tsconfig.build.json create mode 100644 packages/ai-credits-widget/tsconfig.json create mode 100644 packages/ai-credits-widget/tsup.config.ts create mode 100644 tests/widgets/ai-credits-widget/states.spec.ts diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 23571e4..1860440 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -14,6 +14,7 @@ "@goodwidget/claim-widget-theme-demo": "workspace:*", "@goodwidget/citizen-claim-widget": "workspace:*", "@goodwidget/staking-migration-widget": "workspace:*", + "@goodwidget/ai-credits-widget": "workspace:*", "react": "^18.3.0", "react-dom": "^18.3.0", "react-native-web": "^0.19.13", diff --git a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx new file mode 100644 index 0000000..d657a87 --- /dev/null +++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx @@ -0,0 +1,60 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import * as ShowcaseStories from './AiCreditsWidgetShowcase.stories'; +import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout'; + + + + + + + + + + + All 13 widget states as deterministic mock fixtures — ideal for automation and debugging. + + + + + + No wallet connected; show connect CTA. + Wallet connected; G$ balance = 0. + G$ balance > 0; amounts set; cost breakdown visible. + Celo tx submitted; spinner active. + Celo tx mined; settling on Base. + Credits landed; setup snippet visible. + Credits exhausted; upsell shown. + Credits > 0; usage log visible. + Balance below minimum; top-up guidance. + Celo tx reverted or backend error. + Backend unreachable; retry toast. + Wrong chain; switch to Celo CTA. + + + + + + Use the showcase story when validating the product-facing integration flow. Use the QA fixture + stories when you need state-flow coverage, screenshots, or reproducible runtime conditions. + + + diff --git a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx new file mode 100644 index 0000000..ab1f22d --- /dev/null +++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { AiCreditsWidget } from '@goodwidget/ai-credits-widget' +import { + DisconnectedStory, + ConnectedEmptyStory, + QuoteReadyStory, + QuoteReadyGoodIdStory, + PaymentPendingStory, + PaymentConfirmedStory, + HasCreditsStory, + UsageEmptyStory, + UsageActiveStory, + InsufficientGBalanceStory, + PaymentFailedStory, + BackendUnavailableStory, + UnsupportedChainStory, +} from '../helpers/aiCreditsWidgetStories' + +const meta: Meta = { + title: 'QA/AiCreditsWidget/Runtime Fixtures', + component: AiCreditsWidget, + tags: ['autodocs', 'qa'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +/** S1: No wallet connected */ +export const Disconnected: Story = { + render: () => , +} + +/** S2: Wallet connected, G$ balance = 0 */ +export const ConnectedEmpty: Story = { + render: () => , +} + +/** S3: G$ balance > 0, amounts set, quote visible */ +export const QuoteReady: Story = { + render: () => , +} + +/** S3 + GoodID: 20% streaming bonus badge */ +export const QuoteReadyGoodId: Story = { + render: () => , +} + +/** S4: Celo tx submitted, spinner active */ +export const PaymentPending: Story = { + render: () => , +} + +/** S5: Celo tx mined, settling on Base */ +export const PaymentConfirmed: Story = { + render: () => , +} + +/** S6: Credits landed, setup snippet visible */ +export const HasCredits: Story = { + render: () => , +} + +/** S7: Credits = 0 after prior purchase, upsell shown */ +export const UsageEmpty: Story = { + render: () => , +} + +/** S8: Credits > 0, usage log visible */ +export const UsageActive: Story = { + render: () => , +} + +/** S9: G$ balance below minimum */ +export const InsufficientGBalance: Story = { + render: () => , +} + +/** S11: Celo tx reverted */ +export const PaymentFailed: Story = { + render: () => , +} + +/** S12: Backend unreachable */ +export const BackendUnavailable: Story = { + render: () => , +} + +/** S13: Wallet on wrong chain */ +export const UnsupportedChain: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetShowcase.stories.tsx b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetShowcase.stories.tsx new file mode 100644 index 0000000..3ab726b --- /dev/null +++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetShowcase.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { AiCreditsWidget } from '@goodwidget/ai-credits-widget' +import { InjectedWalletStory } from '../helpers/aiCreditsWidgetStories' + +const meta: Meta = { + title: 'Widgets/AiCreditsWidget/Showcase', + component: AiCreditsWidget, + tags: ['integrator', 'manual', 'showcase'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +/** Live wallet integration showcase — requires an injected EIP-1193 wallet */ +export const InjectedWallet: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx new file mode 100644 index 0000000..de7a961 --- /dev/null +++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx @@ -0,0 +1,368 @@ +import React from 'react' +import { YStack } from '@goodwidget/ui' +import { + AiCreditsWidget, + type AiCreditsWidgetAdapterFactory, + type AiCreditsWidgetAdapterState, + type AiCreditsWidgetStatus, +} from '@goodwidget/ai-credits-widget' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} from '../../fixtures/injectedEip1193' + +// --------------------------------------------------------------------------- +// Mock state factory — creates deterministic adapter state for each story +// --------------------------------------------------------------------------- + +function createMockState( + status: AiCreditsWidgetStatus, + overrides: Partial = {}, +): AiCreditsWidgetAdapterState { + const base: AiCreditsWidgetAdapterState = { + status, + address: '0x329377cbeeF39f01b0Ea04B80465c9eB47D3ED1', + chainId: 42220, + gBalance: '42.50', + aiCreditsBalance: null, + isGoodIdVerified: false, + buyerKey: null, + buyerKeyConfirmed: false, + operatorConsentSigned: false, + depositAmount: '5', + streamAmount: '0', + bonusPercent: 10, + quote: null, + setupSnippet: null, + usageLog: [], + error: null, + primaryAction: 'generate_key', + primaryLabel: 'Set Up Buyer Key', + } + return { ...base, ...overrides } +} + +/** Creates a mock adapter factory returning deterministic state for stories */ +function createAdapterFactory( + status: AiCreditsWidgetStatus, + overrides: Partial = {}, +): AiCreditsWidgetAdapterFactory { + return () => ({ + state: createMockState(status, overrides), + actions: { + connect: async () => {}, + switchChain: async () => {}, + generateBuyerKey: () => {}, + pasteBuyerKey: () => {}, + confirmBuyerKey: () => {}, + signOperatorConsent: async () => {}, + setDepositAmount: () => {}, + setStreamAmount: () => {}, + pay: async () => {}, + refresh: async () => {}, + retry: async () => {}, + }, + }) +} + +// --------------------------------------------------------------------------- +// Shell wrapper — provides a consistent width for all story variants +// --------------------------------------------------------------------------- + +function MockStoryShell({ + adapterFactory, + dataTestId, +}: { + adapterFactory: AiCreditsWidgetAdapterFactory + dataTestId: string +}) { + try { + const provider = createCustodialEip1193Provider() + return ( + + + + ) + } catch (error: unknown) { + return ( + + Custodial fixture not configured + + {error instanceof Error ? error.message : 'Set a local private key in custodialEip1193.ts'} + + + ) + } +} + +// --------------------------------------------------------------------------- +// Exported story components — one per widget state +// --------------------------------------------------------------------------- + +/** S1: disconnected — no wallet connected */ +export function DisconnectedStory() { + return ( + + ) +} + +/** S2: connected_empty — wallet connected, G$ balance = 0 */ +export function ConnectedEmptyStory() { + return ( + + ) +} + +/** S3: quote_ready — amounts set, buyer key confirmed, consent signed */ +export function QuoteReadyStory() { + return ( + + ) +} + +/** S3 with GoodID — 20% streaming bonus visible */ +export function QuoteReadyGoodIdStory() { + return ( + + ) +} + +/** S4: payment_pending — Celo tx submitted */ +export function PaymentPendingStory() { + return ( + + ) +} + +/** S5: payment_confirmed — Celo tx mined, Base settling */ +export function PaymentConfirmedStory() { + return ( + + ) +} + +/** S6: has_credits — credits landed, setup snippet visible */ +export function HasCreditsStory() { + return ( + + ) +} + +/** S7: usage_empty — credits exhausted after prior purchase */ +export function UsageEmptyStory() { + return ( + + ) +} + +/** S8: usage_active — credits > 0 with usage log */ +export function UsageActiveStory() { + return ( + + ) +} + +/** S9: insufficient_g_balance — balance below minimum */ +export function InsufficientGBalanceStory() { + return ( + + ) +} + +/** S11: payment_failed — Celo tx reverted */ +export function PaymentFailedStory() { + return ( + + ) +} + +/** S12: backend_unavailable — service unreachable */ +export function BackendUnavailableStory() { + return ( + + ) +} + +/** S13: unsupported_chain — wrong chain connected */ +export function UnsupportedChainStory() { + return ( + + ) +} + +/** Injected wallet — live integration showcase */ +export function InjectedWalletStory() { + const injectedProvider = getInjectedEip1193Provider() + const backendUrl = import.meta.env.VITE_AI_CREDITS_BACKEND_URL + + if (!isInjectedProviderUsable(injectedProvider)) { + return ( + + No injected wallet found + + Install or enable MetaMask (or another EIP-1193 wallet) in this browser, then refresh + Storybook. + + + ) + } + + return ( + + + {!backendUrl && ( + + + Set `VITE_AI_CREDITS_BACKEND_URL` in `examples/storybook/.env.local` to enable the + AI credits backend. + + + )} + + ) +} diff --git a/packages/ai-credits-widget/package.json b/packages/ai-credits-widget/package.json new file mode 100644 index 0000000..b6da3f1 --- /dev/null +++ b/packages/ai-credits-widget/package.json @@ -0,0 +1,50 @@ +{ + "name": "@goodwidget/ai-credits-widget", + "version": "0.1.0", + "description": "GoodWidget for buying AI coding credits with G$ on Celo", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./element": { + "types": "./dist/element.d.ts", + "import": "./dist/element.js", + "require": "./dist/element.cjs" + }, + "./register": { + "types": "./dist/register.d.ts", + "import": "./dist/register.js", + "require": "./dist/register.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "clean": "rm -rf dist .turbo" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "dependencies": { + "@goodwidget/core": "workspace:*", + "@goodwidget/embed": "workspace:*", + "@goodwidget/ui": "workspace:*", + "viem": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.4.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/ai-credits-widget/src/AiCreditsWidget.tsx b/packages/ai-credits-widget/src/AiCreditsWidget.tsx new file mode 100644 index 0000000..c05fa2b --- /dev/null +++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx @@ -0,0 +1,454 @@ +import React, { useCallback, useMemo } from 'react' +import { GoodWidgetProvider } from '@goodwidget/core' +import type { EIP1193Provider } from '@goodwidget/core' +import { + Button, + ButtonText, + Card, + Heading, + Text, + ToastContainer, + XStack, + YStack, + Spinner, + createToast, + updateToast, +} from '@goodwidget/ui' +import { useAiCreditsAdapter } from './adapter' +import { + AiCreditsHero, + AiCreditsFlowStepper, + AiCreditsStatusNotice, + AmountPicker, + BuyerKeyPanel, + OperatorConsentStep, + CreditsBalance, + SetupSnippet, + UsageLog, +} from './aiCreditsComponents' +import type { + AiCreditsWidgetProps, + AiCreditsWidgetEnvironment, + AiCreditsPaySuccessDetail, + AiCreditsPayErrorDetail, + AiCreditsWidgetAdapterFactory, +} from './widgetRuntimeContract' + +// --------------------------------------------------------------------------- +// Inner component — renders inside GoodWidgetProvider +// --------------------------------------------------------------------------- + +interface AiCreditsInnerProps { + environment?: AiCreditsWidgetEnvironment + backendUrl?: string + adapterFactory?: AiCreditsWidgetAdapterFactory + onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void + onPayError?: (detail: AiCreditsPayErrorDetail) => void +} + +function AiCreditsInner({ + environment, + backendUrl, + adapterFactory, + onPaySuccess, + onPayError, +}: AiCreditsInnerProps) { + // Use the injected adapter factory (for Storybook/tests) or the real adapter + const defaultAdapter = useAiCreditsAdapter({ + environment, + backendUrl, + onPaySuccess, + onPayError, + }) + + const activeAdapter = useMemo( + () => + adapterFactory + ? adapterFactory({ environment, backendUrl }) + : defaultAdapter, + [adapterFactory, environment, backendUrl, defaultAdapter], + ) + + const { state, actions } = activeAdapter + + // Tracks whether the operator consent sign is in-flight + const isSigning = state.status === 'payment_pending' + + const handlePay = useCallback(async () => { + const toastId = createToast({ + message: 'Submitting Celo transaction…', + status: 'pending', + duration: 0, + }) + + try { + await actions.pay() + updateToast(toastId, { + message: 'Payment submitted! Waiting for credits…', + status: 'success', + duration: 4000, + }) + } catch { + updateToast(toastId, { + message: state.error ?? 'Payment failed', + status: 'error', + duration: 0, + }) + } + }, [actions, state.error]) + + const handlePrimaryAction = useCallback(async () => { + switch (state.primaryAction) { + case 'connect': + await actions.connect() + break + case 'switch_chain': + await actions.switchChain() + break + case 'generate_key': + actions.generateBuyerKey() + break + case 'sign_consent': + await actions.signOperatorConsent() + break + case 'pay': + await handlePay() + break + case 'retry': + await actions.retry() + break + case 'refresh': + await actions.refresh() + break + default: + break + } + }, [state.primaryAction, actions, handlePay]) + + const isPending = + state.status === 'payment_pending' || state.status === 'payment_confirmed' + + // --------------------------------------------------------------------------- + // Render: post-purchase states (has_credits, usage_active, usage_empty) + // --------------------------------------------------------------------------- + + const isPostPurchase = + state.status === 'has_credits' || + state.status === 'usage_active' || + state.status === 'usage_empty' + + if (isPostPurchase) { + return ( + + + + {state.setupSnippet && } + + {state.status === 'usage_empty' && ( + + Your AI credits are depleted. Purchase more to continue. + + + )} + + + + + + ) + } + + // --------------------------------------------------------------------------- + // Render: disconnected state + // --------------------------------------------------------------------------- + + if (state.status === 'disconnected') { + return ( + + + + + Buy AI Credits with G$ + + + Connect your wallet to purchase AI coding credits on Base using your G$ on Celo. + + + + + + ) + } + + // --------------------------------------------------------------------------- + // Render: unsupported chain + // --------------------------------------------------------------------------- + + if (state.status === 'unsupported_chain') { + return ( + + + + + Wrong Network + + + Please switch to the Celo network to continue. + + + + ) + } + + // --------------------------------------------------------------------------- + // Render: error states + // --------------------------------------------------------------------------- + + if (state.status === 'payment_failed') { + return ( + + + + Payment Failed + + {state.error && {state.error}} + + + + + ) + } + + if (state.status === 'backend_unavailable') { + return ( + + + + Service Unavailable + + + The AI credits service is temporarily unavailable. Your wallet has not been charged. + + + + + ) + } + + if (state.status === 'insufficient_g_balance') { + return ( + + + + + Insufficient G$ Balance + + + You need at least 1 G$ to purchase AI credits. Top up your wallet and try again. + + + + ) + } + + // --------------------------------------------------------------------------- + // Render: pending payment states + // --------------------------------------------------------------------------- + + if (state.status === 'payment_pending' || state.status === 'payment_confirmed') { + const message = + state.status === 'payment_pending' + ? 'Transaction submitted — waiting for confirmation…' + : 'Payment confirmed — settling credits on Base…' + + return ( + + + + + + {message} + + + + + + ) + } + + // --------------------------------------------------------------------------- + // Render: main connected flow (connected_empty → quote_ready) + // --------------------------------------------------------------------------- + + return ( + + {/* Hero: G$ balance + bonus */} + + + {/* Stepper overview */} + + + {/* Step panels — shown progressively */} + {state.address && !state.buyerKey && ( + + )} + + {state.buyerKey && !state.buyerKeyConfirmed && ( + + )} + + {state.buyerKey && state.buyerKeyConfirmed && !state.operatorConsentSigned && ( + + )} + + {state.operatorConsentSigned && ( + + )} + + {/* Primary action button */} + {state.primaryAction !== 'none' && state.primaryAction !== 'generate_key' && ( + + )} + + ) +} + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- + +/** + * AiCreditsWidget — purchase AI coding credits with G$ on Celo. + * + * The widget guides the user through: + * 1. Connect wallet (Celo) + * 2. Generate or provide a buyer key + * 3. Sign EIP-712 operator consent (in-browser, no custody) + * 4. Set deposit / stream amounts + * 5. Submit Celo payment transaction + * 6. Wait for Base credit settlement + * 7. View credits balance, setup snippet, and usage log + * + * Usage as a React component: + * + * + * Also available as a Web Component via the `element` or `register` entry points. + */ +export function AiCreditsWidget({ + provider, + environment = 'production', + backendUrl, + themeOverrides, + config, + defaultTheme = 'dark', + onPaySuccess, + onPayError, + adapterFactory, +}: AiCreditsWidgetProps) { + return ( + + + + + ) +} diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts new file mode 100644 index 0000000..d22ec34 --- /dev/null +++ b/packages/ai-credits-widget/src/adapter.ts @@ -0,0 +1,710 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useWallet } from '@goodwidget/core' +import type { EIP1193Provider } from '@goodwidget/core' +import { + createPublicClient, + createWalletClient, + custom, + formatUnits, + http, + parseAbi, + parseUnits, + type Address, + type Chain, +} from 'viem' +import { + MockAiCreditsBackendClient, + ProductionAiCreditsBackendClient, +} from './mockBackendClient' +import type { AiCreditsBackendClient } from './mockBackendClient' +import type { + AiCreditsWidgetAdapterActions, + AiCreditsWidgetAdapterResult, + AiCreditsWidgetAdapterState, + AiCreditsWidgetEnvironment, + AiCreditsPaySuccessDetail, + AiCreditsPayErrorDetail, + AiCreditsQuote, +} from './widgetRuntimeContract' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Celo mainnet chain ID — the payer chain */ +const CELO_CHAIN_ID = 42220 + +/** Minimum G$ deposit amount in formatted units */ +const MIN_DEPOSIT_AMOUNT = '1' + +/** Minimum G$ stream amount in formatted units */ +const MIN_STREAM_AMOUNT = '1' + +/** AntseedBuyerOperator contract address on Base (settlement chain) */ +const ANTSEED_BUYER_OPERATOR_ADDRESS: Address = '0x0000000000000000000000000000000000000001' + +/** G$ token contract on Celo */ +const G_TOKEN_CELO_ADDRESS: Address = '0x62B8B11039FcfE5aB0C56E502b1C372A3d462a4' + +const G_TOKEN_ABI = parseAbi([ + 'function balanceOf(address account) view returns (uint256)', + 'function decimals() view returns (uint8)', + 'function transfer(address to, uint256 amount) returns (bool)', +]) + +const CELO_CHAIN: Chain = { + id: CELO_CHAIN_ID, + name: 'Celo', + nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 }, + rpcUrls: { + default: { http: ['https://forno.celo.org'] }, + public: { http: ['https://forno.celo.org'] }, + }, +} + +// --------------------------------------------------------------------------- +// EIP-712 domain and type definitions for AntseedBuyerOperator consent +// --------------------------------------------------------------------------- + +const EIP712_DOMAIN = { + name: 'AntseedBuyerOperator', + version: '1', + chainId: 8453, // Base — where credits settle +} + +const EIP712_TYPES = { + OperatorConsent: [ + { name: 'buyerKey', type: 'address' }, + { name: 'operator', type: 'address' }, + { name: 'nonce', type: 'uint256' }, + ], +} + +// --------------------------------------------------------------------------- +// Initial adapter state +// --------------------------------------------------------------------------- + +const INITIAL_STATE: AiCreditsWidgetAdapterState = { + status: 'disconnected', + address: null, + chainId: null, + gBalance: null, + aiCreditsBalance: null, + isGoodIdVerified: false, + buyerKey: null, + buyerKeyConfirmed: false, + operatorConsentSigned: false, + depositAmount: MIN_DEPOSIT_AMOUNT, + streamAmount: '0', + bonusPercent: 10, + quote: null, + setupSnippet: null, + usageLog: [], + error: null, + primaryAction: 'connect', + primaryLabel: 'Connect Wallet', +} + +// --------------------------------------------------------------------------- +// Helper: derive next status from current wallet + widget data +// --------------------------------------------------------------------------- + +function deriveStatus(params: { + isConnected: boolean + chainId: number | null + gBalance: string | null + aiCreditsBalance: string | null + buyerKey: string | null + buyerKeyConfirmed: boolean + operatorConsentSigned: boolean + depositAmount: string + streamAmount: string + error: string | null + currentStatus: AiCreditsWidgetAdapterState['status'] +}): AiCreditsWidgetAdapterState['status'] { + const { + isConnected, + chainId, + gBalance, + aiCreditsBalance, + buyerKey, + buyerKeyConfirmed, + operatorConsentSigned, + depositAmount, + streamAmount, + error, + currentStatus, + } = params + + // Preserve terminal states set explicitly by action handlers + if ( + currentStatus === 'payment_pending' || + currentStatus === 'payment_confirmed' || + currentStatus === 'payment_failed' || + currentStatus === 'backend_unavailable' + ) { + return currentStatus + } + + if (!isConnected) return 'disconnected' + + if (chainId !== null && chainId !== CELO_CHAIN_ID) return 'unsupported_chain' + + if (error && currentStatus !== 'has_credits' && currentStatus !== 'usage_active') { + return 'payment_failed' + } + + if (aiCreditsBalance !== null) { + const credits = Number.parseFloat(aiCreditsBalance) + if (credits > 0) return 'usage_active' + if (currentStatus === 'has_credits') return 'usage_empty' + if (credits === 0 && currentStatus === 'usage_active') return 'usage_empty' + } + + if (gBalance === null) return 'connected_empty' + + const balance = Number.parseFloat(gBalance) + if (balance <= 0) return 'connected_empty' + + const deposit = Number.parseFloat(depositAmount) + const stream = Number.parseFloat(streamAmount) + const minDeposit = Number.parseFloat(MIN_DEPOSIT_AMOUNT) + const minStream = Number.parseFloat(MIN_STREAM_AMOUNT) + + if (balance < minDeposit) return 'insufficient_g_balance' + + const hasValidDeposit = deposit >= minDeposit + const hasValidStream = stream === 0 || stream >= minStream + + if (!buyerKey) return 'connected_empty' + if (!buyerKeyConfirmed) return 'connected_empty' + if (!operatorConsentSigned) return 'connected_empty' + + if (hasValidDeposit && hasValidStream) return 'quote_ready' + + return 'connected_empty' +} + +// --------------------------------------------------------------------------- +// Helper: derive primary action and label from current status +// --------------------------------------------------------------------------- + +function derivePrimaryAction( + status: AiCreditsWidgetAdapterState['status'], +): AiCreditsWidgetAdapterState['primaryAction'] { + switch (status) { + case 'disconnected': + return 'connect' + case 'unsupported_chain': + return 'switch_chain' + case 'connected_empty': + return 'generate_key' + case 'quote_ready': + return 'pay' + case 'payment_pending': + case 'payment_confirmed': + return 'none' + case 'payment_failed': + case 'backend_unavailable': + return 'retry' + case 'has_credits': + case 'usage_active': + case 'usage_empty': + return 'refresh' + case 'insufficient_g_balance': + case 'insufficient_ai_credits': + return 'refresh' + default: + return 'none' + } +} + +function derivePrimaryLabel(action: AiCreditsWidgetAdapterState['primaryAction']): string { + switch (action) { + case 'connect': + return 'Connect Wallet' + case 'switch_chain': + return 'Switch to Celo' + case 'generate_key': + return 'Set Up Buyer Key' + case 'sign_consent': + return 'Sign Consent' + case 'pay': + return 'Buy AI Credits' + case 'retry': + return 'Retry' + case 'refresh': + return 'Refresh' + default: + return '' + } +} + +// --------------------------------------------------------------------------- +// Adapter hook options +// --------------------------------------------------------------------------- + +export interface UseAiCreditsAdapterOptions { + environment?: AiCreditsWidgetEnvironment + backendUrl?: string + onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void + onPayError?: (detail: AiCreditsPayErrorDetail) => void +} + +// --------------------------------------------------------------------------- +// Main adapter hook +// --------------------------------------------------------------------------- + +export function useAiCreditsAdapter({ + backendUrl, + onPaySuccess, + onPayError, +}: UseAiCreditsAdapterOptions): AiCreditsWidgetAdapterResult { + const { address, chainId, isConnected, provider, connect } = useWallet() + const [state, setState] = useState(INITIAL_STATE) + + // Stable ref to latest provider to avoid stale closures in async callbacks + const providerRef = useRef(null) + providerRef.current = provider as EIP1193Provider | null + + // Instantiate the correct backend client once — mock if no backendUrl, production otherwise + const backendClient = useMemo(() => { + if (!backendUrl) { + return new MockAiCreditsBackendClient({ isGoodIdVerified: false }) + } + return new ProductionAiCreditsBackendClient(backendUrl) + }, [backendUrl]) + + // --------------------------------------------------------------------------- + // Load G$ balance whenever wallet state changes + // --------------------------------------------------------------------------- + useEffect(() => { + if (!isConnected || !address) { + setState((prev) => ({ ...prev, gBalance: null, status: 'disconnected' })) + return + } + + let cancelled = false + + async function loadBalance() { + try { + const publicClient = createPublicClient({ chain: CELO_CHAIN, transport: http() }) + const [rawBalance, decimals] = await Promise.all([ + publicClient.readContract({ + address: G_TOKEN_CELO_ADDRESS, + abi: G_TOKEN_ABI, + functionName: 'balanceOf', + args: [address as Address], + }), + publicClient.readContract({ + address: G_TOKEN_CELO_ADDRESS, + abi: G_TOKEN_ABI, + functionName: 'decimals', + }), + ]) + + if (cancelled) return + + const formatted = formatUnits(rawBalance as bigint, decimals as number) + setState((prev) => { + const nextStatus = deriveStatus({ + isConnected: true, + chainId, + gBalance: formatted, + aiCreditsBalance: prev.aiCreditsBalance, + buyerKey: prev.buyerKey, + buyerKeyConfirmed: prev.buyerKeyConfirmed, + operatorConsentSigned: prev.operatorConsentSigned, + depositAmount: prev.depositAmount, + streamAmount: prev.streamAmount, + error: prev.error, + currentStatus: prev.status, + }) + const primaryAction = derivePrimaryAction(nextStatus) + return { + ...prev, + address, + chainId, + gBalance: formatted, + status: nextStatus, + primaryAction, + primaryLabel: derivePrimaryLabel(primaryAction), + } + }) + } catch { + if (cancelled) return + setState((prev) => ({ + ...prev, + address, + chainId, + gBalance: '0', + status: + chainId !== null && chainId !== CELO_CHAIN_ID ? 'unsupported_chain' : 'connected_empty', + primaryAction: chainId !== null && chainId !== CELO_CHAIN_ID ? 'switch_chain' : 'generate_key', + primaryLabel: + chainId !== null && chainId !== CELO_CHAIN_ID ? 'Switch to Celo' : 'Set Up Buyer Key', + })) + } + } + + void loadBalance() + return () => { + cancelled = true + } + }, [isConnected, address, chainId]) + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + const handleConnect = useCallback(async () => { + await connect() + }, [connect]) + + const handleSwitchChain = useCallback(async () => { + const prov = providerRef.current + if (!prov) return + await (prov as { request: (args: { method: string; params: unknown[] }) => Promise }).request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${CELO_CHAIN_ID.toString(16)}` }], + }) + }, []) + + const handleGenerateBuyerKey = useCallback(() => { + // Generate a random 32-byte private key and derive its address for use as buyer key. + // The private key is intentionally discarded — only the derived address is stored, + // since the buyer key address is what AntseedBuyerOperator references on-chain. + const randomBytes = crypto.getRandomValues(new Uint8Array(32)) + const hex = Array.from(randomBytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + const buyerKey = `0x${hex.slice(0, 40)}` // Use first 20 bytes as a pseudo-address + + setState((prev) => ({ + ...prev, + buyerKey, + buyerKeyConfirmed: false, + operatorConsentSigned: false, + })) + }, []) + + const handlePasteBuyerKey = useCallback((key: string) => { + const normalized = key.trim().toLowerCase().startsWith('0x') ? key.trim() : `0x${key.trim()}` + setState((prev) => ({ + ...prev, + buyerKey: normalized, + buyerKeyConfirmed: true, // User-provided keys are pre-confirmed + operatorConsentSigned: false, + })) + }, []) + + const handleConfirmBuyerKey = useCallback(() => { + setState((prev) => ({ ...prev, buyerKeyConfirmed: true })) + }, []) + + const handleSignOperatorConsent = useCallback(async () => { + const currentState = state + if (!currentState.buyerKey || !currentState.address || !providerRef.current) return + + try { + const walletClient = createWalletClient({ + account: currentState.address as Address, + chain: CELO_CHAIN, + transport: custom(providerRef.current), + }) + + // Sign EIP-712 typed data in-browser; private key never leaves the browser + await walletClient.signTypedData({ + domain: EIP712_DOMAIN, + types: EIP712_TYPES, + primaryType: 'OperatorConsent', + message: { + buyerKey: currentState.buyerKey as Address, + operator: ANTSEED_BUYER_OPERATOR_ADDRESS, + nonce: BigInt(Date.now()), + }, + }) + + setState((prev) => { + const nextStatus = deriveStatus({ + isConnected: true, + chainId: prev.chainId, + gBalance: prev.gBalance, + aiCreditsBalance: prev.aiCreditsBalance, + buyerKey: prev.buyerKey, + buyerKeyConfirmed: true, + operatorConsentSigned: true, + depositAmount: prev.depositAmount, + streamAmount: prev.streamAmount, + error: null, + currentStatus: 'connected_empty', + }) + const primaryAction = derivePrimaryAction(nextStatus) + return { + ...prev, + operatorConsentSigned: true, + error: null, + status: nextStatus, + primaryAction, + primaryLabel: derivePrimaryLabel(primaryAction), + } + }) + } catch (err: unknown) { + setState((prev) => ({ + ...prev, + error: err instanceof Error ? err.message : 'Consent signature rejected', + })) + } + }, [state]) + + const handleSetDepositAmount = useCallback((amount: string) => { + setState((prev) => { + const nextStatus = deriveStatus({ + isConnected: true, + chainId: prev.chainId, + gBalance: prev.gBalance, + aiCreditsBalance: prev.aiCreditsBalance, + buyerKey: prev.buyerKey, + buyerKeyConfirmed: prev.buyerKeyConfirmed, + operatorConsentSigned: prev.operatorConsentSigned, + depositAmount: amount, + streamAmount: prev.streamAmount, + error: prev.error, + currentStatus: prev.status === 'payment_pending' ? 'payment_pending' : 'connected_empty', + }) + const primaryAction = derivePrimaryAction(nextStatus) + return { + ...prev, + depositAmount: amount, + quote: null, + status: nextStatus, + primaryAction, + primaryLabel: derivePrimaryLabel(primaryAction), + } + }) + }, []) + + const handleSetStreamAmount = useCallback((amount: string) => { + setState((prev) => { + const bonusPercent = Number.parseFloat(amount) > 0 && prev.isGoodIdVerified ? 20 : 10 + const nextStatus = deriveStatus({ + isConnected: true, + chainId: prev.chainId, + gBalance: prev.gBalance, + aiCreditsBalance: prev.aiCreditsBalance, + buyerKey: prev.buyerKey, + buyerKeyConfirmed: prev.buyerKeyConfirmed, + operatorConsentSigned: prev.operatorConsentSigned, + depositAmount: prev.depositAmount, + streamAmount: amount, + error: prev.error, + currentStatus: prev.status === 'payment_pending' ? 'payment_pending' : 'connected_empty', + }) + const primaryAction = derivePrimaryAction(nextStatus) + return { + ...prev, + streamAmount: amount, + bonusPercent, + quote: null, + status: nextStatus, + primaryAction, + primaryLabel: derivePrimaryLabel(primaryAction), + } + }) + }, []) + + const handlePay = useCallback(async () => { + const currentState = state + + if (!currentState.address || !currentState.buyerKey || !providerRef.current) return + + // Fetch a fresh quote before sending the transaction + let quote: AiCreditsQuote + try { + quote = await backendClient.getQuote( + currentState.address, + currentState.depositAmount, + currentState.streamAmount, + ) + } catch { + setState((prev) => ({ + ...prev, + status: 'backend_unavailable', + primaryAction: 'retry', + primaryLabel: 'Retry', + error: 'Backend unavailable — could not fetch quote', + })) + return + } + + setState((prev) => ({ + ...prev, + quote, + status: 'payment_pending', + primaryAction: 'none', + primaryLabel: 'Processing…', + error: null, + })) + + try { + const walletClient = createWalletClient({ + account: currentState.address as Address, + chain: CELO_CHAIN, + transport: custom(providerRef.current), + }) + + // Build the G$ transfer transaction to the AntseedBuyerOperator contract + const totalG = Number.parseFloat(currentState.depositAmount) + Number.parseFloat(currentState.streamAmount) + const amountWei = parseUnits(totalG.toFixed(2), 18) + + const txHash = await walletClient.writeContract({ + address: G_TOKEN_CELO_ADDRESS, + abi: G_TOKEN_ABI, + functionName: 'transfer', + args: [ANTSEED_BUYER_OPERATOR_ADDRESS, amountWei], + }) + + setState((prev) => ({ + ...prev, + status: 'payment_confirmed', + primaryAction: 'none', + primaryLabel: 'Settling…', + })) + + // Notify backend and wait for Base settlement + await backendClient.notifyPayment(currentState.buyerKey, txHash) + const { credits } = await backendClient.waitForSettlement(currentState.buyerKey) + + const setupSnippet = buildSetupSnippet(currentState.buyerKey) + + setState((prev) => ({ + ...prev, + aiCreditsBalance: credits, + setupSnippet, + status: 'has_credits', + primaryAction: 'refresh', + primaryLabel: 'Refresh', + error: null, + })) + + onPaySuccess?.({ + address: currentState.address!, + chainId: CELO_CHAIN_ID, + transactionHash: txHash, + buyerKey: currentState.buyerKey, + creditsReceived: credits, + }) + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Payment failed' + setState((prev) => ({ + ...prev, + status: 'payment_failed', + primaryAction: 'retry', + primaryLabel: 'Retry', + error: message, + })) + onPayError?.({ + address: currentState.address, + chainId: CELO_CHAIN_ID, + message, + }) + } + }, [state, backendClient, onPaySuccess, onPayError]) + + const handleRefresh = useCallback(async () => { + const currentState = state + if (!currentState.buyerKey) return + + try { + const [balance, usageLog] = await Promise.all([ + backendClient.getCreditsBalance(currentState.buyerKey), + backendClient.getUsageLog(currentState.buyerKey), + ]) + + setState((prev) => { + const credits = Number.parseFloat(balance) + const nextStatus = credits > 0 ? 'usage_active' : 'usage_empty' + const primaryAction = derivePrimaryAction(nextStatus) + return { + ...prev, + aiCreditsBalance: balance, + usageLog, + status: nextStatus, + primaryAction, + primaryLabel: derivePrimaryLabel(primaryAction), + } + }) + } catch { + setState((prev) => ({ + ...prev, + status: 'backend_unavailable', + primaryAction: 'retry', + primaryLabel: 'Retry', + error: 'Could not reach backend — check your connection', + })) + } + }, [state, backendClient]) + + const handleRetry = useCallback(async () => { + setState((prev) => ({ + ...prev, + status: 'connected_empty', + error: null, + primaryAction: 'generate_key', + primaryLabel: 'Set Up Buyer Key', + })) + }, []) + + // --------------------------------------------------------------------------- + // Stable actions object + // --------------------------------------------------------------------------- + + const actions: AiCreditsWidgetAdapterActions = useMemo( + () => ({ + connect: handleConnect, + switchChain: handleSwitchChain, + generateBuyerKey: handleGenerateBuyerKey, + pasteBuyerKey: handlePasteBuyerKey, + confirmBuyerKey: handleConfirmBuyerKey, + signOperatorConsent: handleSignOperatorConsent, + setDepositAmount: handleSetDepositAmount, + setStreamAmount: handleSetStreamAmount, + pay: handlePay, + refresh: handleRefresh, + retry: handleRetry, + }), + [ + handleConnect, + handleSwitchChain, + handleGenerateBuyerKey, + handlePasteBuyerKey, + handleConfirmBuyerKey, + handleSignOperatorConsent, + handleSetDepositAmount, + handleSetStreamAmount, + handlePay, + handleRefresh, + handleRetry, + ], + ) + + return { state, actions } +} + +// --------------------------------------------------------------------------- +// Helper: build the copyable API setup snippet shown after first purchase +// --------------------------------------------------------------------------- + +function buildSetupSnippet(buyerKey: string): string { + return `# AntSeed API Configuration +# Add these to your Cursor / Cline / VS Code Copilot settings: + +ANTSEED_API_KEY="${buyerKey}" +ANTSEED_BASE_URL="https://api.antseed.xyz/v1" + +# Example (Cursor settings.json): +# { +# "antseed.apiKey": "${buyerKey}", +# "antseed.baseUrl": "https://api.antseed.xyz/v1" +# }` +} diff --git a/packages/ai-credits-widget/src/aiCreditsComponents.tsx b/packages/ai-credits-widget/src/aiCreditsComponents.tsx new file mode 100644 index 0000000..d814236 --- /dev/null +++ b/packages/ai-credits-widget/src/aiCreditsComponents.tsx @@ -0,0 +1,716 @@ +import React, { useState } from 'react' +import { + createComponent, + Card, + Heading, + Text, + Button, + ButtonText, + XStack, + YStack, + Separator, + Spinner, + Input, + Icon, + TokenAmount, + Stepper, +} from '@goodwidget/ui' +import type { StepperStepItem } from '@goodwidget/ui' +import type { + AiCreditsWidgetAdapterState, + AiCreditsUsageEntry, +} from './widgetRuntimeContract' + +// --------------------------------------------------------------------------- +// Named styled components — participate in the component sub-theme system. +// Integrators can override light_AiCreditsHeroCard, dark_AiCreditsHeroCard, etc. +// --------------------------------------------------------------------------- + +/** Primary hero card containing G$ input and bonus badge */ +export const AiCreditsHeroCard = createComponent(Card, { + name: 'AiCreditsHeroCard', + extends: 'Card', + gap: '$4', +}) + +/** Panel for buyer key generation and confirmation */ +export const BuyerKeyPanelCard = createComponent(Card, { + name: 'BuyerKeyPanelCard', + extends: 'Card', + gap: '$3', +}) + +/** Operator consent step container */ +export const OperatorConsentCard = createComponent(Card, { + name: 'OperatorConsentCard', + extends: 'Card', + gap: '$3', +}) + +/** Amount picker container for deposit and stream inputs */ +export const AmountPickerCard = createComponent(Card, { + name: 'AmountPickerCard', + extends: 'Card', + gap: '$4', +}) + +/** Credits balance display card */ +export const CreditsBalanceCard = createComponent(Card, { + name: 'CreditsBalanceCard', + extends: 'Card', + gap: '$3', +}) + +/** Copyable setup snippet card */ +export const SetupSnippetCard = createComponent(Card, { + name: 'SetupSnippetCard', + extends: 'Card', + gap: '$3', +}) + +/** Usage log accordion container */ +export const UsageLogCard = createComponent(Card, { + name: 'UsageLogCard', + extends: 'Card', + gap: '$2', +}) + +/** Status notice banner wrapping Text + Card */ +export const AiCreditsStatusNotice = createComponent(Card, { + name: 'AiCreditsStatusNotice', + extends: 'Card', + borderWidth: 1, + padding: '$3', +}) + +/** Bonus badge pill — highlights the active credit bonus percentage */ +export const BonusBadgeFrame = createComponent(XStack, { + name: 'BonusBadgeFrame', + borderRadius: '$full', + paddingHorizontal: '$3', + paddingVertical: '$1', + alignItems: 'center' as const, + gap: '$1', +}) + +// --------------------------------------------------------------------------- +// AiCreditsHeroCard component +// --------------------------------------------------------------------------- + +interface HeroCardProps { + gBalance: string | null + isGoodIdVerified: boolean + bonusPercent: number +} + +/** + * Displays the connected wallet's G$ balance and the applicable bonus badge. + * The bonus is 20% for GoodID-verified users (with stream), 10% otherwise. + */ +export function AiCreditsHero({ gBalance, isGoodIdVerified, bonusPercent }: HeroCardProps) { + return ( + + + + + Your G$ Balance + + {gBalance !== null ? ( + + ) : ( + + )} + + + {/* Bonus badge — shown when balance > 0 */} + {gBalance && Number.parseFloat(gBalance) > 0 && ( + + + + +{bonusPercent}% Bonus + + {isGoodIdVerified && ( + + (GoodID) + + )} + + )} + + + ) +} + +// --------------------------------------------------------------------------- +// BuyerKeyPanel component +// --------------------------------------------------------------------------- + +interface BuyerKeyPanelProps { + buyerKey: string | null + buyerKeyConfirmed: boolean + onGenerate: () => void + onPaste: (key: string) => void + onConfirm: () => void +} + +/** + * Handles buyer key generation and confirmation. + * Generated keys require copy-and-confirm before the user can proceed. + * User-provided (pasted) keys are pre-confirmed. + */ +export function BuyerKeyPanel({ + buyerKey, + buyerKeyConfirmed, + onGenerate, + onPaste, + onConfirm, +}: BuyerKeyPanelProps) { + const [pasteMode, setPasteMode] = useState(false) + const [pasteValue, setPasteValue] = useState('') + const [copied, setCopied] = useState(false) + + async function handleCopy() { + if (!buyerKey) return + await navigator.clipboard.writeText(buyerKey) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + Buyer Key + + Your buyer key links your AI credits on Base to your identity. Generate a new one or paste + an existing key. + + + {!pasteMode && ( + + + + + + {buyerKey && ( + + + + {buyerKey} + + + + + {!buyerKeyConfirmed && ( + + )} + + {buyerKeyConfirmed && ( + + + + Key confirmed — you can proceed + + + )} + + )} + + )} + + {pasteMode && ( + + + + + + + + )} + + ) +} + +// --------------------------------------------------------------------------- +// OperatorConsentStep component +// --------------------------------------------------------------------------- + +interface OperatorConsentStepProps { + buyerKey: string | null + operatorConsentSigned: boolean + isSigning: boolean + onSign: () => Promise +} + +/** + * Prompts the user to sign the EIP-712 operator consent message. + * This authorises AntseedBuyerOperator to manage deposits on Base on behalf of the buyer key. + * The buyer key signs in-browser; the payer wallet is not involved here. + */ +export function OperatorConsentStep({ + buyerKey, + operatorConsentSigned, + isSigning, + onSign, +}: OperatorConsentStepProps) { + return ( + + Operator Consent + + Sign a permission message allowing the AntseedBuyerOperator contract to manage your credits + on Base. This happens in-browser and does not require a gas transaction. + + + {buyerKey && ( + + Buyer key:{' '} + + {buyerKey.slice(0, 10)}…{buyerKey.slice(-6)} + + + )} + + {operatorConsentSigned ? ( + + + Consent signed — ready to pay + + ) : ( + + )} + + ) +} + +// --------------------------------------------------------------------------- +// AmountPicker component +// --------------------------------------------------------------------------- + +interface AmountPickerProps { + depositAmount: string + streamAmount: string + gBalance: string | null + bonusPercent: number + isGoodIdVerified: boolean + onDepositChange: (v: string) => void + onStreamChange: (v: string) => void +} + +/** + * Two-field picker for the one-time deposit and monthly stream amounts. + * Shows USD equivalent estimates and the applicable bonus badge. + */ +export function AmountPicker({ + depositAmount, + streamAmount, + gBalance, + bonusPercent, + isGoodIdVerified, + onDepositChange, + onStreamChange, +}: AmountPickerProps) { + // Approximate G$ → USD conversion for display purposes + const G_USD_RATE = 0.0015 + const depositUsd = (Number.parseFloat(depositAmount) || 0) * G_USD_RATE + const streamUsd = (Number.parseFloat(streamAmount) || 0) * G_USD_RATE + const balance = Number.parseFloat(gBalance ?? '0') + const totalG = (Number.parseFloat(depositAmount) || 0) + (Number.parseFloat(streamAmount) || 0) + const isOverBalance = totalG > balance + + return ( + + Choose Amounts + + {/* One-time deposit field */} + + + One-time Deposit (G$) + + +10% bonus + + + 0 && Number.parseFloat(depositAmount) < 1} + /> + {Number.parseFloat(depositAmount) > 0 && ( + + ≈ ${depositUsd.toFixed(4)} USD + + )} + + + {/* Monthly stream field */} + + + Monthly Stream (G$) + + {isGoodIdVerified ? '+20% bonus (GoodID)' : '+20% with GoodID'} + + + 0 && Number.parseFloat(streamAmount) < 1} + /> + {Number.parseFloat(streamAmount) > 0 && ( + + ≈ ${streamUsd.toFixed(4)} USD/month + + )} + + + + + {/* Total row */} + + Total + + + + + + Applied bonus + + + + +{bonusPercent}% + + + + + {isOverBalance && ( + + + Total exceeds your G$ balance. Reduce the amounts. + + + )} + + ) +} + +// --------------------------------------------------------------------------- +// CreditsBalanceCard component +// --------------------------------------------------------------------------- + +interface CreditsBalanceProps { + aiCreditsBalance: string | null + setupSnippet: string | null +} + +/** + * Shows the current AI credits balance on Base with a compact usage bar. + */ +export function CreditsBalance({ aiCreditsBalance, setupSnippet: _ }: CreditsBalanceProps) { + return ( + + + AI Credits (Base) + + + {aiCreditsBalance !== null ? ( + {Number.parseFloat(aiCreditsBalance).toFixed(2)} credits + ) : ( + + )} + + ) +} + +// --------------------------------------------------------------------------- +// SetupSnippetCard component +// --------------------------------------------------------------------------- + +interface SetupSnippetProps { + snippet: string +} + +/** + * Displays a copyable API key / base URL code block for Cursor, Cline, etc. + */ +export function SetupSnippet({ snippet }: SetupSnippetProps) { + const [copied, setCopied] = useState(false) + + async function handleCopy() { + await navigator.clipboard.writeText(snippet) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + API Setup + + + + + {snippet} + + + + Paste these into your Cursor, Cline, or VS Code Copilot settings to start using AI credits. + + + ) +} + +// --------------------------------------------------------------------------- +// UsageLog component +// --------------------------------------------------------------------------- + +interface UsageLogProps { + entries: AiCreditsUsageEntry[] +} + +/** + * Accordion list of usage sessions, showing credits used per model. + */ +export function UsageLog({ entries }: UsageLogProps) { + const [expanded, setExpanded] = useState(false) + + if (entries.length === 0) return null + + const total = entries.reduce((sum, e) => sum + e.creditsUsed, 0) + + return ( + + setExpanded((v) => !v)} + cursor="pointer" + > + Usage History + + + {total.toFixed(1)} total credits + + + + + + {expanded && ( + + {entries.map((entry) => ( + + + + + + {entry.model} + + + {new Date(entry.timestamp).toLocaleString()} + + + + -{entry.creditsUsed.toFixed(1)} credits + + + + ))} + + )} + + ) +} + +// --------------------------------------------------------------------------- +// AiCreditsFlowStepper component +// --------------------------------------------------------------------------- + +/** Map of step IDs for the AI credits purchase flow */ +export type AiCreditsFlowStep = 'connect' | 'buyer_key' | 'consent' | 'amount' | 'pay' + +interface AiCreditsFlowStepperProps { + state: AiCreditsWidgetAdapterState +} + +function mapStatusToActiveStep( + state: AiCreditsWidgetAdapterState, +): AiCreditsFlowStep | null { + if (state.status === 'disconnected') return 'connect' + if (!state.buyerKey || !state.buyerKeyConfirmed) return 'buyer_key' + if (!state.operatorConsentSigned) return 'consent' + if (state.status === 'connected_empty') return 'amount' + if ( + state.status === 'quote_ready' || + state.status === 'payment_pending' || + state.status === 'payment_confirmed' + ) + return 'pay' + return null +} + +/** + * Wraps the Stepper component with widget-specific steps for the purchase flow. + */ +export function AiCreditsFlowStepper({ state }: AiCreditsFlowStepperProps) { + const activeStep = mapStatusToActiveStep(state) + + function getStepStatus( + step: AiCreditsFlowStep, + ): StepperStepItem['status'] { + const isConnected = state.address !== null + const hasBuyerKey = state.buyerKey !== null && state.buyerKeyConfirmed + const hasConsent = state.operatorConsentSigned + const hasAmount = + Number.parseFloat(state.depositAmount) >= 1 || Number.parseFloat(state.streamAmount) >= 1 + + switch (step) { + case 'connect': + if (isConnected) return 'completed' + if (state.status === 'unsupported_chain') return 'failed' + return activeStep === 'connect' ? 'active' : 'pending' + case 'buyer_key': + if (hasBuyerKey) return 'completed' + if (!isConnected) return 'pending' + return activeStep === 'buyer_key' ? 'active' : 'pending' + case 'consent': + if (hasConsent) return 'completed' + if (!hasBuyerKey) return 'pending' + return activeStep === 'consent' ? 'active' : 'pending' + case 'amount': + if (hasAmount && hasConsent) return 'completed' + if (!hasConsent) return 'pending' + return activeStep === 'amount' ? 'active' : 'pending' + case 'pay': + if ( + state.status === 'has_credits' || + state.status === 'usage_active' || + state.status === 'usage_empty' || + state.status === 'payment_confirmed' + ) + return 'completed' + if (state.status === 'payment_failed') return 'failed' + if (state.status === 'payment_pending') return 'active' + if (!hasAmount) return 'pending' + return activeStep === 'pay' ? 'active' : 'pending' + default: + return 'pending' + } + } + + const steps: StepperStepItem[] = [ + { + id: 'connect', + title: 'Connect Wallet', + description: + state.status === 'unsupported_chain' ? 'Switch to Celo to continue' : undefined, + status: getStepStatus('connect'), + }, + { + id: 'buyer_key', + title: 'Buyer Key', + description: 'Generate or provide your AI credits buyer key', + status: getStepStatus('buyer_key'), + }, + { + id: 'consent', + title: 'Operator Consent', + description: 'Sign permission for the AntseedBuyerOperator', + status: getStepStatus('consent'), + }, + { + id: 'amount', + title: 'Choose Amounts', + description: 'Set deposit and/or monthly stream amounts', + status: getStepStatus('amount'), + }, + { + id: 'pay', + title: 'Buy Credits', + description: + state.status === 'payment_pending' + ? 'Transaction submitted…' + : state.status === 'payment_confirmed' + ? 'Settling on Base…' + : state.status === 'payment_failed' + ? state.error ?? 'Payment failed' + : 'Confirm the Celo transaction', + status: getStepStatus('pay'), + }, + ] + + return ( + + Purchase Flow + + } + /> + ) +} diff --git a/packages/ai-credits-widget/src/element.ts b/packages/ai-credits-widget/src/element.ts new file mode 100644 index 0000000..6669e5c --- /dev/null +++ b/packages/ai-credits-widget/src/element.ts @@ -0,0 +1,27 @@ +import { createMiniAppElement } from '@goodwidget/embed' +import { AiCreditsWidget } from './AiCreditsWidget' +import type React from 'react' + +/** + * A Custom Element class wrapping the AiCreditsWidget React component. + * + * Register it with any tag name: + * customElements.define('ai-credits-widget', AiCreditsWidgetElement) + * + * Then use in HTML: + * + * + * Set the wallet provider and theme overrides via JS properties: + * const el = document.querySelector('ai-credits-widget') + * el.provider = window.ethereum + * el.backendUrl = 'https://api.antseed.xyz' + * el.themeOverrides = { tokens: { color: { primary: '#00AFFE' } } } + */ +export const AiCreditsWidgetElement = createMiniAppElement( + AiCreditsWidget as React.ComponentType>, + { + shadow: true, + defaultTheme: 'dark', + events: ['pay-success', 'pay-error'], + }, +) diff --git a/packages/ai-credits-widget/src/index.ts b/packages/ai-credits-widget/src/index.ts new file mode 100644 index 0000000..43ca179 --- /dev/null +++ b/packages/ai-credits-widget/src/index.ts @@ -0,0 +1,31 @@ +// Integration metadata +export { aiCreditsIntegration } from './integration' +export type { AiCreditsIntegration } from './integration' + +// Adapter contract types +export type { + AiCreditsWidgetStatus, + AiCreditsWidgetPrimaryAction, + AiCreditsWidgetAdapterState, + AiCreditsWidgetAdapterActions, + AiCreditsWidgetAdapterResult, + AiCreditsWidgetAdapterFactory, + AiCreditsWidgetAdapterFactoryInput, + AiCreditsWidgetEnvironment, + AiCreditsWidgetProps, + AiCreditsPaySuccessDetail, + AiCreditsPayErrorDetail, + AiCreditsQuote, + AiCreditsUsageEntry, +} from './widgetRuntimeContract' + +// Backend client +export type { AiCreditsBackendClient } from './mockBackendClient' +export { MockAiCreditsBackendClient, ProductionAiCreditsBackendClient, createBackendClient } from './mockBackendClient' + +// Adapter hook +export { useAiCreditsAdapter } from './adapter' +export type { UseAiCreditsAdapterOptions } from './adapter' + +// Widget component +export { AiCreditsWidget } from './AiCreditsWidget' diff --git a/packages/ai-credits-widget/src/integration.ts b/packages/ai-credits-widget/src/integration.ts new file mode 100644 index 0000000..3604a35 --- /dev/null +++ b/packages/ai-credits-widget/src/integration.ts @@ -0,0 +1,25 @@ +export const aiCreditsIntegration = { + id: 'ai-credits', + /** Payer chain — Celo mainnet */ + chains: [42220], + /** Settlement chain — Base mainnet (credits land here) */ + settlementChains: [8453], + states: [ + 'disconnected', + 'connected_empty', + 'quote_ready', + 'payment_pending', + 'payment_confirmed', + 'has_credits', + 'usage_empty', + 'usage_active', + 'insufficient_g_balance', + 'insufficient_ai_credits', + 'payment_failed', + 'backend_unavailable', + 'unsupported_chain', + ], + events: ['pay-success', 'pay-error'], +} as const + +export type AiCreditsIntegration = typeof aiCreditsIntegration diff --git a/packages/ai-credits-widget/src/mockBackendClient.ts b/packages/ai-credits-widget/src/mockBackendClient.ts new file mode 100644 index 0000000..d25f64d --- /dev/null +++ b/packages/ai-credits-widget/src/mockBackendClient.ts @@ -0,0 +1,217 @@ +import type { AiCreditsQuote, AiCreditsUsageEntry } from './widgetRuntimeContract' + +// --------------------------------------------------------------------------- +// Backend client interface — production implementation calls backendUrl REST endpoints. +// Mock implementation returns deterministic fake data for Storybook and Playwright. +// --------------------------------------------------------------------------- + +export interface AiCreditsBackendClient { + /** + * Returns a pricing quote for the given deposit and stream amounts. + * @param address - Payer wallet address + * @param depositG - One-time deposit amount in G$ (e.g. "10") + * @param streamG - Monthly stream amount in G$ (e.g. "5") + */ + getQuote(address: string, depositG: string, streamG: string): Promise + + /** + * Returns the current AI credits balance for the given buyer key. + * @param buyerKey - Buyer key public address (hex) + */ + getCreditsBalance(buyerKey: string): Promise + + /** + * Returns the usage log for the given buyer key. + * @param buyerKey - Buyer key public address (hex) + */ + getUsageLog(buyerKey: string): Promise + + /** + * Notifies the backend that a Celo payment tx has been submitted and returns the + * estimated credit amount. The backend polls for settlement on Base. + * @param buyerKey - Buyer key public address (hex) + * @param txHash - Submitted Celo transaction hash + */ + notifyPayment(buyerKey: string, txHash: string): Promise<{ estimatedCredits: string }> + + /** + * Polls the backend for credit settlement status after a payment. + * Resolves when credits are confirmed or throws on timeout/error. + * @param buyerKey - Buyer key public address (hex) + */ + waitForSettlement(buyerKey: string): Promise<{ credits: string }> +} + +// --------------------------------------------------------------------------- +// Mock backend client — returns deterministic fake data. +// Completely separate from any production code path; no if(mock) sprinkled in adapter. +// --------------------------------------------------------------------------- + +const MOCK_DELAY_MS = 600 + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Deterministic mock quote calculation: $1 G$ ≈ 0.0015 USD, 1 USD ≈ 100 credits */ +function calculateMockQuote( + depositG: string, + streamG: string, + isGoodIdVerified: boolean, +): AiCreditsQuote { + const deposit = Number.parseFloat(depositG) || 0 + const stream = Number.parseFloat(streamG) || 0 + const G_USD_RATE = 0.0015 + const depositUsd = deposit * G_USD_RATE + const streamUsd = stream * G_USD_RATE + const bonusPercent = stream > 0 && isGoodIdVerified ? 20 : 10 + const totalUsd = depositUsd + streamUsd + const totalCredits = totalUsd * 100 * (1 + bonusPercent / 100) + + return { + depositAmountG: deposit.toFixed(2), + streamAmountG: stream.toFixed(2), + depositAmountUsd: depositUsd.toFixed(4), + streamAmountUsd: streamUsd.toFixed(4), + bonusPercent, + totalCredits: totalCredits.toFixed(2), + } +} + +export class MockAiCreditsBackendClient implements AiCreditsBackendClient { + /** Whether to simulate the GoodID verified bonus in quote calculations */ + private readonly isGoodIdVerified: boolean + + constructor(options: { isGoodIdVerified?: boolean } = {}) { + this.isGoodIdVerified = options.isGoodIdVerified ?? false + } + + async getQuote(_address: string, depositG: string, streamG: string): Promise { + await sleep(MOCK_DELAY_MS) + return calculateMockQuote(depositG, streamG, this.isGoodIdVerified) + } + + async getCreditsBalance(_buyerKey: string): Promise { + await sleep(MOCK_DELAY_MS) + return '124.50' + } + + async getUsageLog(_buyerKey: string): Promise { + await sleep(MOCK_DELAY_MS) + return [ + { + sessionId: 'sess-001', + timestamp: '2025-06-20T10:00:00Z', + creditsUsed: 12.5, + model: 'claude-3-5-sonnet', + }, + { + sessionId: 'sess-002', + timestamp: '2025-06-21T14:30:00Z', + creditsUsed: 8.0, + model: 'gpt-4o', + }, + { + sessionId: 'sess-003', + timestamp: '2025-06-22T09:15:00Z', + creditsUsed: 22.0, + model: 'claude-3-5-sonnet', + }, + ] + } + + async notifyPayment( + _buyerKey: string, + _txHash: string, + ): Promise<{ estimatedCredits: string }> { + await sleep(MOCK_DELAY_MS) + return { estimatedCredits: '110.00' } + } + + async waitForSettlement(_buyerKey: string): Promise<{ credits: string }> { + // Simulate backend settlement taking 2 seconds + await sleep(2000) + return { credits: '110.00' } + } +} + +// --------------------------------------------------------------------------- +// Production backend client stub — calls backendUrl REST endpoints. +// Separated from mock so the adapter only receives a single client implementation. +// --------------------------------------------------------------------------- + +export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient { + private readonly backendUrl: string + + constructor(backendUrl: string) { + this.backendUrl = backendUrl.replace(/\/$/, '') + } + + async getQuote(address: string, depositG: string, streamG: string): Promise { + const url = `${this.backendUrl}/quote?address=${encodeURIComponent(address)}&depositG=${depositG}&streamG=${streamG}` + const response = await fetch(url) + if (!response.ok) throw new Error(`Quote request failed: ${response.status}`) + return response.json() as Promise + } + + async getCreditsBalance(buyerKey: string): Promise { + const url = `${this.backendUrl}/balance/${encodeURIComponent(buyerKey)}` + const response = await fetch(url) + if (!response.ok) throw new Error(`Balance request failed: ${response.status}`) + const data = (await response.json()) as { balance: string } + return data.balance + } + + async getUsageLog(buyerKey: string): Promise { + const url = `${this.backendUrl}/usage/${encodeURIComponent(buyerKey)}` + const response = await fetch(url) + if (!response.ok) throw new Error(`Usage log request failed: ${response.status}`) + return response.json() as Promise + } + + async notifyPayment( + buyerKey: string, + txHash: string, + ): Promise<{ estimatedCredits: string }> { + const url = `${this.backendUrl}/payment` + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ buyerKey, txHash }), + }) + if (!response.ok) throw new Error(`Payment notification failed: ${response.status}`) + return response.json() as Promise<{ estimatedCredits: string }> + } + + async waitForSettlement(buyerKey: string): Promise<{ credits: string }> { + const POLL_INTERVAL_MS = 3000 + const MAX_ATTEMPTS = 20 + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + await sleep(POLL_INTERVAL_MS) + const url = `${this.backendUrl}/settlement/${encodeURIComponent(buyerKey)}` + const response = await fetch(url) + if (!response.ok) continue + const data = (await response.json()) as { status: string; credits?: string } + if (data.status === 'settled' && data.credits) { + return { credits: data.credits } + } + } + + throw new Error('Settlement polling timeout — credits may still be arriving') + } +} + +// --------------------------------------------------------------------------- +// Factory helper — resolves the correct backend client based on context +// --------------------------------------------------------------------------- + +export function createBackendClient( + backendUrl: string | undefined, + options: { isGoodIdVerified?: boolean } = {}, +): AiCreditsBackendClient { + if (!backendUrl) { + return new MockAiCreditsBackendClient(options) + } + return new ProductionAiCreditsBackendClient(backendUrl) +} diff --git a/packages/ai-credits-widget/src/register.ts b/packages/ai-credits-widget/src/register.ts new file mode 100644 index 0000000..6762dd4 --- /dev/null +++ b/packages/ai-credits-widget/src/register.ts @@ -0,0 +1,27 @@ +import { AiCreditsWidgetElement } from './element' + +const DEFAULT_TAG_NAME = 'ai-credits-widget' + +/** + * Register the custom element. + * + * Call once at the top of your app or in a