diff --git a/.gitignore b/.gitignore
index e764fb8..82b2766 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,3 +18,7 @@ playwright-report/
local-reference-assets
.vercel
.env*
+
+
+.agents
+skills-lock.json
\ No newline at end of file
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..234098b
--- /dev/null
+++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx
@@ -0,0 +1,106 @@
+import { Canvas, Meta } from '@storybook/blocks';
+import * as ShowcaseStories from './AiCreditsWidgetShowcase.stories';
+import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout';
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All 10 widget states as deterministic mock fixtures — ideal for automation and debugging.
+
+
+
+
+
+ No wallet connected.
+ Wallet connected; buyer key / consent / amounts in progress.
+ Setup complete; on-chain quote built; ready to pay.
+ Celo tx submitted.
+ Settling on Base.
+ AI credits balance > 0; management dashboard with stream, history, and API setup.
+ G$ below minimum purchase threshold.
+ Transaction or settlement error.
+ Backend unreachable.
+ Wrong network (Celo required).
+
+
+
+
+
+ Credits profile and funding history for the payer wallet.
+ Pending or failed Base funding entries.
+ Notify `{ txHash }` after a successful Celo payment.
+
+ Buyer-signed SetOperator relay — backend submits `acceptBuyerOperator` on Base (no buyer gas).
+
+ Buyer-signed channel close from credits management.
+ Buyer-signed principal withdraw to the payer wallet.
+
+
+ Vault minimum reads, quotes, withdrawable principal, and Celo deposit/stream transactions are
+ handled on-chain via RPC. Buyer EIP-712 signing for operator consent, close, and withdraw
+ happens in the widget; only the Base submission is relayed by the worker. There is no
+ channel-list API — paste the channel ID manually.
+
+
+
+
+
+ Close channel and withdraw require the buyer private key in the current browser session. Use
+ Sign & Generate in Buyer & Operator on the credits management screen if the key was
+ lost after refresh.
+
+
+ First-deposit USD minimum applies only when `totalDepositedGd(payer)` is zero. Monthly stream
+ USD minimum applies on every stream create/update.
+
+
+ Operator consent, close, withdraw, and credit funding require the worker's Base operator
+ bridge to be configured (`bridge.enabled: true` in API responses).
+
+
+
+
+
+ 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..aab8f5a
--- /dev/null
+++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx
@@ -0,0 +1,69 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { AiCreditsWidget } from '@goodwidget/ai-credits-widget'
+import {
+ DisconnectedStory,
+ PurchaseSetupStory,
+ QuoteReadyStory,
+ QuoteReadyGoodIdStory,
+ PaymentPendingStory,
+ PaymentConfirmedStory,
+ CreditsManagementStory,
+ 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
+
+export const Disconnected: Story = {
+ render: () => ,
+}
+
+export const PurchaseSetup: Story = {
+ render: () => ,
+}
+
+export const QuoteReady: Story = {
+ render: () => ,
+}
+
+export const QuoteReadyGoodId: Story = {
+ render: () => ,
+}
+
+export const PaymentPending: Story = {
+ render: () => ,
+}
+
+export const PaymentConfirmed: Story = {
+ render: () => ,
+}
+
+export const CreditsManagement: Story = {
+ render: () => ,
+}
+
+export const InsufficientGBalance: Story = {
+ render: () => ,
+}
+
+export const PaymentFailed: Story = {
+ render: () => ,
+}
+
+export const BackendUnavailable: Story = {
+ render: () => ,
+}
+
+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..2937a3a
--- /dev/null
+++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetShowcase.stories.tsx
@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from '@storybook/react'
+import { AiCreditsWidget } from '@goodwidget/ai-credits-widget'
+import { InjectedWalletStory, MockBackendStory } 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
+
+export const MockBackend: Story = {
+ name: 'Mock Backend (browser wallet)',
+ render: () => ,
+}
+
+export const InjectedWallet: Story = {
+ name: 'Injected Wallet',
+ 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..b123781
--- /dev/null
+++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
@@ -0,0 +1,364 @@
+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'
+
+function createMockState(
+ status: AiCreditsWidgetStatus,
+ overrides: Partial = {},
+): AiCreditsWidgetAdapterState {
+ const base: AiCreditsWidgetAdapterState = {
+ status,
+ address: '0x329377cbeeF39f01b0Ea04B80465c9eB47D3ED1',
+ chainId: 42220,
+ gBalance: '42.50',
+ aiCreditsBalance: null,
+ isGoodIdVerified: false,
+ buyerKey: null,
+ buyerKeyPrivate: null,
+ buyerKeyConfirmed: false,
+ operatorConsentSigned: false,
+ operatorAddress: null,
+ apiKey: null,
+ depositAmount: '5',
+ streamAmount: '0',
+ minDepositG: '1',
+ minStreamG: '1',
+ bonusPercent: 10,
+ quote: null,
+ setupSnippet: null,
+ usageLog: [],
+ totalGdDepositedG: null,
+ monthlyStreamG: null,
+ monthlyStreamCredits: null,
+ withdrawableUsd: null,
+ channelId: '',
+ withdrawAmount: '',
+ error: null,
+ primaryAction: 'generate_key',
+ primaryLabel: 'Set Up Buyer Key',
+ }
+ return { ...base, ...overrides }
+}
+
+function createAdapterFactory(
+ status: AiCreditsWidgetStatus,
+ overrides: Partial = {},
+): AiCreditsWidgetAdapterFactory {
+ return () => ({
+ state: createMockState(status, overrides),
+ actions: {
+ connect: async () => {},
+ switchChain: async () => {},
+ generateBuyerKey: async () => {},
+ confirmBuyerKey: () => {},
+ signOperatorConsent: async () => {},
+ setDepositAmount: () => {},
+ setStreamAmount: () => {},
+ setChannelId: () => {},
+ setWithdrawAmount: () => {},
+ pay: async () => {},
+ refresh: async () => {},
+ startPurchase: () => {},
+ closeChannel: async () => {},
+ withdrawCredits: async () => {},
+ retry: async () => {},
+ },
+ })
+}
+
+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'}
+
+
+ )
+ }
+}
+
+const SETUP_SNIPPET = `export ANTSEED_IDENTITY_HEX=\nexport ANTHROPIC_BASE_URL=http://localhost:8377`
+
+export function DisconnectedStory() {
+ return (
+
+ )
+}
+
+export function PurchaseSetupStory() {
+ return (
+
+ )
+}
+
+export function QuoteReadyStory() {
+ return (
+
+ )
+}
+
+export function QuoteReadyGoodIdStory() {
+ return (
+
+ )
+}
+
+export function PaymentPendingStory() {
+ return (
+
+ )
+}
+
+export function PaymentConfirmedStory() {
+ return (
+
+ )
+}
+
+export function CreditsManagementStory() {
+ return (
+
+ )
+}
+
+export function InsufficientGBalanceStory() {
+ return (
+
+ )
+}
+
+export function PaymentFailedStory() {
+ return (
+
+ )
+}
+
+export function BackendUnavailableStory() {
+ return (
+
+ )
+}
+
+export function UnsupportedChainStory() {
+ return (
+
+ )
+}
+
+export function MockBackendStory() {
+ const injectedProvider = getInjectedEip1193Provider()
+
+ if (!isInjectedProviderUsable(injectedProvider)) {
+ return (
+
+ No injected wallet found
+
+ Install or enable Rabby (or another EIP-1193 wallet) in this browser, then refresh
+ Storybook.
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+export function InjectedWalletStory() {
+ const injectedProvider = getInjectedEip1193Provider()
+ const backendUrl = import.meta.env.VITE_AI_CREDITS_BACKEND_URL
+ const baseRpcUrl = import.meta.env.VITE_AI_CREDITS_BASE_RPC_URL
+ const fundingVaultAddress = import.meta.env.VITE_AI_CREDITS_FUNDING_VAULT_ADDRESS
+ const vaultAddress = import.meta.env.VITE_AI_CREDITS_VAULT_ADDRESS
+
+ 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..f9229d3
--- /dev/null
+++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx
@@ -0,0 +1,477 @@
+import React, { useCallback, useMemo } from 'react'
+import { GoodWidgetProvider } from '@goodwidget/core'
+import type { EIP1193Provider } from '@goodwidget/core'
+import {
+ Button,
+ ButtonText,
+ Card,
+ Text,
+ ToastContainer,
+ XStack,
+ YStack,
+ Spinner,
+ createToast,
+ updateToast,
+} from '@goodwidget/ui'
+import { useAiCreditsAdapter } from './adapter'
+import {
+ AiCreditsHero,
+ AiCreditsFlowStepper,
+ AiCreditsStatusNotice,
+ AmountPicker,
+ BuyerKeyPanel,
+ OperatorConsentStep,
+ CreditsManagementCard,
+ BuyerOperatorCard,
+ SetupSnippet,
+ UsageLog,
+} from './aiCreditsComponents'
+import type {
+ AiCreditsWidgetProps,
+ AiCreditsWidgetEnvironment,
+ AiCreditsPaySuccessDetail,
+ AiCreditsPayErrorDetail,
+ AiCreditsWidgetAdapterFactory,
+} from './widgetRuntimeContract'
+import { getPaymentAmountValidation, getPayDisabledMessage } from './vaultMinimums'
+
+// ---------------------------------------------------------------------------
+// Inner component — renders inside GoodWidgetProvider
+// ---------------------------------------------------------------------------
+
+interface AiCreditsInnerProps {
+ environment?: AiCreditsWidgetEnvironment
+ backendUrl?: string
+ baseRpcUrl?: string
+ fundingVaultAddress?: string
+ vaultAddress?: string
+ adapterFactory?: AiCreditsWidgetAdapterFactory
+ onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
+ onPayError?: (detail: AiCreditsPayErrorDetail) => void
+}
+
+function AiCreditsInner({
+ environment,
+ backendUrl,
+ baseRpcUrl,
+ fundingVaultAddress,
+ vaultAddress,
+ adapterFactory,
+ onPaySuccess,
+ onPayError,
+}: AiCreditsInnerProps) {
+ const defaultAdapter = useAiCreditsAdapter({
+ environment,
+ backendUrl,
+ baseRpcUrl,
+ fundingVaultAddress: fundingVaultAddress as `0x${string}` | undefined,
+ vaultAddress: vaultAddress as `0x${string}` | undefined,
+ onPaySuccess,
+ onPayError,
+ })
+
+ const activeAdapter = useMemo(
+ () =>
+ adapterFactory
+ ? adapterFactory({ environment, backendUrl })
+ : defaultAdapter,
+ [adapterFactory, environment, backendUrl, defaultAdapter],
+ )
+
+ const { state, actions } = activeAdapter
+
+ const paymentValidation = useMemo(
+ () =>
+ getPaymentAmountValidation({
+ depositAmount: state.depositAmount,
+ streamAmount: state.streamAmount,
+ minDepositG: state.minDepositG,
+ minStreamG: state.minStreamG,
+ gBalance: state.gBalance,
+ }),
+ [
+ state.depositAmount,
+ state.streamAmount,
+ state.minDepositG,
+ state.minStreamG,
+ state.gBalance,
+ ],
+ )
+
+ const minsLoaded = state.minDepositG !== null && state.minStreamG !== null
+ const canPay =
+ state.status === 'quote_ready' &&
+ minsLoaded &&
+ paymentValidation.vaultMinimumsMet &&
+ !paymentValidation.overBalance
+
+ const payDisabledMessage = getPayDisabledMessage({
+ canPay,
+ minsLoaded,
+ status: state.status,
+ minDepositG: state.minDepositG,
+ minStreamG: state.minStreamG,
+ validation: paymentValidation,
+ })
+
+ 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':
+ await 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: credits management dashboard
+ // ---------------------------------------------------------------------------
+
+ const isPostPurchase = state.status === 'credits_management'
+
+ if (isPostPurchase) {
+ return (
+
+ {state.error && (
+
+
+ {state.error}
+
+
+ )}
+
+
+
+
+
+ {state.setupSnippet && }
+
+
+
+
+
+ )
+ }
+
+ // ---------------------------------------------------------------------------
+ // 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 purchase flow (purchase_setup → quote_ready)
+ // ---------------------------------------------------------------------------
+
+ return (
+
+ {state.address && (
+
+ )}
+
+ {state.gBalance !== null && Number.parseFloat(state.gBalance) <= 0 && (
+
+ You need G$ before you can buy AI credits.
+
+ )}
+
+
+
+ {/* Step panels — shown progressively */}
+ {state.address && !state.buyerKey && !state.operatorConsentSigned && (
+
+ )}
+
+ {state.buyerKey && !state.buyerKeyConfirmed && !state.operatorConsentSigned && (
+
+ )}
+
+ {state.buyerKey && state.buyerKeyConfirmed && !state.operatorConsentSigned && (
+
+ )}
+
+ {state.operatorConsentSigned && (
+ {
+ void handlePay()
+ }}
+ />
+ )}
+
+ {!state.operatorConsentSigned &&
+ state.primaryAction !== 'none' &&
+ state.primaryAction !== 'generate_key' &&
+ state.primaryAction !== 'sign_consent' && (
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 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 (real private key, user must save it)
+ * 3. Sign backend-issued nonce → receive `gd_live_...` API key
+ * 4. Set deposit / stream amounts
+ * 5. Submit G$ approve + CeloGdAntSeedVault.deposit (buyer address ABI-encoded)
+ * 6. Wait for credit settlement (Worker verifies vault events)
+ * 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,
+ baseRpcUrl,
+ fundingVaultAddress,
+ vaultAddress,
+ 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..deb62ae
--- /dev/null
+++ b/packages/ai-credits-widget/src/adapter.ts
@@ -0,0 +1,1004 @@
+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,
+ type Address,
+ type Chain,
+} from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { buildBuyerKeyMessage, deriveBuyerPrivateKeyFromSignature } from './buyerKeyDerivation'
+import {
+ normalizeChannelId,
+ signRequestClose,
+ signWithdrawPrincipal,
+} from './buyerSignatures'
+import {
+ MockAiCreditsBackendClient,
+ balanceFromProfile,
+ buildAccountView,
+ createBackendClient,
+ enrichAccountView,
+ waitForOperatorConsent,
+} from './backendClient'
+import type { AccountEnrichment, AiCreditsBackendClient } from './backendClient'
+import type { AccountRef, AccountView } from './backendTypes'
+import { createChainClient, CELO_GD_ANTSEED_VAULT_ADDRESS } from './chainClient'
+import type { AiCreditsChainClient } from './chainClient'
+import { signOperatorConsentFromTypedData } from './operatorConsent'
+import { executeCeloPayment, G_TOKEN_CELO_ADDRESS } from './celoPayment'
+import { fetchVaultPaymentMinimums, validateVaultPaymentAmounts } from './vaultMinimums'
+import { CREDITS_PER_USD, usdDisplayToMicro } from './quoteMath'
+import type {
+ AiCreditsWidgetAdapterActions,
+ AiCreditsWidgetAdapterResult,
+ AiCreditsWidgetAdapterState,
+ AiCreditsWidgetEnvironment,
+ AiCreditsWidgetPrimaryAction,
+ AiCreditsWidgetStatus,
+ AiCreditsPaySuccessDetail,
+ AiCreditsPayErrorDetail,
+ AiCreditsQuote,
+} from './widgetRuntimeContract'
+
+const CELO_CHAIN_ID = 42220
+const MIN_DEPOSIT_AMOUNT = '1'
+const MIN_STREAM_AMOUNT = '1'
+const CELO_GD_ANTSEED_VAULT_FALLBACK: Address = CELO_GD_ANTSEED_VAULT_ADDRESS
+
+const G_TOKEN_ABI = parseAbi([
+ 'function balanceOf(address account) view returns (uint256)',
+ 'function decimals() view returns (uint8)',
+])
+
+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'] },
+ },
+}
+
+const INITIAL_STATE: AiCreditsWidgetAdapterState = {
+ status: 'disconnected',
+ address: null,
+ chainId: null,
+ gBalance: null,
+ aiCreditsBalance: null,
+ isGoodIdVerified: false,
+ buyerKey: null,
+ buyerKeyPrivate: null,
+ buyerKeyConfirmed: false,
+ operatorConsentSigned: false,
+ operatorAddress: null,
+ apiKey: null,
+ depositAmount: MIN_DEPOSIT_AMOUNT,
+ streamAmount: '0',
+ minDepositG: null,
+ minStreamG: null,
+ bonusPercent: 10,
+ quote: null,
+ setupSnippet: null,
+ usageLog: [],
+ totalGdDepositedG: null,
+ monthlyStreamG: null,
+ monthlyStreamCredits: null,
+ withdrawableUsd: null,
+ channelId: '',
+ withdrawAmount: '',
+ error: null,
+ primaryAction: 'connect',
+ primaryLabel: 'Connect Wallet',
+}
+
+function hasCredits(balance: string | null): boolean {
+ return balance !== null && Number.parseFloat(balance) > 0
+}
+
+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: AiCreditsWidgetStatus
+}): AiCreditsWidgetStatus {
+ const {
+ isConnected,
+ chainId,
+ gBalance,
+ aiCreditsBalance,
+ buyerKey,
+ buyerKeyConfirmed,
+ operatorConsentSigned,
+ depositAmount,
+ streamAmount,
+ error,
+ currentStatus,
+ } = params
+
+ 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 !== 'credits_management') return 'payment_failed'
+
+ const inPurchaseFlow =
+ currentStatus === 'purchase_setup' || currentStatus === 'quote_ready'
+
+ if (!inPurchaseFlow && hasCredits(aiCreditsBalance)) return 'credits_management'
+
+ if (gBalance === null) return 'purchase_setup'
+
+ const balance = Number.parseFloat(gBalance)
+ if (balance <= 0) return 'purchase_setup'
+
+ 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 || !buyerKeyConfirmed || !operatorConsentSigned) return 'purchase_setup'
+
+ const readyToPay = (hasValidDeposit || stream >= minStream) && hasValidStream
+ if (readyToPay) return 'quote_ready'
+
+ return 'purchase_setup'
+}
+
+function derivePrimaryAction(status: AiCreditsWidgetStatus): AiCreditsWidgetPrimaryAction {
+ switch (status) {
+ case 'disconnected':
+ return 'connect'
+ case 'unsupported_chain':
+ return 'switch_chain'
+ case 'purchase_setup':
+ 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 'credits_management':
+ case 'insufficient_g_balance':
+ return 'refresh'
+ default:
+ return 'none'
+ }
+}
+
+function derivePrimaryLabel(action: AiCreditsWidgetPrimaryAction): 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 ''
+ }
+}
+
+function withDerivedStatus(
+ prev: AiCreditsWidgetAdapterState,
+ overrides: Partial,
+ isConnected = true,
+): AiCreditsWidgetAdapterState {
+ const merged = { ...prev, ...overrides }
+ const status = deriveStatus({
+ isConnected,
+ chainId: merged.chainId,
+ gBalance: merged.gBalance,
+ aiCreditsBalance: merged.aiCreditsBalance,
+ buyerKey: merged.buyerKey,
+ buyerKeyConfirmed: merged.buyerKeyConfirmed,
+ operatorConsentSigned: merged.operatorConsentSigned,
+ depositAmount: merged.depositAmount,
+ streamAmount: merged.streamAmount,
+ error: merged.error,
+ currentStatus: merged.status,
+ })
+ const primaryAction = derivePrimaryAction(status)
+ return {
+ ...merged,
+ status,
+ primaryAction,
+ primaryLabel: derivePrimaryLabel(primaryAction),
+ }
+}
+
+function mergeStatePreservingManagement(
+ prev: AiCreditsWidgetAdapterState,
+ overrides: Partial,
+): AiCreditsWidgetAdapterState {
+ if (prev.status !== 'credits_management') {
+ return withDerivedStatus(prev, overrides, true)
+ }
+ return {
+ ...prev,
+ ...overrides,
+ status: 'credits_management',
+ primaryAction: 'refresh',
+ primaryLabel: 'Refresh',
+ }
+}
+
+function viewToStatePatch(
+ view: AccountView,
+ enriched: AccountEnrichment,
+ prev: AiCreditsWidgetAdapterState,
+ options?: { usageLog?: AiCreditsWidgetAdapterState['usageLog']; balanceMode?: 'if_positive' | 'always' },
+): Partial {
+ const operatorAccepted = view.operator.operatorAccepted
+ const buyer = enriched.buyer
+ const balance = enriched.balance
+ const balanceMode = options?.balanceMode ?? 'if_positive'
+ const keyForSnippet = buyer ?? prev.buyerKey
+
+ return {
+ aiCreditsBalance:
+ balanceMode === 'always' || hasCredits(balance) ? balance : prev.aiCreditsBalance,
+ isGoodIdVerified: enriched.goodIdVerified,
+ buyerKey: buyer ?? prev.buyerKey,
+ buyerKeyConfirmed: buyer && operatorAccepted ? true : prev.buyerKeyConfirmed,
+ operatorConsentSigned: operatorAccepted,
+ operatorAddress: view.operator.operatorAddress ?? null,
+ withdrawableUsd: view.withdrawableUsd,
+ setupSnippet:
+ keyForSnippet && hasCredits(balance) ? buildSetupSnippet(keyForSnippet) : prev.setupSnippet,
+ bonusPercent: enriched.bonusPercent,
+ totalGdDepositedG: enriched.totalGdDepositedG,
+ monthlyStreamG: enriched.monthlyStreamG,
+ monthlyStreamCredits: enriched.monthlyStreamCredits,
+ ...(options?.usageLog !== undefined ? { usageLog: options.usageLog } : {}),
+ }
+}
+
+export interface UseAiCreditsAdapterOptions {
+ environment?: AiCreditsWidgetEnvironment
+ backendUrl?: string
+ baseRpcUrl?: string
+ fundingVaultAddress?: Address
+ vaultAddress?: Address
+ onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
+ onPayError?: (detail: AiCreditsPayErrorDetail) => void
+}
+
+export function useAiCreditsAdapter({
+ backendUrl,
+ baseRpcUrl,
+ fundingVaultAddress,
+ vaultAddress,
+ onPaySuccess,
+ onPayError,
+}: UseAiCreditsAdapterOptions): AiCreditsWidgetAdapterResult {
+ const { address, chainId, isConnected, provider, connect } = useWallet()
+ const [state, setState] = useState(INITIAL_STATE)
+
+ const providerRef = useRef(null)
+ providerRef.current = provider as EIP1193Provider | null
+
+ const celoVault = vaultAddress ?? CELO_GD_ANTSEED_VAULT_FALLBACK
+
+ const backendClient = useMemo(
+ () => createBackendClient(backendUrl),
+ [backendUrl],
+ )
+
+ const chainClient = useMemo(
+ () =>
+ createChainClient(backendUrl, {
+ baseRpcUrl,
+ fundingVaultAddress,
+ celoVaultAddress: celoVault,
+ }),
+ [backendUrl, baseRpcUrl, fundingVaultAddress, celoVault],
+ )
+
+ useEffect(() => {
+ if (!isConnected || !address) {
+ setState({ ...INITIAL_STATE })
+ return
+ }
+
+ let cancelled = false
+
+ async function loadWalletData() {
+ const publicClient = createPublicClient({ chain: CELO_CHAIN, transport: http() })
+ const balancePromise = 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',
+ }),
+ ])
+
+ const accountPromise = buildAccountView(address!, backendClient, chainClient)
+ .then(async (view) => ({
+ view,
+ enriched: await enrichAccountView(view, chainClient),
+ }))
+ .catch(() => null)
+
+ const minimumsPromise =
+ backendClient instanceof MockAiCreditsBackendClient
+ ? Promise.resolve({
+ minDepositG: '1',
+ minStreamG: '1',
+ minDepositUsd: '1.00',
+ minStreamUsd: '1.00',
+ })
+ : fetchVaultPaymentMinimums(publicClient, celoVault, address as Address).catch(() => null)
+
+ try {
+ const [[rawBalance, decimals], account, minimums] = await Promise.all([
+ balancePromise,
+ accountPromise,
+ minimumsPromise,
+ ])
+ if (cancelled) return
+
+ const patch: Partial = {
+ address,
+ chainId,
+ gBalance: formatUnits(rawBalance as bigint, decimals as number),
+ minDepositG: minimums?.minDepositG ?? null,
+ minStreamG: minimums?.minStreamG ?? null,
+ }
+
+ setState((prev) => {
+ const accountPatch = account
+ ? viewToStatePatch(account.view, account.enriched, prev, { balanceMode: 'if_positive' })
+ : {}
+ return withDerivedStatus(prev, { ...patch, ...accountPatch }, true)
+ })
+ } catch {
+ if (cancelled) return
+ setState((prev) =>
+ withDerivedStatus(
+ prev,
+ {
+ address,
+ chainId,
+ gBalance: '0',
+ status:
+ chainId !== null && chainId !== CELO_CHAIN_ID
+ ? 'unsupported_chain'
+ : 'purchase_setup',
+ 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',
+ },
+ true,
+ ),
+ )
+ }
+ }
+
+ void loadWalletData()
+ return () => {
+ cancelled = true
+ }
+ }, [isConnected, address, chainId, backendClient, chainClient, celoVault])
+
+ useEffect(() => {
+ if (!isConnected || !address) return
+ if (!state.operatorConsentSigned) return
+ if (state.status === 'payment_pending' || state.status === 'payment_confirmed') return
+
+ let cancelled = false
+
+ async function refreshQuote() {
+ try {
+ const quote = await chainClient.buildQuote(
+ state.depositAmount,
+ state.streamAmount,
+ state.isGoodIdVerified,
+ )
+ if (cancelled) return
+ setState((prev) =>
+ withDerivedStatus(prev, { quote, bonusPercent: quote.bonusPercent }, true),
+ )
+ } catch {
+ if (!cancelled) setState((prev) => ({ ...prev, quote: null }))
+ }
+ }
+
+ void refreshQuote()
+ return () => {
+ cancelled = true
+ }
+ }, [
+ isConnected,
+ address,
+ state.operatorConsentSigned,
+ state.depositAmount,
+ state.streamAmount,
+ state.isGoodIdVerified,
+ state.status,
+ chainClient,
+ ])
+
+ 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(async () => {
+ if (!address || !providerRef.current) {
+ setState((prev) => ({
+ ...prev,
+ error: 'Connect your wallet before generating a buyer key',
+ }))
+ return
+ }
+
+ try {
+ const payerAddress = address as Address
+ const message = buildBuyerKeyMessage(payerAddress)
+ const walletClient = createWalletClient({
+ account: payerAddress,
+ chain: CELO_CHAIN,
+ transport: custom(providerRef.current),
+ })
+ const signature = await walletClient.signMessage({
+ account: payerAddress,
+ message,
+ })
+ const privateKey = deriveBuyerPrivateKeyFromSignature(signature)
+ const account = privateKeyToAccount(privateKey)
+
+ setState((prev) => {
+ const inManagement = prev.status === 'credits_management'
+ return mergeStatePreservingManagement(prev, {
+ buyerKey: account.address,
+ buyerKeyPrivate: privateKey,
+ buyerKeyConfirmed: inManagement,
+ operatorConsentSigned: false,
+ apiKey: null,
+ error: null,
+ ...(inManagement && hasCredits(prev.aiCreditsBalance)
+ ? { setupSnippet: buildSetupSnippet(account.address) }
+ : {}),
+ ...(!inManagement ? { status: 'purchase_setup' } : {}),
+ })
+ })
+ } catch (err: unknown) {
+ setState((prev) => ({
+ ...prev,
+ error: err instanceof Error ? err.message : 'Buyer key generation was rejected',
+ }))
+ }
+ }, [address])
+
+ const handleConfirmBuyerKey = useCallback(() => {
+ setState((prev) => withDerivedStatus(prev, { buyerKeyConfirmed: true, status: 'purchase_setup' }, true))
+ }, [])
+
+ const handleSignOperatorConsent = useCallback(async () => {
+ const currentState = state
+ if (!currentState.address || !currentState.buyerKey || !currentState.buyerKeyPrivate) {
+ setState((prev) => ({
+ ...prev,
+ error: 'Generate a buyer key before signing operator consent',
+ }))
+ return
+ }
+
+ const ref: AccountRef = { payer: currentState.address, buyer: currentState.buyerKey }
+ const inManagement = currentState.status === 'credits_management'
+
+ try {
+ const operatorStatus = await chainClient.getBuyerOperatorStatus(ref)
+
+ if (!operatorStatus.enabled) {
+ throw new Error('Operator consent is not available')
+ }
+
+ if (operatorStatus.operatorAccepted) {
+ setState((prev) =>
+ mergeStatePreservingManagement(prev, {
+ operatorConsentSigned: true,
+ error: null,
+ ...(!inManagement ? { status: 'purchase_setup' } : {}),
+ }),
+ )
+ return
+ }
+
+ const payload = await chainClient.buildOperatorConsentPayload(ref, operatorStatus)
+
+ if (!payload.enabled || !payload.typedData) {
+ throw new Error('Operator consent is not available')
+ }
+
+ const buyerSig = await signOperatorConsentFromTypedData(
+ currentState.buyerKeyPrivate as `0x${string}`,
+ payload.typedData,
+ )
+
+ await backendClient.submitOperatorConsent(ref.buyer, {
+ nonce: operatorStatus.consentNonce,
+ signature: buyerSig,
+ })
+ await waitForOperatorConsent(chainClient, ref)
+
+ setState((prev) =>
+ mergeStatePreservingManagement(prev, {
+ operatorConsentSigned: true,
+ error: null,
+ ...(!inManagement ? { status: 'purchase_setup' } : {}),
+ }),
+ )
+ } catch (err: unknown) {
+ setState((prev) => ({
+ ...prev,
+ error: err instanceof Error ? err.message : 'Operator consent signature rejected',
+ }))
+ }
+ }, [state, backendClient, chainClient])
+
+ const handleSetDepositAmount = useCallback((amount: string) => {
+ setState((prev) =>
+ withDerivedStatus(
+ prev,
+ {
+ depositAmount: amount,
+ status: prev.status === 'payment_pending' ? 'payment_pending' : 'purchase_setup',
+ },
+ true,
+ ),
+ )
+ }, [])
+
+ const handleSetStreamAmount = useCallback((amount: string) => {
+ setState((prev) =>
+ withDerivedStatus(
+ prev,
+ {
+ streamAmount: amount,
+ status: prev.status === 'payment_pending' ? 'payment_pending' : 'purchase_setup',
+ },
+ true,
+ ),
+ )
+ }, [])
+
+ const handleSetChannelId = useCallback((channelId: string) => {
+ setState((prev) => ({ ...prev, channelId }))
+ }, [])
+
+ const handleSetWithdrawAmount = useCallback((amount: string) => {
+ setState((prev) => ({ ...prev, withdrawAmount: amount }))
+ }, [])
+
+ const handlePay = useCallback(async () => {
+ const currentState = state
+
+ if (!currentState.address || !currentState.buyerKey || !providerRef.current) return
+
+ const depositAmountG = Number.parseFloat(currentState.depositAmount)
+ const streamAmountG = Number.parseFloat(currentState.streamAmount)
+ const hasDeposit = depositAmountG > 0
+ const hasStream = streamAmountG > 0
+ if (!hasDeposit && !hasStream) return
+
+ let quote: AiCreditsQuote | null = currentState.quote
+ try {
+ if (!quote) {
+ quote = await chainClient.buildQuote(
+ currentState.depositAmount,
+ currentState.streamAmount,
+ currentState.isGoodIdVerified,
+ )
+ }
+ } catch {
+ setState((prev) => ({
+ ...prev,
+ status: 'backend_unavailable',
+ primaryAction: 'retry',
+ primaryLabel: 'Retry',
+ error: 'Could not build quote — check chain connectivity',
+ }))
+ return
+ }
+
+ if (!quote) return
+
+ if (!(backendClient instanceof MockAiCreditsBackendClient)) {
+ try {
+ const publicClient = createPublicClient({ chain: CELO_CHAIN, transport: http() })
+ await validateVaultPaymentAmounts({
+ publicClient,
+ vault: celoVault,
+ payer: currentState.address as Address,
+ depositAmount: currentState.depositAmount,
+ streamAmount: currentState.streamAmount,
+ })
+ } catch (error) {
+ const message = error instanceof Error ? error.message : 'Payment amount below vault minimum'
+ setState((prev) => ({
+ ...prev,
+ status: 'payment_failed',
+ primaryAction: 'retry',
+ primaryLabel: 'Retry',
+ error: message,
+ }))
+ onPayError?.({
+ address: currentState.address,
+ chainId: CELO_CHAIN_ID,
+ message,
+ })
+ return
+ }
+ }
+
+ setState((prev) => ({
+ ...prev,
+ quote,
+ status: 'payment_pending',
+ primaryAction: 'none',
+ primaryLabel: 'Processing…',
+ error: null,
+ }))
+
+ try {
+ const vault = celoVault
+ const payerAddress = currentState.address as Address
+ const buyerAddress = currentState.buyerKey as Address
+
+ const publicClient = createPublicClient({ chain: CELO_CHAIN, transport: http() })
+ const walletClient = createWalletClient({
+ account: payerAddress,
+ chain: CELO_CHAIN,
+ transport: custom(providerRef.current),
+ })
+
+ const accountRef: AccountRef = {
+ payer: currentState.address,
+ buyer: currentState.buyerKey,
+ }
+
+ if (backendClient instanceof MockAiCreditsBackendClient) {
+ const creditUsdMicro = BigInt(
+ Math.round(Number.parseFloat(quote.totalCredits) * CREDITS_PER_USD),
+ )
+ backendClient.prepareSettlement(accountRef, creditUsdMicro)
+ }
+
+ const { txHashes } = await executeCeloPayment({
+ walletClient,
+ publicClient,
+ payer: payerAddress,
+ buyer: buyerAddress,
+ vault,
+ depositAmountG,
+ streamAmountG,
+ })
+
+ const txHash = txHashes[txHashes.length - 1]!
+
+ setState((prev) => ({
+ ...prev,
+ status: 'payment_confirmed',
+ primaryAction: 'none',
+ primaryLabel: 'Settling…',
+ }))
+
+ let balanceBefore = '0'
+ try {
+ const credit = await backendClient.getAccountCredit(currentState.address)
+ balanceBefore = balanceFromProfile(credit.profile)
+ } catch {
+ balanceBefore = '0'
+ }
+
+ for (const hash of txHashes) {
+ await backendClient.notifyPayment(hash)
+ }
+ const { credits } = await backendClient.waitForSettlement(accountRef, {
+ txHashes,
+ previousBalance: balanceBefore,
+ })
+
+ const setupSnippet = buildSetupSnippet(currentState.buyerKey)
+
+ setState((prev) =>
+ withDerivedStatus(prev, {
+ aiCreditsBalance: credits,
+ setupSnippet,
+ error: null,
+ status: 'credits_management',
+ primaryAction: 'refresh',
+ primaryLabel: 'Refresh',
+ }),
+ )
+
+ 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, chainClient, celoVault, onPaySuccess, onPayError])
+
+ const handleRefresh = useCallback(async () => {
+ const currentState = state
+ if (!currentState.address) return
+
+ try {
+ const [view, usageLog] = await Promise.all([
+ buildAccountView(currentState.address, backendClient, chainClient),
+ backendClient.getUsageLog(currentState.address),
+ ])
+ const enriched = await enrichAccountView(view, chainClient)
+
+ setState((prev) =>
+ withDerivedStatus(
+ prev,
+ {
+ ...viewToStatePatch(view, enriched, prev, {
+ usageLog,
+ balanceMode: 'always',
+ }),
+ status: hasCredits(enriched.balance) ? 'credits_management' : 'purchase_setup',
+ },
+ true,
+ ),
+ )
+ } catch {
+ setState((prev) => ({
+ ...prev,
+ status: 'backend_unavailable',
+ primaryAction: 'retry',
+ primaryLabel: 'Retry',
+ error: 'Could not reach backend — check your connection',
+ }))
+ }
+ }, [state, backendClient, chainClient])
+
+ const handleCloseChannel = useCallback(async () => {
+ const currentState = state
+ const channelId = normalizeChannelId(currentState.channelId)
+ if (!channelId) {
+ setState((prev) => ({
+ ...prev,
+ error: 'Enter a valid channel ID (0x followed by 64 hex characters)',
+ }))
+ return
+ }
+ if (!currentState.buyerKeyPrivate) {
+ setState((prev) => ({
+ ...prev,
+ error: 'Sign with your payer wallet in Buyer & Operator below to generate the buyer private key before closing a channel',
+ }))
+ return
+ }
+ if (!fundingVaultAddress) {
+ setState((prev) => ({
+ ...prev,
+ error: 'Funding vault address is not configured',
+ }))
+ return
+ }
+
+ try {
+ const timestamp = Math.floor(Date.now() / 1000)
+ const signature = await signRequestClose({
+ buyerPrivateKey: currentState.buyerKeyPrivate as `0x${string}`,
+ fundingVaultAddress,
+ channelId,
+ timestamp,
+ })
+
+ await backendClient.closeChannel(channelId, { timestamp, signature })
+ setState((prev) => ({ ...prev, error: null, channelId: '' }))
+ } catch (err: unknown) {
+ setState((prev) => ({
+ ...prev,
+ error: err instanceof Error ? err.message : 'Close channel failed',
+ }))
+ }
+ }, [state, backendClient, fundingVaultAddress])
+
+ const handleWithdrawCredits = useCallback(async () => {
+ const currentState = state
+ if (!currentState.address || !currentState.buyerKey) return
+ if (!currentState.buyerKeyPrivate) {
+ setState((prev) => ({
+ ...prev,
+ error:
+ 'Sign with your payer wallet in Buyer & Operator below to generate the buyer private key before withdrawing funds',
+ }))
+ return
+ }
+ if (!fundingVaultAddress) {
+ setState((prev) => ({
+ ...prev,
+ error: 'Funding vault address is not configured',
+ }))
+ return
+ }
+ if (!currentState.withdrawAmount.trim()) {
+ setState((prev) => ({ ...prev, error: 'Enter an amount to withdraw' }))
+ return
+ }
+
+ try {
+ const amount = usdDisplayToMicro(currentState.withdrawAmount.trim())
+ const withdrawable = BigInt(currentState.withdrawableUsd ?? '0')
+ if (BigInt(amount) <= 0n) {
+ setState((prev) => ({ ...prev, error: 'Enter a valid USD amount' }))
+ return
+ }
+ if (BigInt(amount) > withdrawable) {
+ setState((prev) => ({ ...prev, error: 'Amount exceeds withdrawable principal' }))
+ return
+ }
+
+ const buyer = currentState.buyerKey as Address
+ const payer = currentState.address as Address
+ const timestamp = Math.floor(Date.now() / 1000)
+ const signature = await signWithdrawPrincipal({
+ buyerPrivateKey: currentState.buyerKeyPrivate as `0x${string}`,
+ fundingVaultAddress,
+ buyer,
+ amountMicro: BigInt(amount),
+ recipient: payer,
+ timestamp,
+ })
+
+ await backendClient.withdrawCredits(buyer, {
+ amount,
+ recipient: payer,
+ timestamp,
+ signature,
+ })
+ setState((prev) => ({ ...prev, error: null, withdrawAmount: '' }))
+ await handleRefresh()
+ } catch (err: unknown) {
+ setState((prev) => ({
+ ...prev,
+ error: err instanceof Error ? err.message : 'Withdraw failed',
+ }))
+ }
+ }, [state, backendClient, fundingVaultAddress, handleRefresh])
+
+ const handleRetry = useCallback(async () => {
+ setState((prev) =>
+ withDerivedStatus(prev, { status: 'purchase_setup', error: null }, true),
+ )
+ }, [])
+
+ const handleStartPurchase = useCallback(() => {
+ setState((prev) =>
+ withDerivedStatus(prev, { status: 'purchase_setup', error: null }, true),
+ )
+ }, [])
+
+ const actions: AiCreditsWidgetAdapterActions = useMemo(
+ () => ({
+ connect: handleConnect,
+ switchChain: handleSwitchChain,
+ generateBuyerKey: handleGenerateBuyerKey,
+ confirmBuyerKey: handleConfirmBuyerKey,
+ signOperatorConsent: handleSignOperatorConsent,
+ setDepositAmount: handleSetDepositAmount,
+ setStreamAmount: handleSetStreamAmount,
+ setChannelId: handleSetChannelId,
+ setWithdrawAmount: handleSetWithdrawAmount,
+ pay: handlePay,
+ refresh: handleRefresh,
+ startPurchase: handleStartPurchase,
+ closeChannel: handleCloseChannel,
+ withdrawCredits: handleWithdrawCredits,
+ retry: handleRetry,
+ }),
+ [
+ handleConnect,
+ handleSwitchChain,
+ handleGenerateBuyerKey,
+ handleConfirmBuyerKey,
+ handleSignOperatorConsent,
+ handleSetDepositAmount,
+ handleSetStreamAmount,
+ handleSetChannelId,
+ handleSetWithdrawAmount,
+ handlePay,
+ handleRefresh,
+ handleStartPurchase,
+ handleCloseChannel,
+ handleWithdrawCredits,
+ handleRetry,
+ ],
+ )
+
+ return { state, actions }
+}
+
+function buildSetupSnippet(buyerAddress: string): string {
+ return [
+ 'npm install -g @antseed/cli',
+ '',
+ 'export ANTSEED_IDENTITY_HEX=',
+ '',
+ 'antseed buyer start',
+ 'antseed network browse',
+ 'antseed buyer connection set --peer ',
+ '',
+ 'export ANTHROPIC_BASE_URL=http://localhost:8377',
+ 'export OPENAI_BASE_URL=http://localhost:8377',
+ 'export OPENAI_API_KEY=placeholder',
+ '',
+ `GOODDOLLAR_BUYER_ADDRESS=${buyerAddress}`,
+ ].join('\n')
+}
diff --git a/packages/ai-credits-widget/src/aiCreditsComponents.tsx b/packages/ai-credits-widget/src/aiCreditsComponents.tsx
new file mode 100644
index 0000000..91227ad
--- /dev/null
+++ b/packages/ai-credits-widget/src/aiCreditsComponents.tsx
@@ -0,0 +1,1205 @@
+import React, { useState } from 'react'
+import {
+ createComponent,
+ Card,
+ Heading,
+ Text,
+ Anchor,
+ Button,
+ ButtonText,
+ XStack,
+ YStack,
+ Separator,
+ Spinner,
+ Input,
+ Icon,
+ TokenAmount,
+ Stepper,
+ copyTextToClipboard,
+} from '@goodwidget/ui'
+import type { StepperStepItem } from '@goodwidget/ui'
+import type {
+ AiCreditsWidgetAdapterActions,
+ AiCreditsWidgetAdapterState,
+ AiCreditsUsageEntry,
+ AiCreditsQuote,
+} from './widgetRuntimeContract'
+import { formatUsdMicro, parseGAmount } from './quoteMath'
+import { formatMinGDisplayLocale, getPaymentAmountValidation } from './vaultMinimums'
+
+const monospaceSingleLineStyle: React.CSSProperties = {
+ fontFamily: 'monospace',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+}
+
+function truncateAddress(address: string): string {
+ return `${address.slice(0, 10)}…${address.slice(-6)}`
+}
+
+function useCopyFeedback() {
+ const [copied, setCopied] = useState(false)
+ const copy = async (text: string) => {
+ if (!(await copyTextToClipboard(text))) return
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+ return { copied, copy }
+}
+
+function InfoTooltip({ message }: { message: string }) {
+ const [open, setOpen] = useState(false)
+
+ return (
+ setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ >
+
+ {open && }
+
+ )
+}
+
+function TooltipBubble({ message }: { message: string }) {
+ return (
+
+
+ {message}
+
+
+ )
+}
+
+function HoverTooltip({
+ message,
+ children,
+ fullWidth = false,
+}: {
+ message: string | null
+ children: React.ReactNode
+ fullWidth?: boolean
+}) {
+ const [open, setOpen] = useState(false)
+ if (!message) return <>{children}>
+
+ return (
+ setOpen(true)}
+ onMouseLeave={() => setOpen(false)}
+ onFocus={() => setOpen(true)}
+ onBlur={() => setOpen(false)}
+ >
+ {children}
+ {open && }
+
+ )
+}
+
+const BUYER_KEY_REQUIRED_CLOSE_TOOLTIP =
+ 'Sign with your payer wallet in Buyer & Operator below to generate the buyer private key before closing a channel.'
+
+const BUYER_KEY_REQUIRED_WITHDRAW_TOOLTIP =
+ 'Sign with your payer wallet in Buyer & Operator below to generate the buyer private key before withdrawing funds.'
+
+const WITHDRAW_TOOLTIP =
+ 'Withdraws principal to your payer wallet (not bonus). Requires the buyer private key from this session.'
+
+function AddressView({ label, address }: { label: string; address: string }) {
+ const { copied, copy } = useCopyFeedback()
+
+ return (
+
+
+ {label}
+
+
+
+ {truncateAddress(address)}
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 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 management dashboard card */
+export const CreditsManagementCardFrame = createComponent(Card, {
+ name: 'CreditsManagementCard',
+ extends: 'Card',
+ gap: '$4',
+})
+
+/** Buyer and operator management card */
+export const BuyerOperatorCardFrame = createComponent(Card, {
+ name: 'BuyerOperatorCard',
+ 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
+ buyerKeyPrivate: string | null
+ buyerKeyConfirmed: boolean
+ onGenerate: () => void | Promise
+ onConfirm: () => void
+}
+
+export function BuyerKeyPanel({
+ buyerKey,
+ buyerKeyPrivate,
+ buyerKeyConfirmed,
+ onGenerate,
+ onConfirm,
+}: BuyerKeyPanelProps) {
+ const { copied: copiedAddress, copy: copyAddress } = useCopyFeedback()
+ const { copied: copiedPrivate, copy: copyPrivate } = useCopyFeedback()
+ const [isPrivateKeyVisible, setIsPrivateKeyVisible] = useState(false)
+ const [isGenerating, setIsGenerating] = useState(false)
+
+ async function handleGenerate() {
+ setIsGenerating(true)
+ try {
+ await onGenerate()
+ } finally {
+ setIsGenerating(false)
+ }
+ }
+
+ return (
+
+ Buyer Key
+
+ Sign a message with your payer wallet to derive a deterministic AntSeed buyer key. Save the
+ private key — you will need it to authenticate from your developer tools.
+
+
+
+
+
+ {buyerKey && (
+
+ {/* Address row */}
+
+ Address (registered on-chain)
+
+
+
+ {buyerKey}
+
+
+
+
+ {/* Private key row — only shown for generated keys */}
+ {buyerKeyPrivate && (
+ <>
+
+
+ Private Key — save this securely
+
+
+
+
+
+ ⚠ Revealing your private key can expose your account. Never share it — store it in a
+ secure place.
+
+
+
+
+ {isPrivateKeyVisible ? buyerKeyPrivate : '•'.repeat(Math.min(48, buyerKeyPrivate.length))}
+
+
+
+ >
+ )}
+
+ {!buyerKeyConfirmed && (
+
+ )}
+
+ {buyerKeyConfirmed && (
+
+
+
+ Key confirmed — you can proceed
+
+
+ )}
+
+ )}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// OperatorConsentStep component
+// ---------------------------------------------------------------------------
+
+interface OperatorConsentStepProps {
+ buyerKey: string | null
+ buyerKeyPrivate: string | null
+ operatorConsentSigned: boolean
+ onSign: () => Promise
+}
+
+export function OperatorConsentStep({
+ buyerKey,
+ buyerKeyPrivate,
+ operatorConsentSigned,
+ onSign,
+}: OperatorConsentStepProps) {
+ const [isSigning, setIsSigning] = useState(false)
+ const canSign = Boolean(buyerKey && buyerKeyPrivate)
+
+ return (
+
+ Authorize AntSeed 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.
+
+
+ {buyerKey && (
+
+ Buyer address:{' '}
+
+ {truncateAddress(buyerKey)}
+
+
+ )}
+
+ {operatorConsentSigned ? (
+
+
+ Operator consent accepted — ready to pay
+
+ ) : (
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// AmountPicker component
+// ---------------------------------------------------------------------------
+
+interface AmountPickerProps {
+ depositAmount: string
+ streamAmount: string
+ gBalance: string | null
+ minDepositG: string | null
+ minStreamG: string | null
+ quote: AiCreditsQuote | null
+ canPay: boolean
+ payDisabledMessage: string | null
+ isPayPending: boolean
+ onDepositChange: (v: string) => void
+ onStreamChange: (v: string) => void
+ onPay: () => void
+}
+
+export function AmountPicker({
+ depositAmount,
+ streamAmount,
+ gBalance,
+ minDepositG,
+ minStreamG,
+ quote,
+ canPay,
+ payDisabledMessage,
+ isPayPending,
+ onDepositChange,
+ onStreamChange,
+ onPay,
+}: AmountPickerProps) {
+ const depositG = parseGAmount(depositAmount)
+ const streamG = parseGAmount(streamAmount)
+ const bonusPercent = quote?.bonusPercent ?? 10
+ const { depositBelowMin, streamBelowMin, overBalance } = getPaymentAmountValidation({
+ depositAmount,
+ streamAmount,
+ minDepositG,
+ minStreamG,
+ gBalance,
+ })
+ const totalG = depositG + streamG
+ const depositPlaceholder =
+ minDepositG === null
+ ? 'Loading minimum…'
+ : parseGAmount(minDepositG) > 0
+ ? `Min ${formatMinGDisplayLocale(minDepositG)} G$`
+ : '0 G$ (optional)'
+ const streamPlaceholder =
+ minStreamG === null
+ ? 'Loading minimum…'
+ : `Min ${formatMinGDisplayLocale(minStreamG)} G$ (optional)`
+
+ const formatCredits = (value: string) => {
+ const parsed = Number.parseFloat(value)
+ return parsed < 10 ? parsed.toFixed(1) : parsed.toFixed(2)
+ }
+
+ return (
+
+ Buy Credits
+
+
+
+ One-time Deposit (G$)
+
+ +10% bonus
+
+
+
+ {depositG > 0 && quote && (
+
+ ≈ ${quote.depositAmountUsd} USD
+
+ )}
+ {depositG > 0 && !quote && (
+
+ )}
+
+
+
+
+ Monthly Stream (G$)
+
+ {bonusPercent >= 20 ? '+20% bonus (GoodID)' : '+20% with GoodID'}
+
+
+
+ {streamG > 0 && quote && (
+
+
+ ≈ ${quote.streamAmountUsd} USD/month
+
+
+ )}
+ {streamG > 0 && !quote && (
+
+ )}
+
+
+
+
+
+ Total
+
+
+
+ {quote && (
+
+
+ Est. credits
+
+
+ {formatCredits(quote.totalCredits)}
+
+
+ )}
+
+
+
+ Applied bonus
+
+
+
+ +{bonusPercent}%
+
+
+
+
+ {overBalance && (
+
+
+ Total exceeds your G$ balance. Reduce the amounts.
+
+
+ )}
+
+ {depositBelowMin && minDepositG && (
+
+
+ First deposit must be at least {formatMinGDisplayLocale(minDepositG)} G$.
+
+
+ )}
+
+ {streamBelowMin && minStreamG && (
+
+
+ Monthly stream must be at least {formatMinGDisplayLocale(minStreamG)} G$.
+
+
+ )}
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// CreditsManagementCard component
+// ---------------------------------------------------------------------------
+
+interface CreditsManagementCardProps {
+ state: AiCreditsWidgetAdapterState
+ actions: Pick<
+ AiCreditsWidgetAdapterActions,
+ 'startPurchase' | 'setChannelId' | 'setWithdrawAmount' | 'closeChannel' | 'withdrawCredits'
+ >
+}
+
+export function CreditsManagementCard({ state, actions }: CreditsManagementCardProps) {
+ const [isClosing, setIsClosing] = useState(false)
+ const [isWithdrawing, setIsWithdrawing] = useState(false)
+ const {
+ aiCreditsBalance,
+ gBalance,
+ totalGdDepositedG,
+ monthlyStreamG,
+ monthlyStreamCredits,
+ withdrawableUsd,
+ buyerKeyPrivate,
+ channelId,
+ withdrawAmount,
+ } = state
+
+ const withdrawableDisplay =
+ withdrawableUsd && BigInt(withdrawableUsd) > 0n ? formatUsdMicro(withdrawableUsd) : null
+ const canClose = Boolean(buyerKeyPrivate) && Boolean(channelId.trim()) && !isClosing
+ const canWithdraw =
+ Boolean(buyerKeyPrivate) &&
+ Boolean(withdrawableDisplay) &&
+ Boolean(withdrawAmount.trim()) &&
+ !isWithdrawing
+
+ return (
+
+ AI Credits
+
+
+
+ Total Credits
+ {aiCreditsBalance !== null ? (
+ {Number.parseFloat(aiCreditsBalance).toFixed(2)}
+ ) : (
+
+ )}
+
+
+
+
+ Payer G$ Balance
+
+ {gBalance !== null ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ Total Deposited
+
+
+
+
+
+
+
+ Monthly Stream
+
+
+
+ {monthlyStreamCredits && Number.parseFloat(monthlyStreamCredits) > 0 && (
+
+
+ Est. Monthly Credits
+
+
+ ~{Number.parseFloat(monthlyStreamCredits).toFixed(2)} credits/mo
+
+
+ )}
+
+ {withdrawableDisplay && (
+
+
+ Withdrawable
+
+ ${withdrawableDisplay}
+
+ )}
+
+
+
+
+
+ Close Channel
+
+
+
+
+
+
+
+
+
+
+
+
+ Withdraw
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// BuyerOperatorCard component
+// ---------------------------------------------------------------------------
+
+interface BuyerOperatorCardProps {
+ state: Pick<
+ AiCreditsWidgetAdapterState,
+ 'address' | 'buyerKey' | 'buyerKeyPrivate' | 'operatorConsentSigned'
+ >
+ actions: Pick
+}
+
+export function BuyerOperatorCard({ state, actions }: BuyerOperatorCardProps) {
+ const { address, buyerKey, buyerKeyPrivate, operatorConsentSigned } = state
+ const { copied: copiedPrivate, copy: copyPrivate } = useCopyFeedback()
+ const [isPrivateKeyVisible, setIsPrivateKeyVisible] = useState(false)
+ const [isGenerating, setIsGenerating] = useState(false)
+ const [isSigning, setIsSigning] = useState(false)
+
+ return (
+
+ Buyer & Operator
+
+ {address && }
+ {buyerKey && }
+
+
+
+
+
+
+
+ {buyerKeyPrivate && (
+
+
+
+ Private Key
+
+
+
+
+
+ {isPrivateKeyVisible
+ ? buyerKeyPrivate
+ : '•'.repeat(Math.min(48, buyerKeyPrivate.length))}
+
+
+
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// SetupSnippetCard component
+// ---------------------------------------------------------------------------
+
+const ANTSEED_API_DOCS_URL = 'https://antseed.com/docs/guides/using-the-api'
+
+const setupSnippetLineStyle: React.CSSProperties = {
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
+ fontSize: 13,
+ lineHeight: '20px',
+ wordBreak: 'break-word',
+ overflowWrap: 'anywhere',
+}
+
+interface SetupSnippetProps {
+ snippet: string
+}
+
+export function SetupSnippet({ snippet }: SetupSnippetProps) {
+ const [expanded, setExpanded] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const copyText = snippet.replace(/\n\n+/g, '\n').trim()
+ const lines = snippet.trim().split('\n')
+
+ async function handleCopy() {
+ const copied = await copyTextToClipboard(copyText)
+ if (!copied) return
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ }
+
+ return (
+
+
+ setExpanded((value) => !value)}
+ cursor="pointer"
+ >
+ API Setup
+
+
+
+
+
+ {expanded && (
+
+
+ {lines.map((line, index) => (
+
+ {line.length > 0 ? line : ' '}
+
+ ))}
+
+
+ Setup guide:{' '}
+
+ antseed.com/docs
+
+
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 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)
+
+ const isFundingHistory = entries.length === 0 || entries.every((entry) => entry.kind === 'funding')
+ const total = entries.reduce((sum, entry) => {
+ if (entry.kind === 'funding' && entry.fundingStatus !== 'funded') return sum
+ return sum + entry.creditsUsed
+ }, 0)
+ const title = isFundingHistory ? 'Credit History' : 'Usage History'
+
+ return (
+
+ setExpanded((value) => !value)}
+ cursor="pointer"
+ >
+ {title}
+
+
+ {entries.length === 0
+ ? 'No entries yet'
+ : isFundingHistory
+ ? `${total.toFixed(1)} funded in history`
+ : `${total.toFixed(1)} total credits`}
+
+
+
+
+
+ {expanded && (
+
+ {entries.length === 0 ? (
+
+ Purchases and funding activity will appear here.
+
+ ) : (
+ entries.map((entry) => (
+
+
+
+
+
+ {entry.model}
+
+
+ {new Date(entry.timestamp).toLocaleString()}
+
+
+
+ {entry.kind === 'funding' ? '+' : '-'}
+ {entry.creditsUsed.toFixed(1)} credits
+
+
+
+ ))
+ )}
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// AiCreditsFlowStepper component
+// ---------------------------------------------------------------------------
+
+/** Map of step IDs for the AI credits purchase flow */
+export type AiCreditsFlowStep = 'connect' | 'buyer_key' | 'consent' | '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 === 'purchase_setup' ||
+ 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
+
+ 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 'pay':
+ if (state.status === 'credits_management' || state.status === 'payment_confirmed')
+ return 'completed'
+ if (state.status === 'payment_failed') return 'failed'
+ if (!hasConsent) 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: '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/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts
new file mode 100644
index 0000000..f0de1bb
--- /dev/null
+++ b/packages/ai-credits-widget/src/backendClient.ts
@@ -0,0 +1,576 @@
+import type { AiCreditsUsageEntry } from './widgetRuntimeContract'
+import type {
+ AccountCreditResponse,
+ AccountRef,
+ AccountView,
+ CeloEventsRecordResponse,
+ GdCreditEntry,
+ SettlementResult,
+ TransactionsResponse,
+ UserCreditProfile,
+} from './backendTypes'
+import { markMockOperatorConsent, type AiCreditsChainClient } from './chainClient'
+import {
+ flowRateWeiToMonthlyG,
+ isGoodIdVerifiedFromProfile,
+ usdToCredits,
+ weiToG,
+} from './quoteMath'
+import { isAddress } from 'viem'
+
+export type {
+ AccountRef,
+ AccountCreditResponse,
+ AccountView,
+ GdCreditEntry,
+ TransactionsResponse,
+ CeloEventsRecordResponse,
+ SettlementResult,
+ UserCreditProfile,
+} from './backendTypes'
+
+export type { AccountView as AccountStatusResponse } from './backendTypes'
+
+export { usdToCredits } from './quoteMath'
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+const BRIDGE_POLL_INTERVAL_MS = 3000
+const BRIDGE_POLL_MAX_ATTEMPTS = 20
+
+function normalizeAddress(address: string): string {
+ return address.toLowerCase()
+}
+
+export type WithdrawPrincipalRequest = {
+ amount: string
+ recipient: string
+ timestamp: number
+ signature: string
+}
+
+export type ChannelOperationRequest = {
+ timestamp?: number
+ signature?: string
+}
+
+export type BridgeResponse = {
+ enabled: boolean
+ txHash?: string
+}
+
+export type ChannelOperationResponse = {
+ channelId: string
+ action: 'close' | 'withdraw'
+ bridge: BridgeResponse
+}
+
+export type WithdrawPrincipalResponse = {
+ account: string
+ amountUsd: string
+ bridge: BridgeResponse
+}
+
+export type OperatorConsentRequest = {
+ nonce: string
+ signature: string
+}
+
+export type OperatorConsentResponse = {
+ buyer: string
+ bridge: BridgeResponse
+}
+
+async function readBridgeResponseBody(
+ response: Response,
+ actionLabel: string,
+): Promise {
+ if (!response.ok) {
+ let detail = ''
+ try {
+ const body = (await response.json()) as { error?: unknown }
+ if (body.error) detail = ` — ${JSON.stringify(body.error)}`
+ } catch {
+ detail = ''
+ }
+ throw new Error(`${actionLabel} failed: ${response.status}${detail}`)
+ }
+ const body = (await response.json()) as T
+ const bridge = body.bridge ?? { enabled: false }
+ if (!bridge.enabled) {
+ throw new Error(`${actionLabel} bridge is not configured on the backend`)
+ }
+ return { ...body, bridge }
+}
+
+async function parseBridgeResponse(
+ response: Response,
+ actionLabel: string,
+): Promise {
+ const body = await readBridgeResponseBody(response, actionLabel)
+ return body.bridge
+}
+
+function filterGdCredits(
+ entries: GdCreditEntry[],
+ options: { status?: 'pending' | 'funded' | 'failed'; limit?: number } = {},
+): GdCreditEntry[] {
+ let result = [...entries]
+ if (options.status) {
+ result = result.filter((entry) => entry.fundingStatus === options.status)
+ }
+ const limit = options.limit ?? 20
+ return result.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, limit)
+}
+
+export function resolveBuyerAddress(credit: AccountCreditResponse): string | null {
+ if (credit.profile.buyer && isAddress(credit.profile.buyer)) {
+ return normalizeAddress(credit.profile.buyer)
+ }
+ for (const entry of credit.gdCredits ?? []) {
+ if (entry.buyerAddress && isAddress(entry.buyerAddress)) {
+ return normalizeAddress(entry.buyerAddress)
+ }
+ }
+ return null
+}
+
+export function balanceFromProfile(
+ profile: Pick,
+): string {
+ const principal = BigInt(profile.totalPrincipalUsd || '0')
+ const bonus = BigInt(profile.totalBonusUsd || '0')
+ return usdToCredits((principal + bonus).toString())
+}
+
+export function creditsBalanceFromStatus(status: {
+ profile: Pick
+}): string {
+ return balanceFromProfile(status.profile)
+}
+
+export type AccountEnrichment = {
+ balance: string
+ goodIdVerified: boolean
+ bonusPercent: number
+ buyer: string | null
+ totalGdDepositedG: string
+ monthlyStreamG: string
+ monthlyStreamCredits: string | null
+}
+
+export async function enrichAccountView(
+ view: AccountView,
+ chain: AiCreditsChainClient,
+): Promise {
+ const { profile } = view
+ const goodIdVerified = isGoodIdVerifiedFromProfile(profile.account, profile.rootAccount)
+ const monthlyStreamG = flowRateWeiToMonthlyG(profile.streamFlowRateWeiPerSecond ?? '0')
+ let monthlyStreamCredits: string | null = null
+ if (Number.parseFloat(monthlyStreamG) > 0) {
+ monthlyStreamCredits = (await chain.buildQuote('0', monthlyStreamG, goodIdVerified)).totalCredits
+ }
+ const depositedWei = BigInt(profile.totalGdDepositedWei ?? '0')
+ const buyer = view.buyer
+ return {
+ balance: balanceFromProfile(profile),
+ goodIdVerified,
+ bonusPercent: goodIdVerified ? 20 : 10,
+ buyer,
+ totalGdDepositedG: depositedWei > 0n ? weiToG(depositedWei) : '0.00',
+ monthlyStreamG,
+ monthlyStreamCredits,
+ }
+}
+
+function sourceLabel(source: GdCreditEntry['source']): string {
+ if (source === 'deposit') return 'G$ deposit'
+ return 'G$ stream'
+}
+
+export function gdCreditsToUsageEntries(entries: GdCreditEntry[]): AiCreditsUsageEntry[] {
+ return [...entries]
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
+ .map((entry) => ({
+ sessionId: entry.id,
+ timestamp: entry.createdAt,
+ creditsUsed: Number.parseFloat(usdToCredits(entry.totalCreditUsd)),
+ model:
+ entry.fundingStatus === 'failed'
+ ? `${sourceLabel(entry.source)} (failed)`
+ : sourceLabel(entry.source),
+ kind: 'funding' as const,
+ fundingStatus: entry.fundingStatus,
+ }))
+}
+
+export interface AiCreditsBackendClient {
+ getAccountCredit(payer: string): Promise
+ getOutstanding(payer: string): Promise<{ outstandingFundingUsd: string; count: number }>
+ getTransactions(
+ payer: string,
+ options?: { status?: 'pending' | 'funded' | 'failed'; limit?: number; cursor?: string },
+ ): Promise
+ getUsageLog(payer: string): Promise
+ notifyPayment(txHash: string): Promise
+ waitForSettlement(
+ ref: AccountRef,
+ options?: { txHashes?: string[]; previousBalance?: string },
+ ): Promise
+ closeChannel(
+ channelId: string,
+ body?: ChannelOperationRequest,
+ ): Promise
+ withdrawCredits(
+ buyer: string,
+ body: WithdrawPrincipalRequest,
+ ): Promise
+ submitOperatorConsent(
+ buyer: string,
+ body: OperatorConsentRequest,
+ ): Promise
+}
+
+const MOCK_DELAY_MS = 600
+
+export class MockAiCreditsBackendClient implements AiCreditsBackendClient {
+ private activeRef: AccountRef | null = null
+ private lastCreditUsd = 0n
+
+ private readonly accountStates = new Map<
+ string,
+ {
+ principalUsd: bigint
+ bonusUsd: bigint
+ transactions: GdCreditEntry[]
+ buyer: string | null
+ rootAccount: string
+ }
+ >()
+
+ private getState(payer: string) {
+ const key = normalizeAddress(payer)
+ if (!this.accountStates.has(key)) {
+ this.accountStates.set(key, {
+ principalUsd: 0n,
+ bonusUsd: 0n,
+ transactions: [],
+ buyer: null,
+ rootAccount: key,
+ })
+ }
+ return this.accountStates.get(key)!
+ }
+
+ private buildProfile(payer: string): UserCreditProfile {
+ const state = this.getState(payer)
+ const outstanding = state.transactions
+ .filter((entry) => entry.fundingStatus === 'pending' || entry.fundingStatus === 'failed')
+ .reduce((sum, entry) => sum + BigInt(entry.totalCreditUsd || '0'), 0n)
+ return {
+ account: normalizeAddress(payer),
+ rootAccount: state.rootAccount,
+ totalPrincipalUsd: state.principalUsd.toString(),
+ totalBonusUsd: state.bonusUsd.toString(),
+ totalOutstandingFundingUsd: outstanding.toString(),
+ buyer: state.buyer ?? undefined,
+ totalGdDepositedWei: '0',
+ streamFlowRateWeiPerSecond: '0',
+ }
+ }
+
+ async getAccountCredit(payer: string): Promise {
+ await sleep(MOCK_DELAY_MS)
+ const profile = this.buildProfile(payer)
+ return {
+ account: profile.account,
+ profile,
+ gdCredits: this.getState(payer).transactions,
+ }
+ }
+
+ async getOutstanding(payer: string): Promise<{ outstandingFundingUsd: string; count: number }> {
+ await sleep(MOCK_DELAY_MS)
+ const state = this.getState(payer)
+ const pending = state.transactions.filter(
+ (entry) => entry.fundingStatus === 'pending' || entry.fundingStatus === 'failed',
+ )
+ const outstanding = pending.reduce(
+ (sum, entry) => sum + BigInt(entry.totalCreditUsd || '0'),
+ 0n,
+ )
+ return { outstandingFundingUsd: outstanding.toString(), count: pending.length }
+ }
+
+ async getTransactions(
+ payer: string,
+ options: { status?: 'pending' | 'funded' | 'failed'; limit?: number; cursor?: string } = {},
+ ): Promise {
+ await sleep(MOCK_DELAY_MS)
+ let transactions = [...this.getState(payer).transactions]
+ if (options.status) {
+ transactions = transactions.filter((entry) => entry.fundingStatus === options.status)
+ }
+ const limit = options.limit ?? 20
+ const page = transactions.slice(0, limit)
+ return { account: normalizeAddress(payer), transactions: page }
+ }
+
+ async getUsageLog(payer: string): Promise {
+ const credit = await this.getAccountCredit(payer)
+ return gdCreditsToUsageEntries(filterGdCredits(credit.gdCredits ?? [], { limit: 20 }))
+ }
+
+ prepareSettlement(ref: AccountRef, creditUsd: bigint): void {
+ this.activeRef = { payer: normalizeAddress(ref.payer), buyer: normalizeAddress(ref.buyer) }
+ this.lastCreditUsd = creditUsd
+ }
+
+ async notifyPayment(txHash: string): Promise {
+ await sleep(MOCK_DELAY_MS)
+ const ref = this.activeRef
+ if (!ref) return { txHash, events: [] }
+
+ const totalUsd = this.lastCreditUsd
+ const entry: GdCreditEntry = {
+ id: `${txHash}:0`,
+ source: 'deposit',
+ totalCreditUsd: totalUsd.toString(),
+ principalUsd: totalUsd.toString(),
+ bonusUsd: '0',
+ fundingStatus: 'pending',
+ txHash,
+ logIndex: 0,
+ createdAt: new Date().toISOString(),
+ buyerAddress: ref.buyer,
+ }
+
+ const state = this.getState(ref.payer)
+ if (!state.transactions.find((item) => item.id === entry.id)) {
+ state.transactions.unshift(entry)
+ }
+ return { txHash, events: [entry] }
+ }
+
+ async waitForSettlement(
+ ref: AccountRef,
+ _options: { txHashes?: string[]; previousBalance?: string } = {},
+ ): Promise {
+ await sleep(MOCK_DELAY_MS)
+ const state = this.getState(ref.payer)
+ for (const entry of state.transactions) {
+ if (entry.fundingStatus !== 'pending') continue
+ entry.fundingStatus = 'funded'
+ state.principalUsd += BigInt(entry.principalUsd ?? '0')
+ state.bonusUsd += BigInt(entry.bonusUsd ?? '0')
+ }
+ return { credits: balanceFromProfile(this.buildProfile(ref.payer)) }
+ }
+
+ async closeChannel(
+ channelId: string,
+ _body: ChannelOperationRequest = {},
+ ): Promise {
+ await sleep(MOCK_DELAY_MS)
+ return { channelId, action: 'close', bridge: { enabled: true, txHash: '0xmock' } }
+ }
+
+ async withdrawCredits(
+ _buyer: string,
+ body: WithdrawPrincipalRequest,
+ ): Promise {
+ await sleep(MOCK_DELAY_MS)
+ return {
+ account: _buyer,
+ amountUsd: body.amount,
+ bridge: { enabled: true, txHash: '0xmock' },
+ }
+ }
+
+ async submitOperatorConsent(buyer: string, _body: OperatorConsentRequest): Promise {
+ await sleep(MOCK_DELAY_MS)
+ const normalizedBuyer = normalizeAddress(buyer)
+ markMockOperatorConsent(normalizedBuyer)
+ return { buyer: normalizedBuyer, bridge: { enabled: true, txHash: '0xmock' } }
+ }
+}
+
+export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient {
+ private readonly backendUrl: string
+
+ constructor(backendUrl: string) {
+ this.backendUrl = backendUrl.replace(/\/$/, '')
+ }
+
+ private accountBase(payer: string): string {
+ return `${this.backendUrl}/v1/accounts/${encodeURIComponent(normalizeAddress(payer))}`
+ }
+
+ async getAccountCredit(payer: string): Promise {
+ const response = await fetch(`${this.accountBase(payer)}/credit`)
+ if (!response.ok) throw new Error(`Account credit request failed: ${response.status}`)
+ return response.json() as Promise
+ }
+
+ async getOutstanding(payer: string): Promise<{ outstandingFundingUsd: string; count: number }> {
+ const response = await fetch(`${this.accountBase(payer)}/outstanding`)
+ if (!response.ok) throw new Error(`Outstanding funding request failed: ${response.status}`)
+ const data = (await response.json()) as {
+ outstandingFundingUsd?: string
+ failedFundingCredits?: unknown[]
+ }
+ return {
+ outstandingFundingUsd: data.outstandingFundingUsd ?? '0',
+ count: data.failedFundingCredits?.length ?? 0,
+ }
+ }
+
+ async getTransactions(
+ payer: string,
+ options: { status?: 'pending' | 'funded' | 'failed'; limit?: number; cursor?: string } = {},
+ ): Promise {
+ const credit = await this.getAccountCredit(payer)
+ const transactions = filterGdCredits(credit.gdCredits ?? [], options)
+ return { account: normalizeAddress(payer), transactions }
+ }
+
+ async getUsageLog(payer: string): Promise {
+ const credit = await this.getAccountCredit(payer)
+ return gdCreditsToUsageEntries(filterGdCredits(credit.gdCredits ?? [], { limit: 20 }))
+ }
+
+ async notifyPayment(txHash: string): Promise {
+ const response = await fetch(`${this.backendUrl}/v1/celo/events/record`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ txHash }),
+ })
+ if (!response.ok) throw new Error(`Payment notification failed: ${response.status}`)
+ return response.json() as Promise
+ }
+
+ async waitForSettlement(
+ ref: AccountRef,
+ options: { txHashes?: string[]; previousBalance?: string } = {},
+ ): Promise {
+ const baseline = options.previousBalance ? Number.parseFloat(options.previousBalance) : 0
+ const txHashes = new Set((options.txHashes ?? []).map((hash) => hash.toLowerCase()))
+
+ for (let attempt = 0; attempt < BRIDGE_POLL_MAX_ATTEMPTS; attempt++) {
+ if (attempt > 0) await sleep(BRIDGE_POLL_INTERVAL_MS)
+
+ if (txHashes.size > 0) {
+ const credit = await this.getAccountCredit(ref.payer)
+ const matching = (credit.gdCredits ?? []).filter(
+ (entry) => entry.txHash && txHashes.has(entry.txHash.toLowerCase()),
+ )
+ const failed = matching.find((entry) => entry.fundingStatus === 'failed')
+ if (failed) {
+ throw new Error(failed.fundingError ?? 'Base funding failed for this deposit')
+ }
+ if (matching.length > 0 && matching.every((entry) => entry.fundingStatus === 'funded')) {
+ const credit = await this.getAccountCredit(ref.payer)
+ return { credits: balanceFromProfile(credit.profile) }
+ }
+ if (matching.some((entry) => entry.fundingStatus === 'pending')) continue
+ }
+
+ const credit = await this.getAccountCredit(ref.payer)
+ const balance = Number.parseFloat(balanceFromProfile(credit.profile))
+ if (balance > baseline + 0.0001) {
+ return { credits: balanceFromProfile(credit.profile) }
+ }
+ }
+
+ throw new Error('Settlement polling timeout — credits may still be arriving')
+ }
+
+ async closeChannel(
+ channelId: string,
+ body: ChannelOperationRequest = {},
+ ): Promise {
+ const response = await fetch(
+ `${this.backendUrl}/v1/channels/${encodeURIComponent(channelId)}/close`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ },
+ )
+ const bridge = await parseBridgeResponse(response, 'Close channel')
+ return { channelId, action: 'close', bridge }
+ }
+
+ async withdrawCredits(
+ buyer: string,
+ body: WithdrawPrincipalRequest,
+ ): Promise {
+ const response = await fetch(`${this.accountBase(buyer)}/withdraw`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const bridge = await parseBridgeResponse(response, 'Withdraw')
+ return { account: normalizeAddress(buyer), amountUsd: body.amount, bridge }
+ }
+
+ async submitOperatorConsent(
+ buyer: string,
+ body: OperatorConsentRequest,
+ ): Promise {
+ const response = await fetch(`${this.accountBase(buyer)}/operator-consent`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ })
+ const payload = await readBridgeResponseBody(response, 'Operator consent')
+ return { buyer: normalizeAddress(payload.buyer ?? buyer), bridge: payload.bridge }
+ }
+}
+
+export function createBackendClient(backendUrl: string | undefined): AiCreditsBackendClient {
+ if (!backendUrl) {
+ return new MockAiCreditsBackendClient()
+ }
+ return new ProductionAiCreditsBackendClient(backendUrl)
+}
+
+export async function waitForOperatorConsent(
+ chain: AiCreditsChainClient,
+ ref: AccountRef,
+): Promise {
+ for (let attempt = 0; attempt < BRIDGE_POLL_MAX_ATTEMPTS; attempt++) {
+ if (attempt > 0) await sleep(BRIDGE_POLL_INTERVAL_MS)
+ const status = await chain.getBuyerOperatorStatus(ref)
+ if (status.operatorAccepted) return
+ }
+
+ throw new Error('Operator consent confirmation timeout — check Base transaction status')
+}
+
+export async function buildAccountView(
+ payer: string,
+ backend: AiCreditsBackendClient,
+ chain: AiCreditsChainClient,
+): Promise {
+ const [credit, outstanding] = await Promise.all([
+ backend.getAccountCredit(payer),
+ backend.getOutstanding(payer),
+ ])
+ const buyer = resolveBuyerAddress(credit) ?? normalizeAddress(payer)
+ const [operator, withdrawableUsd] = await Promise.all([
+ chain.getBuyerOperatorStatus({ payer: normalizeAddress(payer), buyer }),
+ chain.getWithdrawableUsd(buyer),
+ ])
+ return {
+ account: normalizeAddress(payer),
+ buyer: resolveBuyerAddress(credit),
+ profile: credit.profile,
+ operator,
+ withdrawableUsd,
+ outstandingFundingUsd: outstanding.outstandingFundingUsd,
+ outstandingFundingCount: outstanding.count,
+ }
+}
diff --git a/packages/ai-credits-widget/src/backendTypes.ts b/packages/ai-credits-widget/src/backendTypes.ts
new file mode 100644
index 0000000..4283c77
--- /dev/null
+++ b/packages/ai-credits-widget/src/backendTypes.ts
@@ -0,0 +1,74 @@
+import type { BuyerOperatorStatus } from './operatorConsent'
+
+export type AccountRef = {
+ payer: string
+ buyer: string
+}
+
+export type UserCreditProfile = {
+ account: string
+ rootAccount: string
+ totalPrincipalUsd: string
+ totalBonusUsd: string
+ totalOutstandingFundingUsd?: string
+ buyer?: string
+ createdAt?: string
+ updatedAt?: string
+ totalGdDepositedWei?: string
+ totalGDStreamedWei?: string
+ streamFlowRateWeiPerSecond?: string
+ lastStreamCreditAt?: string
+}
+
+export type GdCreditEntry = {
+ id: string
+ account?: string
+ rootAccount?: string
+ source: 'deposit' | 'streamUpdate' | 'streamRequest' | 'streamCron'
+ gdAmountWei?: string
+ principalUsd?: string
+ bonusUsd?: string
+ totalCreditUsd: string
+ fundingStatus: 'pending' | 'funded' | 'failed'
+ fundingTxHash?: string
+ fundingError?: string
+ txHash?: string
+ logIndex?: number
+ createdAt: string
+ streamUpdateMonth?: string
+ buyerAddress?: string
+}
+
+export type AccountCreditResponse = {
+ account: string
+ profile: UserCreditProfile
+ gdCredits: GdCreditEntry[]
+}
+
+export type AccountView = {
+ account: string
+ buyer: string | null
+ profile: UserCreditProfile
+ operator: BuyerOperatorStatus
+ withdrawableUsd: string
+ outstandingFundingUsd: string
+ outstandingFundingCount: number
+}
+
+export type TransactionsResponse = {
+ account: string
+ transactions: GdCreditEntry[]
+ nextCursor?: string
+}
+
+export type CeloEventsRecordResponse = {
+ txHash?: string
+ account?: string
+ fromBlock?: string
+ toBlock?: string
+ events: GdCreditEntry[]
+}
+
+export type SettlementResult = {
+ credits: string
+}
diff --git a/packages/ai-credits-widget/src/buyerKeyDerivation.ts b/packages/ai-credits-widget/src/buyerKeyDerivation.ts
new file mode 100644
index 0000000..3c39b0e
--- /dev/null
+++ b/packages/ai-credits-widget/src/buyerKeyDerivation.ts
@@ -0,0 +1,30 @@
+import { keccak256, toBytes, type Hex } from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+
+const SECP256K1_ORDER = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n
+
+export function buildBuyerKeyMessage(payerAddress: string): string {
+ return `Generate a key for G$ credits from payer wallet of '${payerAddress.toLowerCase()}'`
+}
+
+export function deriveBuyerPrivateKeyFromSignature(signature: Hex): `0x${string}` {
+ for (let counter = 0; counter < 256; counter++) {
+ const hash =
+ counter === 0 ? keccak256(signature) : keccak256(toBytes(`${signature}:${counter}`))
+ const candidate = BigInt(hash)
+
+ if (candidate <= 0n || candidate >= SECP256K1_ORDER) {
+ continue
+ }
+
+ const privateKey = `0x${candidate.toString(16).padStart(64, '0')}` as `0x${string}`
+ try {
+ privateKeyToAccount(privateKey)
+ return privateKey
+ } catch {
+ continue
+ }
+ }
+
+ throw new Error('Could not derive a valid buyer key from wallet signature')
+}
diff --git a/packages/ai-credits-widget/src/buyerSignatures.ts b/packages/ai-credits-widget/src/buyerSignatures.ts
new file mode 100644
index 0000000..1cc0a81
--- /dev/null
+++ b/packages/ai-credits-widget/src/buyerSignatures.ts
@@ -0,0 +1,80 @@
+import { privateKeyToAccount } from 'viem/accounts'
+import type { Address, Hex } from 'viem'
+import { BASE_CHAIN_ID } from './chainClient'
+
+export const ANTSEED_BUYER_OPERATOR_DOMAIN = {
+ name: 'AntseedBuyerOperator',
+ version: '1',
+} as const
+
+const WITHDRAW_PRINCIPAL_TYPES = {
+ WithdrawPrincipal: [
+ { name: 'buyer', type: 'address' },
+ { name: 'amount', type: 'uint256' },
+ { name: 'recipient', type: 'address' },
+ { name: 'timestamp', type: 'uint256' },
+ ],
+} as const
+
+const REQUEST_CLOSE_TYPES = {
+ RequestClose: [
+ { name: 'channelId', type: 'bytes32' },
+ { name: 'timestamp', type: 'uint256' },
+ ],
+} as const
+
+export function normalizeChannelId(channelId: string): Hex | null {
+ const trimmed = channelId.trim()
+ if (!/^0x[0-9a-fA-F]{64}$/.test(trimmed)) return null
+ return trimmed.toLowerCase() as Hex
+}
+
+export async function signWithdrawPrincipal(params: {
+ buyerPrivateKey: Hex
+ fundingVaultAddress: Address
+ buyer: Address
+ amountMicro: bigint
+ recipient: Address
+ timestamp: number
+}): Promise {
+ const account = privateKeyToAccount(params.buyerPrivateKey)
+ return account.signTypedData({
+ domain: {
+ name: ANTSEED_BUYER_OPERATOR_DOMAIN.name,
+ version: ANTSEED_BUYER_OPERATOR_DOMAIN.version,
+ chainId: BASE_CHAIN_ID,
+ verifyingContract: params.fundingVaultAddress,
+ },
+ types: WITHDRAW_PRINCIPAL_TYPES,
+ primaryType: 'WithdrawPrincipal',
+ message: {
+ buyer: params.buyer,
+ amount: params.amountMicro,
+ recipient: params.recipient,
+ timestamp: BigInt(params.timestamp),
+ },
+ })
+}
+
+export async function signRequestClose(params: {
+ buyerPrivateKey: Hex
+ fundingVaultAddress: Address
+ channelId: Hex
+ timestamp: number
+}): Promise {
+ const account = privateKeyToAccount(params.buyerPrivateKey)
+ return account.signTypedData({
+ domain: {
+ name: ANTSEED_BUYER_OPERATOR_DOMAIN.name,
+ version: ANTSEED_BUYER_OPERATOR_DOMAIN.version,
+ chainId: BASE_CHAIN_ID,
+ verifyingContract: params.fundingVaultAddress,
+ },
+ types: REQUEST_CLOSE_TYPES,
+ primaryType: 'RequestClose',
+ message: {
+ channelId: params.channelId,
+ timestamp: BigInt(params.timestamp),
+ },
+ })
+}
diff --git a/packages/ai-credits-widget/src/celoPayment.ts b/packages/ai-credits-widget/src/celoPayment.ts
new file mode 100644
index 0000000..d8273ee
--- /dev/null
+++ b/packages/ai-credits-widget/src/celoPayment.ts
@@ -0,0 +1,178 @@
+import {
+ encodeAbiParameters,
+ parseAbi,
+ parseUnits,
+ type Address,
+ type PublicClient,
+ type WalletClient,
+} from 'viem'
+
+export const G_TOKEN_CELO_ADDRESS: Address = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A'
+
+export const SUPERFLUID_HOST_ADDRESS: Address = '0xA4Ff07cF81C02CFD356184879D953970cA957585'
+
+export const CFA_V1_CELO_ADDRESS: Address = '0x511CBa3De92dB7891967e21Dbd7C4571531ab84B'
+
+export const CFA_V1_FORWARDER_ADDRESS: Address = '0xcfA132E353cB4E398080B9700609bb008eceB125'
+
+const SECONDS_PER_MONTH = 30n * 24n * 3600n
+const MAX_UINT256 = 2n ** 256n - 1n
+
+const G_TOKEN_ABI = parseAbi([
+ 'function increaseAllowance(address spender, uint256 addedValue) returns (bool)',
+ 'function allowance(address owner, address spender) view returns (uint256)',
+ 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)',
+])
+
+const CFA_FORWARDER_ABI = parseAbi([
+ 'function createFlow(address token, address sender, address receiver, int96 flowrate, bytes userData) returns (bool)',
+ 'function updateFlow(address token, address sender, address receiver, int96 flowrate, bytes userData) returns (bool)',
+ 'function getFlowInfo(address token, address sender, address receiver) view returns (uint256 lastUpdated, int96 flowrate, uint256 deposit, uint256 owedDeposit)',
+ 'function getBufferAmountByFlowrate(address token, int96 flowrate) view returns (uint256 bufferAmount)',
+])
+
+const CELO_CHAIN = {
+ id: 42220,
+ name: 'Celo',
+ nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 },
+ rpcUrls: { default: { http: ['https://forno.celo.org'] } },
+} as const
+
+export interface CeloPaymentParams {
+ walletClient: WalletClient
+ publicClient: PublicClient
+ payer: Address
+ buyer: Address
+ vault: Address
+ depositAmountG: number
+ streamAmountG: number
+}
+
+export interface CeloPaymentResult {
+ txHashes: `0x${string}`[]
+}
+
+function monthlyToFlowRate(monthlyAmountG: number): bigint {
+ const monthlyWei = parseUnits(monthlyAmountG.toString(), 18)
+ return monthlyWei / SECONDS_PER_MONTH
+}
+
+function encodeBuyerUserData(buyer: Address): `0x${string}` {
+ return encodeAbiParameters([{ type: 'address' }], [buyer])
+}
+
+async function submitStreamSetup(
+ walletClient: WalletClient,
+ publicClient: PublicClient,
+ payer: Address,
+ buyer: Address,
+ vault: Address,
+ streamAmountG: number,
+): Promise<`0x${string}`[]> {
+ const flowRatePerSecond = monthlyToFlowRate(streamAmountG)
+ if (flowRatePerSecond <= 0n) {
+ throw new Error('Stream amount must be greater than zero')
+ }
+
+ const userData = encodeBuyerUserData(buyer)
+ const txHashes: `0x${string}`[] = []
+
+ const flowInfo = (await publicClient.readContract({
+ address: CFA_V1_FORWARDER_ADDRESS,
+ abi: CFA_FORWARDER_ABI,
+ functionName: 'getFlowInfo',
+ args: [G_TOKEN_CELO_ADDRESS, payer, vault],
+ })) as readonly [bigint, bigint, bigint, bigint]
+ const existingFlowRate = flowInfo[1]
+ const flowFunction = existingFlowRate > 0n ? 'updateFlow' : 'createFlow'
+
+ const requiredAllowance = (await publicClient.readContract({
+ address: CFA_V1_FORWARDER_ADDRESS,
+ abi: CFA_FORWARDER_ABI,
+ functionName: 'getBufferAmountByFlowrate',
+ args: [G_TOKEN_CELO_ADDRESS, flowRatePerSecond],
+ })) as bigint
+
+ const existingCfaAllowance = (await publicClient.readContract({
+ address: G_TOKEN_CELO_ADDRESS,
+ abi: G_TOKEN_ABI,
+ functionName: 'allowance',
+ args: [payer, CFA_V1_CELO_ADDRESS],
+ })) as bigint
+
+ if (existingCfaAllowance < requiredAllowance) {
+ const allowanceTx = await walletClient.writeContract({
+ account: payer,
+ chain: CELO_CHAIN,
+ address: G_TOKEN_CELO_ADDRESS,
+ abi: G_TOKEN_ABI,
+ functionName: 'increaseAllowance',
+ args: [CFA_V1_CELO_ADDRESS, MAX_UINT256 - existingCfaAllowance],
+ })
+ txHashes.push(allowanceTx)
+ }
+
+ const flowTx = await walletClient.writeContract({
+ account: payer,
+ chain: CELO_CHAIN,
+ address: CFA_V1_FORWARDER_ADDRESS,
+ abi: CFA_FORWARDER_ABI,
+ functionName: flowFunction,
+ args: [G_TOKEN_CELO_ADDRESS, payer, vault, flowRatePerSecond, userData],
+ })
+ txHashes.push(flowTx)
+
+ return txHashes
+}
+
+async function submitOneTimeDeposit(
+ walletClient: WalletClient,
+ payer: Address,
+ vault: Address,
+ buyer: Address,
+ depositAmountG: number,
+): Promise<`0x${string}`> {
+ const depositWei = parseUnits(depositAmountG.toString(), 18)
+ const userData = encodeBuyerUserData(buyer)
+
+ return walletClient.writeContract({
+ account: payer,
+ chain: CELO_CHAIN,
+ address: G_TOKEN_CELO_ADDRESS,
+ abi: G_TOKEN_ABI,
+ functionName: 'transferAndCall',
+ args: [vault, depositWei, userData],
+ })
+}
+
+export async function executeCeloPayment(params: CeloPaymentParams): Promise {
+ const { walletClient, publicClient, payer, buyer, vault, depositAmountG, streamAmountG } = params
+ const hasDeposit = depositAmountG > 0
+ const hasStream = streamAmountG > 0
+
+ if (!hasDeposit && !hasStream) {
+ throw new Error('At least one of deposit or stream amount must be greater than zero')
+ }
+
+ const txHashes: `0x${string}`[] = []
+
+ if (hasStream) {
+ const streamTxHashes = await submitStreamSetup(
+ walletClient,
+ publicClient,
+ payer,
+ buyer,
+ vault,
+ streamAmountG,
+ )
+ txHashes.push(...streamTxHashes)
+ }
+
+ if (hasDeposit) {
+ txHashes.push(
+ await submitOneTimeDeposit(walletClient, payer, vault, buyer, depositAmountG),
+ )
+ }
+
+ return { txHashes }
+}
diff --git a/packages/ai-credits-widget/src/chainClient.ts b/packages/ai-credits-widget/src/chainClient.ts
new file mode 100644
index 0000000..736f57b
--- /dev/null
+++ b/packages/ai-credits-widget/src/chainClient.ts
@@ -0,0 +1,329 @@
+import {
+ createPublicClient,
+ http,
+ parseAbi,
+ type Address,
+ type Chain,
+ type PublicClient,
+} from 'viem'
+import type { AiCreditsQuote } from './widgetRuntimeContract'
+import type { BuyerOperatorStatus, OperatorConsentPayloadResponse } from './operatorConsent'
+import { ANTSEED_DEPOSITS_BASE_ADDRESS, buildSetOperatorPayload } from './operatorConsent'
+import type { AccountRef } from './backendTypes'
+import { buildQuoteFromGdAmounts, buildQuoteFromPrincipalUsd, gToWei, vaultUsd18ToMicro } from './quoteMath'
+
+export const BASE_CHAIN_ID = 8453
+export const DEFAULT_BASE_RPC_URL = 'https://mainnet.base.org'
+export const CELO_GD_ANTSEED_VAULT_ADDRESS =
+ '0x4Dd0136b9aabD5823cf0F65d89e8fB882C660885' as const
+
+const BASE_CHAIN: Chain = {
+ id: BASE_CHAIN_ID,
+ name: 'Base',
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
+ rpcUrls: {
+ default: { http: [DEFAULT_BASE_RPC_URL] },
+ public: { http: [DEFAULT_BASE_RPC_URL] },
+ },
+}
+
+const CELO_VAULT_ABI = parseAbi([
+ 'function gdUsdPerToken(uint128 amount) view returns (uint256)',
+])
+
+const DEPOSITS_ABI = parseAbi([
+ 'function getOperator(address buyer) view returns (address)',
+ 'function getOperatorNonce(address buyer) view returns (uint256)',
+ 'function eip712Domain() view returns (bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions)',
+])
+
+const FUNDING_VAULT_ABI = parseAbi([
+ 'function withdrawablePrincipal(address buyer) view returns (uint256)',
+])
+
+function normalizeAddress(address: string): string {
+ return address.toLowerCase()
+}
+
+const mockOperatorAcceptedBuyers = new Set()
+
+export function markMockOperatorConsent(buyer: string): void {
+ mockOperatorAcceptedBuyers.add(normalizeAddress(buyer))
+}
+
+export type AiCreditsChainClientOptions = {
+ baseRpcUrl?: string
+ fundingVaultAddress?: Address
+ celoRpcUrl?: string
+ celoVaultAddress?: Address
+ depositsAddress?: Address
+}
+
+export interface AiCreditsChainClient {
+ fetchGdUsdPerToken(): Promise
+ buildQuote(
+ depositG: string,
+ streamG: string,
+ isGoodIdVerified: boolean,
+ ): Promise
+ getBuyerOperatorStatus(ref: AccountRef): Promise
+ buildOperatorConsentPayload(
+ ref: AccountRef,
+ operatorStatus?: BuyerOperatorStatus,
+ ): Promise
+ getWithdrawableUsd(buyer: string): Promise
+}
+
+export class ProductionAiCreditsChainClient implements AiCreditsChainClient {
+ private readonly baseClient: PublicClient
+ private readonly celoClient: PublicClient | null
+ private readonly fundingVaultAddress?: Address
+ private readonly celoVaultAddress?: Address
+ private readonly depositsAddress: Address
+
+ constructor(options: AiCreditsChainClientOptions = {}) {
+ const baseRpcUrl = options.baseRpcUrl ?? DEFAULT_BASE_RPC_URL
+ this.baseClient = createPublicClient({ chain: BASE_CHAIN, transport: http(baseRpcUrl) })
+ this.fundingVaultAddress = options.fundingVaultAddress
+ this.celoVaultAddress = options.celoVaultAddress
+ this.depositsAddress = options.depositsAddress ?? ANTSEED_DEPOSITS_BASE_ADDRESS
+ this.celoClient = options.celoVaultAddress
+ ? createPublicClient({
+ chain: {
+ id: 42220,
+ name: 'Celo',
+ nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 },
+ rpcUrls: {
+ default: { http: [options.celoRpcUrl ?? 'https://forno.celo.org'] },
+ },
+ },
+ transport: http(options.celoRpcUrl ?? 'https://forno.celo.org'),
+ })
+ : null
+ }
+
+ async fetchGdUsdPerToken(): Promise {
+ if (!this.celoClient || !this.celoVaultAddress) return 0.0015
+ const usd18 = await this.celoClient.readContract({
+ address: this.celoVaultAddress,
+ abi: CELO_VAULT_ABI,
+ functionName: 'gdUsdPerToken',
+ args: [10n ** 18n],
+ })
+ return Number(usd18) / 1e18
+ }
+
+ async buildQuote(
+ depositG: string,
+ streamG: string,
+ isGoodIdVerified: boolean,
+ ): Promise {
+ const depositWei = gToWei(depositG)
+ const streamWei = gToWei(streamG)
+
+ if (this.celoClient && this.celoVaultAddress) {
+ const [depositPrincipalUsd, streamPrincipalUsd] = await Promise.all([
+ this.readGdUsdMicro(depositWei),
+ this.readGdUsdMicro(streamWei),
+ ])
+ return buildQuoteFromPrincipalUsd(
+ depositG,
+ streamG,
+ depositPrincipalUsd,
+ streamPrincipalUsd,
+ isGoodIdVerified,
+ )
+ }
+
+ const gdUsdPerToken = await this.fetchGdUsdPerToken()
+ return buildQuoteFromGdAmounts(depositG, streamG, gdUsdPerToken, isGoodIdVerified)
+ }
+
+ private async readGdUsdMicro(gdAmountWei: bigint): Promise {
+ if (gdAmountWei <= 0n) return 0n
+ const usd18 = await this.celoClient!.readContract({
+ address: this.celoVaultAddress!,
+ abi: CELO_VAULT_ABI,
+ functionName: 'gdUsdPerToken',
+ args: [gdAmountWei],
+ })
+ return vaultUsd18ToMicro(usd18)
+ }
+
+ async getBuyerOperatorStatus(ref: AccountRef): Promise {
+ const payer = normalizeAddress(ref.payer)
+ const buyer = normalizeAddress(ref.buyer)
+ const operatorAddress = this.fundingVaultAddress?.toLowerCase()
+
+ if (!operatorAddress) {
+ return {
+ enabled: false,
+ account: payer,
+ buyerAddress: buyer,
+ currentOperator: '0x0000000000000000000000000000000000000000',
+ operatorAccepted: false,
+ consentNonce: '0',
+ }
+ }
+
+ const [currentOperator, consentNonce] = await Promise.all([
+ this.baseClient.readContract({
+ address: this.depositsAddress,
+ abi: DEPOSITS_ABI,
+ functionName: 'getOperator',
+ args: [buyer as Address],
+ }),
+ this.readOperatorNonce(buyer as Address),
+ ])
+
+ const current = String(currentOperator).toLowerCase()
+ return {
+ enabled: true,
+ account: payer,
+ buyerAddress: buyer,
+ operatorAddress,
+ currentOperator: current,
+ operatorAccepted: current === operatorAddress,
+ consentNonce: consentNonce.toString(),
+ }
+ }
+
+ async buildOperatorConsentPayload(
+ ref: AccountRef,
+ operatorStatus?: BuyerOperatorStatus,
+ ): Promise {
+ const payer = normalizeAddress(ref.payer)
+ const buyer = normalizeAddress(ref.buyer)
+ const status = operatorStatus ?? (await this.getBuyerOperatorStatus(ref))
+
+ if (!status.enabled || !status.operatorAddress) {
+ return { enabled: false, account: payer, buyerAddress: buyer }
+ }
+
+ const domain = await this.readDepositsDomain()
+
+ return {
+ enabled: true,
+ account: payer,
+ buyerAddress: buyer,
+ typedData: buildSetOperatorPayload(
+ BASE_CHAIN_ID,
+ this.depositsAddress,
+ status.operatorAddress,
+ BigInt(status.consentNonce),
+ domain,
+ ),
+ }
+ }
+
+ async getWithdrawableUsd(buyer: string): Promise {
+ if (!this.fundingVaultAddress) return '0'
+ const amount = await this.baseClient.readContract({
+ address: this.fundingVaultAddress,
+ abi: FUNDING_VAULT_ABI,
+ functionName: 'withdrawablePrincipal',
+ args: [normalizeAddress(buyer) as Address],
+ })
+ return amount.toString()
+ }
+
+ private async readOperatorNonce(buyer: Address): Promise {
+ return this.baseClient.readContract({
+ address: this.depositsAddress,
+ abi: DEPOSITS_ABI,
+ functionName: 'getOperatorNonce',
+ args: [buyer],
+ })
+ }
+
+ private async readDepositsDomain(): Promise<{ name: string; version: string }> {
+ try {
+ const domain = await this.baseClient.readContract({
+ address: this.depositsAddress,
+ abi: DEPOSITS_ABI,
+ functionName: 'eip712Domain',
+ })
+ return { name: String(domain[1]), version: String(domain[2]) }
+ } catch {
+ return { name: 'AntseedDeposits', version: '1' }
+ }
+ }
+}
+
+export class MockAiCreditsChainClient implements AiCreditsChainClient {
+ private operatorAccepted: boolean
+ private readonly gdUsdPerToken: number
+
+ constructor(options: { operatorAccepted?: boolean; gdUsdPerToken?: number } = {}) {
+ this.operatorAccepted = options.operatorAccepted ?? false
+ this.gdUsdPerToken = options.gdUsdPerToken ?? 0.0015
+ }
+
+ async fetchGdUsdPerToken(): Promise {
+ return this.gdUsdPerToken
+ }
+
+ async buildQuote(
+ depositG: string,
+ streamG: string,
+ isGoodIdVerified: boolean,
+ ): Promise {
+ return buildQuoteFromGdAmounts(depositG, streamG, this.gdUsdPerToken, isGoodIdVerified)
+ }
+
+ async getBuyerOperatorStatus(ref: AccountRef): Promise {
+ const payer = normalizeAddress(ref.payer)
+ const buyer = normalizeAddress(ref.buyer)
+ const operatorAddress = '0x0000000000000000000000000000000000000004'
+ const operatorAccepted = this.operatorAccepted || mockOperatorAcceptedBuyers.has(buyer)
+ return {
+ enabled: true,
+ account: payer,
+ buyerAddress: buyer,
+ operatorAddress,
+ currentOperator: operatorAccepted ? operatorAddress : '0x0000000000000000000000000000000000000000',
+ operatorAccepted,
+ consentNonce: '0',
+ }
+ }
+
+ async buildOperatorConsentPayload(
+ ref: AccountRef,
+ operatorStatus?: BuyerOperatorStatus,
+ ): Promise {
+ const status = operatorStatus ?? (await this.getBuyerOperatorStatus(ref))
+ if (!status.enabled || !status.operatorAddress) {
+ return {
+ enabled: false,
+ account: normalizeAddress(ref.payer),
+ buyerAddress: normalizeAddress(ref.buyer),
+ }
+ }
+ return {
+ enabled: true,
+ account: normalizeAddress(ref.payer),
+ buyerAddress: normalizeAddress(ref.buyer),
+ typedData: buildSetOperatorPayload(
+ BASE_CHAIN_ID,
+ ANTSEED_DEPOSITS_BASE_ADDRESS,
+ status.operatorAddress,
+ BigInt(status.consentNonce),
+ { name: 'AntseedDeposits', version: '1' },
+ ),
+ }
+ }
+
+ async getWithdrawableUsd(_buyer: string): Promise {
+ return '0'
+ }
+}
+
+export function createChainClient(
+ backendUrl: string | undefined,
+ options: AiCreditsChainClientOptions = {},
+): AiCreditsChainClient {
+ if (!backendUrl) {
+ return new MockAiCreditsChainClient()
+ }
+ return new ProductionAiCreditsChainClient(options)
+}
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..15ce210
--- /dev/null
+++ b/packages/ai-credits-widget/src/index.ts
@@ -0,0 +1,45 @@
+export { aiCreditsIntegration } from './integration'
+export type { AiCreditsIntegration } from './integration'
+
+export type {
+ AiCreditsWidgetStatus,
+ AiCreditsWidgetPrimaryAction,
+ AiCreditsWidgetAdapterState,
+ AiCreditsWidgetAdapterActions,
+ AiCreditsWidgetAdapterResult,
+ AiCreditsWidgetAdapterFactory,
+ AiCreditsWidgetAdapterFactoryInput,
+ AiCreditsWidgetEnvironment,
+ AiCreditsWidgetProps,
+ AiCreditsPaySuccessDetail,
+ AiCreditsPayErrorDetail,
+ AiCreditsQuote,
+ AiCreditsUsageEntry,
+} from './widgetRuntimeContract'
+
+export type {
+ AiCreditsBackendClient,
+ AccountRef,
+ AccountStatusResponse,
+ AccountView,
+ AccountEnrichment,
+ GdCreditEntry,
+} from './backendClient'
+export {
+ MockAiCreditsBackendClient,
+ ProductionAiCreditsBackendClient,
+ createBackendClient,
+ buildAccountView,
+ enrichAccountView,
+ balanceFromProfile,
+ creditsBalanceFromStatus,
+ usdToCredits,
+} from './backendClient'
+export type { BuyerOperatorStatus, Eip712SigningPayload } from './operatorConsent'
+export type { AiCreditsChainClient } from './chainClient'
+export { createChainClient, DEFAULT_BASE_RPC_URL } from './chainClient'
+
+export { useAiCreditsAdapter } from './adapter'
+export type { UseAiCreditsAdapterOptions } from './adapter'
+
+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..8392bdc
--- /dev/null
+++ b/packages/ai-credits-widget/src/integration.ts
@@ -0,0 +1,20 @@
+export const aiCreditsIntegration = {
+ id: 'ai-credits',
+ chains: [42220],
+ settlementChains: [8453],
+ states: [
+ 'disconnected',
+ 'purchase_setup',
+ 'quote_ready',
+ 'payment_pending',
+ 'payment_confirmed',
+ 'credits_management',
+ 'insufficient_g_balance',
+ '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/operatorConsent.ts b/packages/ai-credits-widget/src/operatorConsent.ts
new file mode 100644
index 0000000..36c0ac4
--- /dev/null
+++ b/packages/ai-credits-widget/src/operatorConsent.ts
@@ -0,0 +1,101 @@
+import { privateKeyToAccount } from 'viem/accounts'
+import type { Address } from 'viem'
+
+export const ANTSEED_DEPOSITS_BASE_ADDRESS =
+ '0x0F7a3a8f4Da01637d1202bb5443fcF7F88F99fD2' as const
+
+export type Eip712SigningPayload = {
+ primaryType: string
+ domain: {
+ name: string
+ version: string
+ chainId: number
+ verifyingContract: string
+ }
+ types: Record>
+ message: Record
+}
+
+export type BuyerOperatorStatus = {
+ enabled: boolean
+ account: string
+ buyerAddress: string
+ operatorAddress?: string
+ currentOperator: string
+ operatorAccepted: boolean
+ consentNonce: string
+}
+
+export type OperatorConsentPayloadResponse = {
+ enabled: boolean
+ account: string
+ buyerAddress: string
+ typedData?: Eip712SigningPayload
+}
+
+export type OperatorAcceptResponse = {
+ account: string
+ buyerAddress: string
+ bridge?: { txHash?: string }
+}
+
+export const SET_OPERATOR_TYPES: Record> = {
+ SetOperator: [
+ { name: 'operator', type: 'address' },
+ { name: 'nonce', type: 'uint256' },
+ ],
+}
+
+export function buildSetOperatorPayload(
+ chainId: number,
+ depositsAddress: string,
+ operatorAddress: string,
+ nonce: bigint,
+ domain: { name: string; version: string },
+): Eip712SigningPayload {
+ return {
+ primaryType: 'SetOperator',
+ domain: {
+ name: domain.name,
+ version: domain.version,
+ chainId,
+ verifyingContract: depositsAddress.toLowerCase(),
+ },
+ types: {
+ EIP712Domain: [
+ { name: 'name', type: 'string' },
+ { name: 'version', type: 'string' },
+ { name: 'chainId', type: 'uint256' },
+ { name: 'verifyingContract', type: 'address' },
+ ],
+ ...SET_OPERATOR_TYPES,
+ },
+ message: {
+ operator: operatorAddress.toLowerCase(),
+ nonce: nonce.toString(),
+ },
+ }
+}
+
+export async function signOperatorConsentFromTypedData(
+ buyerPrivateKey: `0x${string}`,
+ typedData: Eip712SigningPayload,
+): Promise<`0x${string}`> {
+ const account = privateKeyToAccount(buyerPrivateKey)
+ const types = { ...typedData.types }
+ delete types.EIP712Domain
+ return account.signTypedData({
+ domain: {
+ name: typedData.domain.name,
+ version: typedData.domain.version,
+ chainId: typedData.domain.chainId,
+ verifyingContract: typedData.domain.verifyingContract as Address,
+ },
+ types,
+ primaryType: typedData.primaryType,
+ message: {
+ operator: typedData.message.operator as Address,
+ nonce: BigInt(String(typedData.message.nonce)),
+ },
+ })
+}
diff --git a/packages/ai-credits-widget/src/quoteMath.ts b/packages/ai-credits-widget/src/quoteMath.ts
new file mode 100644
index 0000000..493ce27
--- /dev/null
+++ b/packages/ai-credits-widget/src/quoteMath.ts
@@ -0,0 +1,141 @@
+import { parseUnits } from 'viem'
+
+export const CREDITS_PER_USD = 10_000
+const REGULAR_BONUS_BPS = 1_000n
+const STREAMING_BONUS_BPS = 2_000n
+const BPS = 10_000n
+const SECONDS_PER_MONTH = 30n * 24n * 60n * 60n
+const USD_18_TO_MICRO = 1_000_000_000_000n
+
+export function parseGAmount(amount: string): number {
+ const normalized = amount.trim().replace(/,/g, '')
+ if (!normalized) return 0
+ const value = Number.parseFloat(normalized)
+ return Number.isFinite(value) ? value : 0
+}
+
+export function gToWei(amountG: string): bigint {
+ const trimmed = amountG.trim()
+ if (!trimmed || Number.parseFloat(trimmed) <= 0) return 0n
+ return parseUnits(trimmed, 18)
+}
+
+export function weiToG(amountWei: bigint): string {
+ const value = Number(amountWei) / 1e18
+ return value.toFixed(2)
+}
+
+export function gdWeiToUsd(gdAmountWei: bigint, gdUsdPerToken: number): bigint {
+ const usdPerToken = BigInt(Math.round(gdUsdPerToken * 1e6))
+ return (gdAmountWei * usdPerToken) / 1_000_000_000_000_000_000n
+}
+
+export function formatProfileUsd(usd: bigint): string {
+ return (Number(usd) / 1_000_000).toFixed(4)
+}
+
+export function formatUsdMicro(usdMicro: string): string {
+ return (Number(usdMicro || '0') / 1_000_000).toFixed(4)
+}
+
+export function usdDisplayToMicro(usdDisplay: string): string {
+ const value = Number.parseFloat(usdDisplay)
+ if (!Number.isFinite(value) || value <= 0) {
+ throw new Error('Enter a valid USD amount')
+ }
+ return Math.round(value * 1_000_000).toString()
+}
+
+export function usdToCredits(usd: string): string {
+ const value = BigInt(usd || '0')
+ const credits = Number(value) / CREDITS_PER_USD
+ return credits.toFixed(2)
+}
+
+export function flowRateWeiToMonthlyG(flowRateWeiPerSecond: string): string {
+ const rate = BigInt(flowRateWeiPerSecond || '0')
+ if (rate <= 0n) return '0.00'
+ return weiToG(rate * SECONDS_PER_MONTH)
+}
+
+export function vaultUsd18ToMicro(usd18: bigint): bigint {
+ return usd18 / USD_18_TO_MICRO
+}
+
+export function buildQuoteFromPrincipalUsd(
+ depositG: string,
+ streamG: string,
+ depositPrincipalUsd: bigint,
+ streamPrincipalUsd: bigint,
+ isGoodIdVerified: boolean,
+): {
+ depositAmountG: string
+ streamAmountG: string
+ depositAmountUsd: string
+ streamAmountUsd: string
+ bonusPercent: number
+ totalCredits: string
+} {
+ const streamWei = gToWei(streamG)
+ const depositBonusUsd = isGoodIdVerified
+ ? (depositPrincipalUsd * REGULAR_BONUS_BPS) / BPS
+ : 0n
+ const streamBonusUsd = isGoodIdVerified
+ ? (streamPrincipalUsd * STREAMING_BONUS_BPS) / BPS
+ : 0n
+ const totalUsd =
+ depositPrincipalUsd + depositBonusUsd + streamPrincipalUsd + streamBonusUsd
+ const hasStream = streamWei > 0n
+ const bonusPercent = !isGoodIdVerified ? 0 : hasStream ? 20 : 10
+
+ return {
+ depositAmountG: depositG,
+ streamAmountG: streamG,
+ depositAmountUsd: formatProfileUsd(depositPrincipalUsd),
+ streamAmountUsd: formatProfileUsd(streamPrincipalUsd),
+ bonusPercent,
+ totalCredits: usdToCredits(totalUsd.toString()),
+ }
+}
+
+export function buildQuoteFromGdAmounts(
+ depositG: string,
+ streamG: string,
+ gdUsdPerToken: number,
+ isGoodIdVerified: boolean,
+): {
+ depositAmountG: string
+ streamAmountG: string
+ depositAmountUsd: string
+ streamAmountUsd: string
+ bonusPercent: number
+ totalCredits: string
+} {
+ const depositWei = gToWei(depositG)
+ const streamWei = gToWei(streamG)
+ const depositPrincipalUsd = gdWeiToUsd(depositWei, gdUsdPerToken)
+ const streamPrincipalUsd = gdWeiToUsd(streamWei, gdUsdPerToken)
+ const depositBonusUsd = isGoodIdVerified
+ ? (depositPrincipalUsd * REGULAR_BONUS_BPS) / BPS
+ : 0n
+ const streamBonusUsd = isGoodIdVerified
+ ? (streamPrincipalUsd * STREAMING_BONUS_BPS) / BPS
+ : 0n
+ const totalUsd =
+ depositPrincipalUsd + depositBonusUsd + streamPrincipalUsd + streamBonusUsd
+ const hasStream = streamWei > 0n
+ const bonusPercent = !isGoodIdVerified ? 0 : hasStream ? 20 : 10
+
+ return {
+ depositAmountG: depositG,
+ streamAmountG: streamG,
+ depositAmountUsd: formatProfileUsd(depositPrincipalUsd),
+ streamAmountUsd: formatProfileUsd(streamPrincipalUsd),
+ bonusPercent,
+ totalCredits: usdToCredits(totalUsd.toString()),
+ }
+}
+
+export function isGoodIdVerifiedFromProfile(account: string, rootAccount: string): boolean {
+ return rootAccount.toLowerCase() !== account.toLowerCase()
+}
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