diff --git a/.github/workflows/deploy-ai-credits-web.yml b/.github/workflows/deploy-ai-credits-web.yml
new file mode 100644
index 0000000..653ff5f
--- /dev/null
+++ b/.github/workflows/deploy-ai-credits-web.yml
@@ -0,0 +1,62 @@
+name: Deploy AI Credits Web
+
+on:
+ push:
+ branches:
+ - main
+ paths:
+ - 'apps/ai-credits-web/**'
+ - 'packages/ai-credits-widget/**'
+ - 'packages/core/**'
+ - 'packages/ui/**'
+ - 'packages/embed/**'
+ - 'pnpm-lock.yaml'
+ - 'pnpm-workspace.yaml'
+
+concurrency:
+ group: deploy-ai-credits-web-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
+ VERCEL_PROJECT_ID: ${{ secrets.VERCEL_AI_CREDITS_WEB_PROJECT_ID }}
+
+jobs:
+ production:
+ runs-on: ubuntu-latest
+ environment:
+ name: production
+ url: ${{ steps.deploy.outputs.url }}
+ defaults:
+ run:
+ working-directory: apps/ai-credits-web
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v4
+ with:
+ version: 9.15.0
+
+ - name: Setup Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: pnpm
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+ working-directory: ${{ github.workspace }}
+
+ - name: Pull Vercel project settings
+ run: pnpm exec vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
+
+ - name: Build deployment
+ run: pnpm exec vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
+
+ - name: Deploy production
+ id: deploy
+ run: |
+ url="$(pnpm exec vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }})"
+ echo "url=$url" >> "$GITHUB_OUTPUT"
diff --git a/.github/workflows/deploy-storybook.yml b/.github/workflows/deploy-storybook.yml
index 2437b09..9a225bb 100644
--- a/.github/workflows/deploy-storybook.yml
+++ b/.github/workflows/deploy-storybook.yml
@@ -4,6 +4,11 @@ on:
push:
branches:
- main
+ paths:
+ - 'examples/storybook/**'
+ - 'packages/**'
+ - 'pnpm-lock.yaml'
+ - 'pnpm-workspace.yaml'
concurrency:
group: deploy-storybook-${{ github.ref }}
@@ -19,6 +24,9 @@ jobs:
environment:
name: production
url: ${{ steps.deploy.outputs.url }}
+ defaults:
+ run:
+ working-directory: examples/storybook
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -36,6 +44,7 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
+ working-directory: ${{ github.workspace }}
- name: Pull Vercel project settings
run: pnpm exec vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
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/apps/ai-credits-web/.gitignore b/apps/ai-credits-web/.gitignore
new file mode 100644
index 0000000..245259b
--- /dev/null
+++ b/apps/ai-credits-web/.gitignore
@@ -0,0 +1,2 @@
+.vercel
+.env*
diff --git a/apps/ai-credits-web/index.html b/apps/ai-credits-web/index.html
new file mode 100644
index 0000000..b9374af
--- /dev/null
+++ b/apps/ai-credits-web/index.html
@@ -0,0 +1,24 @@
+
+
+
+
+
+ GoodDollar AI Credits
+
+
+
+
+
+
+
diff --git a/apps/ai-credits-web/package.json b/apps/ai-credits-web/package.json
new file mode 100644
index 0000000..2dfe7cf
--- /dev/null
+++ b/apps/ai-credits-web/package.json
@@ -0,0 +1,28 @@
+{
+ "name": "@goodwidget/ai-credits-web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --port 3001",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "lint": "eslint src/",
+ "clean": "rm -rf dist .turbo"
+ },
+ "dependencies": {
+ "@goodwidget/ai-credits-widget": "workspace:*",
+ "@goodwidget/core": "workspace:*",
+ "@goodwidget/ui": "workspace:*",
+ "react": "^18.3.0",
+ "react-dom": "^18.3.0",
+ "react-native-web": "^0.19.13"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.0",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.2.0"
+ }
+}
diff --git a/apps/ai-credits-web/src/App.tsx b/apps/ai-credits-web/src/App.tsx
new file mode 100644
index 0000000..488d3db
--- /dev/null
+++ b/apps/ai-credits-web/src/App.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+import { AiCreditsWidget } from '@goodwidget/ai-credits-widget'
+import { Container, Text, YStack } from '@goodwidget/ui'
+
+function getInjectedProvider(): unknown | null {
+ if (typeof window === 'undefined') return null
+ return window.ethereum ?? null
+}
+
+function envAddress(value: string | undefined): `0x${string}` | undefined {
+ return value ? (value as `0x${string}`) : undefined
+}
+
+export function App() {
+ const provider = getInjectedProvider()
+
+ if (!provider) {
+ return (
+
+
+ No wallet found
+
+ Install or enable a browser wallet (e.g. Rabby, MetaMask), then refresh this page.
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/ai-credits-web/src/globals.d.ts b/apps/ai-credits-web/src/globals.d.ts
new file mode 100644
index 0000000..b0b04dc
--- /dev/null
+++ b/apps/ai-credits-web/src/globals.d.ts
@@ -0,0 +1,22 @@
+declare const process: {
+ env: Record
+}
+
+interface Window {
+ ethereum?: unknown
+}
+
+interface ImportMetaEnv {
+ readonly VITE_AI_CREDITS_BACKEND_URL?: string
+ readonly VITE_AI_CREDITS_BASE_RPC_URL?: string
+ readonly VITE_AI_CREDITS_CELO_RPC_URL?: string
+ readonly VITE_AI_CREDITS_FUNDING_VAULT_ADDRESS?: string
+ readonly VITE_AI_CREDITS_VAULT_ADDRESS?: string
+ readonly VITE_AI_CREDITS_GOODID_ADDRESS?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
+
+declare function setImmediate(callback: (...args: unknown[]) => void, ...args: unknown[]): number
diff --git a/apps/ai-credits-web/src/main.tsx b/apps/ai-credits-web/src/main.tsx
new file mode 100644
index 0000000..5fdbbf2
--- /dev/null
+++ b/apps/ai-credits-web/src/main.tsx
@@ -0,0 +1,9 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { App } from './App'
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/apps/ai-credits-web/src/types/tamagui-shim.d.ts b/apps/ai-credits-web/src/types/tamagui-shim.d.ts
new file mode 100644
index 0000000..33ca314
--- /dev/null
+++ b/apps/ai-credits-web/src/types/tamagui-shim.d.ts
@@ -0,0 +1,4 @@
+declare module 'tamagui' {
+ const tamagui: unknown
+ export = tamagui
+}
diff --git a/apps/ai-credits-web/tsconfig.json b/apps/ai-credits-web/tsconfig.json
new file mode 100644
index 0000000..23c0301
--- /dev/null
+++ b/apps/ai-credits-web/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": true,
+ "strict": false,
+ "baseUrl": ".",
+ "paths": {
+ "react-native": ["react-native-web"],
+ "tamagui": ["src/types/tamagui-shim.d.ts"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/apps/ai-credits-web/vercel.json b/apps/ai-credits-web/vercel.json
new file mode 100644
index 0000000..cea75b7
--- /dev/null
+++ b/apps/ai-credits-web/vercel.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "framework": "vite",
+ "installCommand": "cd ../.. && pnpm install --frozen-lockfile",
+ "buildCommand": "cd ../.. && pnpm --filter @goodwidget/ai-credits-web build",
+ "outputDirectory": "dist",
+ "devCommand": "pnpm dev",
+ "git": {
+ "deploymentEnabled": false
+ }
+}
diff --git a/apps/ai-credits-web/vite.config.ts b/apps/ai-credits-web/vite.config.ts
new file mode 100644
index 0000000..4237508
--- /dev/null
+++ b/apps/ai-credits-web/vite.config.ts
@@ -0,0 +1,36 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+ define: {
+ global: 'globalThis',
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
+ 'process.env.TAMAGUI_TARGET': JSON.stringify('web'),
+ },
+ resolve: {
+ alias: {
+ 'react-native': 'react-native-web',
+ },
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ resolveExtensions: [
+ '.web.js',
+ '.web.jsx',
+ '.web.ts',
+ '.web.tsx',
+ '.mjs',
+ '.js',
+ '.mts',
+ '.ts',
+ '.jsx',
+ '.tsx',
+ '.json',
+ ],
+ loader: {
+ '.js': 'jsx',
+ },
+ },
+ },
+})
diff --git a/examples/storybook/package.json b/examples/storybook/package.json
index 2e95733..3cf1283 100644
--- a/examples/storybook/package.json
+++ b/examples/storybook/package.json
@@ -16,6 +16,7 @@
"@goodwidget/governance-widget": "workspace:*",
"@goodwidget/streaming-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..65a1227
--- /dev/null
+++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidget.mdx
@@ -0,0 +1,116 @@
+import { Canvas, Meta } from '@storybook/blocks';
+import * as ShowcaseStories from './AiCreditsWidgetShowcase.stories';
+import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout';
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ All QA fixture states as deterministic mock stories — ideal for automation and debugging.
+
+
+
+
+
+ No wallet connected; tabs and purchase stepper hidden; circular Connect Wallet button.
+ Buy tab — stepper with active step opened in a bottom drawer.
+ Buy tab — pay step in drawer; ready to confirm Celo transaction.
+ Buy tab — Celo tx submitted.
+ Buy tab — settling on Base.
+ Manage tab — balance, stream, history, close/withdraw, API setup.
+ G$ below minimum purchase threshold.
+ Transaction or settlement error.
+ Backend unreachable.
+ Wrong network (Celo required).
+
+
+
+
+
+ Stepper for buyer key, consent, and pay — each step opens in a drawer.
+ Credits balance, stream info, close/withdraw, API setup, and history.
+
+
+
+
+
+ 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..11c5edb
--- /dev/null
+++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetQA.stories.tsx
@@ -0,0 +1,75 @@
+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',
+ goodWidgetProvider: {
+ disableProvider: true,
+ useShell: false,
+ },
+ },
+}
+
+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..9648f6e
--- /dev/null
+++ b/examples/storybook/src/stories/ai-credits-widget/AiCreditsWidgetShowcase.stories.tsx
@@ -0,0 +1,29 @@
+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',
+ goodWidgetProvider: {
+ disableProvider: true,
+ useShell: false,
+ },
+ },
+}
+
+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..71eaad0
--- /dev/null
+++ b/examples/storybook/src/stories/helpers/aiCreditsWidgetStories.tsx
@@ -0,0 +1,375 @@
+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'
+
+const SETUP_SNIPPET = `export ANTSEED_IDENTITY_HEX=\nexport ANTHROPIC_BASE_URL=http://localhost:8377`
+
+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: 0,
+ quote: null,
+ setupSnippet: SETUP_SNIPPET,
+ usageLog: [],
+ totalGdDepositedG: null,
+ monthlyStreamG: null,
+ monthlyStreamCredits: null,
+ withdrawableUsd: null,
+ channelId: '',
+ withdrawAmount: '',
+ error: null,
+ primaryAction: 'generate_key',
+ primaryLabel: 'Set Up Buyer Key',
+ activeTab: 'buy',
+ }
+ 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: () => {},
+ setActiveTab: () => {},
+ 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'}
+
+
+ )
+ }
+}
+
+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 ManageTabStory() {
+ 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 celoRpcUrl = import.meta.env.VITE_AI_CREDITS_CELO_RPC_URL
+ const fundingVaultAddress = import.meta.env.VITE_AI_CREDITS_FUNDING_VAULT_ADDRESS
+ const vaultAddress = import.meta.env.VITE_AI_CREDITS_VAULT_ADDRESS
+ const goodIdAddress = import.meta.env.VITE_AI_CREDITS_GOODID_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/examples/storybook/vercel.json b/examples/storybook/vercel.json
new file mode 100644
index 0000000..8bc4ab7
--- /dev/null
+++ b/examples/storybook/vercel.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "framework": "storybook",
+ "installCommand": "cd ../.. && pnpm install --frozen-lockfile",
+ "buildCommand": "cd ../.. && pnpm build && pnpm build-storybook",
+ "outputDirectory": "storybook-static",
+ "devCommand": "cd ../.. && pnpm storybook"
+}
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..4cc45cf
--- /dev/null
+++ b/packages/ai-credits-widget/src/AiCreditsWidget.tsx
@@ -0,0 +1,457 @@
+import React, { useCallback, useMemo } from 'react'
+import { GoodWidgetProvider } from '@goodwidget/core'
+import type { EIP1193Provider } from '@goodwidget/core'
+import {
+ Button,
+ ButtonText,
+ Card,
+ CircularActionButton,
+ Icon,
+ Text,
+ ToastContainer,
+ WidgetTabs,
+ XStack,
+ YStack,
+ Spinner,
+ createToast,
+ updateToast,
+} from '@goodwidget/ui'
+import { useAiCreditsAdapter } from './adapter'
+import {
+ AiCreditsHero,
+ AiCreditsFlowStepper,
+ AiCreditsPurchaseFlow,
+ AiCreditsStatusNotice,
+ CreditsManagementCard,
+ BuyerOperatorCard,
+ SetupSnippet,
+ UsageLog,
+} from './components'
+import type {
+ AiCreditsWidgetProps,
+ AiCreditsWidgetEnvironment,
+ AiCreditsPaySuccessDetail,
+ AiCreditsPayErrorDetail,
+ AiCreditsWidgetAdapterFactory,
+ AiCreditsWidgetAdapterActions,
+ AiCreditsWidgetAdapterState,
+ AiCreditsWidgetTab,
+} from './widgetRuntimeContract'
+import { getPaymentAmountValidation, getPayDisabledMessage } from './vaultMinimums'
+import { compactButtonProps } from './components/shared/styles'
+
+const CELO_CHAIN_ID = 42220
+
+interface AiCreditsInnerProps {
+ environment?: AiCreditsWidgetEnvironment
+ backendUrl?: string
+ baseRpcUrl?: string
+ celoRpcUrl?: string
+ fundingVaultAddress?: string
+ vaultAddress?: string
+ goodIdAddress?: string
+ adapterFactory?: AiCreditsWidgetAdapterFactory
+ onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
+ onPayError?: (detail: AiCreditsPayErrorDetail) => void
+}
+
+function DisconnectedPanel({
+ onConnect,
+}: {
+ onConnect: () => Promise
+}) {
+ return (
+
+
+ Connect your wallet to buy AI credits
+ {
+ void onConnect()
+ }}
+ />
+
+
+ )
+}
+
+interface BuyPanelProps {
+ state: AiCreditsWidgetAdapterState
+ actions: AiCreditsWidgetAdapterActions
+ canPay: boolean
+ payDisabledMessage: string | null
+ isPending: boolean
+ onPay: () => void
+}
+
+function BuyCreditsPanel({
+ state,
+ actions,
+ canPay,
+ payDisabledMessage,
+ isPending,
+ onPay,
+}: BuyPanelProps) {
+ if (state.status === 'unsupported_chain') {
+ return (
+
+
+
+ Wrong Network
+
+
+ Please switch to the Celo network to continue.
+
+
+ )
+ }
+
+ 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.
+
+
+
+ )
+ }
+
+ 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}
+
+
+
+
+
+ )
+ }
+
+ return (
+
+ {state.address && (
+
+ )}
+
+ {state.gBalance !== null && Number.parseFloat(state.gBalance) <= 0 && (
+
+ You need G$ before you can buy AI credits.
+
+ )}
+
+
+
+ )
+}
+
+function ManagePanel({
+ state,
+ actions,
+}: {
+ state: AiCreditsWidgetAdapterState
+ actions: AiCreditsWidgetAdapterActions
+}) {
+ return (
+
+ {state.error && (
+
+
+ {state.error}
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+function AiCreditsInner({
+ environment,
+ backendUrl,
+ baseRpcUrl,
+ celoRpcUrl,
+ fundingVaultAddress,
+ vaultAddress,
+ goodIdAddress,
+ adapterFactory,
+ onPaySuccess,
+ onPayError,
+}: AiCreditsInnerProps) {
+ const defaultAdapter = useAiCreditsAdapter({
+ environment,
+ backendUrl,
+ baseRpcUrl,
+ celoRpcUrl,
+ fundingVaultAddress: fundingVaultAddress as `0x${string}` | undefined,
+ vaultAddress: vaultAddress as `0x${string}` | undefined,
+ goodIdAddress: goodIdAddress 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: 'Credits added successfully!',
+ status: 'success',
+ duration: 4000,
+ })
+ } catch (err) {
+ updateToast(toastId, {
+ message: err instanceof Error ? err.message : (state.error ?? 'Payment failed'),
+ status: 'error',
+ duration: 0,
+ })
+ }
+ }, [actions, state.error])
+
+ const isPending =
+ state.status === 'payment_pending' || state.status === 'payment_confirmed'
+
+ const handleTabChange = useCallback(
+ (tabId: string) => {
+ actions.setActiveTab(tabId as AiCreditsWidgetTab)
+ },
+ [actions],
+ )
+
+ const buyPanel = (
+ {
+ void handlePay()
+ }}
+ />
+ )
+
+ if (state.status === 'disconnected') {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ {state.activeTab === 'manage' ? (
+
+ ) : (
+ buyPanel
+ )}
+
+ )
+}
+
+export function AiCreditsWidget({
+ provider,
+ environment = 'production',
+ backendUrl,
+ baseRpcUrl,
+ celoRpcUrl,
+ fundingVaultAddress,
+ vaultAddress,
+ goodIdAddress,
+ themeOverrides,
+ config,
+ defaultTheme = 'dark',
+ onPaySuccess,
+ onPayError,
+ adapterFactory,
+ testId,
+}: 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..875cfb2
--- /dev/null
+++ b/packages/ai-credits-widget/src/adapter.ts
@@ -0,0 +1,1065 @@
+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, CELO_GOODID_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,
+ AiCreditsWidgetTab,
+ 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: 0,
+ quote: null,
+ setupSnippet: buildSetupSnippet(),
+ usageLog: [],
+ totalGdDepositedG: null,
+ monthlyStreamG: null,
+ monthlyStreamCredits: null,
+ withdrawableUsd: null,
+ channelId: '',
+ withdrawAmount: '',
+ error: null,
+ primaryAction: 'connect',
+ primaryLabel: 'Connect Wallet',
+ activeTab: 'buy',
+}
+
+function isInBuyFlowStatus(status: AiCreditsWidgetStatus): boolean {
+ return (
+ status === 'purchase_setup' ||
+ status === 'quote_ready' ||
+ status === 'payment_pending' ||
+ status === 'payment_confirmed' ||
+ status === 'payment_failed'
+ )
+}
+
+function resolveActiveTab(
+ prev: AiCreditsWidgetAdapterState,
+ overrides: Partial,
+ status: AiCreditsWidgetStatus,
+): AiCreditsWidgetTab {
+ if (overrides.activeTab !== undefined) return overrides.activeTab
+
+ const balance = overrides.aiCreditsBalance ?? prev.aiCreditsBalance
+ const justConnected = !prev.address && Boolean(overrides.address)
+ const creditsAdded =
+ overrides.aiCreditsBalance !== undefined &&
+ hasCredits(overrides.aiCreditsBalance) &&
+ !hasCredits(prev.aiCreditsBalance)
+
+ if (justConnected && hasCredits(balance)) return 'manage'
+ if (creditsAdded && !isInBuyFlowStatus(status)) return 'manage'
+
+ return prev.activeTab ?? 'buy'
+}
+
+function hasCredits(balance: string | null): boolean {
+ return balance !== null && Number.parseFloat(balance) > 0
+}
+
+function deriveStatus(params: {
+ isConnected: boolean
+ chainId: number | null
+ gBalance: string | null
+ buyerKey: string | null
+ buyerKeyConfirmed: boolean
+ operatorConsentSigned: boolean
+ depositAmount: string
+ streamAmount: string
+ error: string | null
+ currentStatus: AiCreditsWidgetStatus
+ activeTab: AiCreditsWidgetTab
+}): AiCreditsWidgetStatus {
+ const {
+ isConnected,
+ chainId,
+ gBalance,
+ buyerKey,
+ buyerKeyConfirmed,
+ operatorConsentSigned,
+ depositAmount,
+ streamAmount,
+ error,
+ currentStatus,
+ activeTab,
+ } = 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 && activeTab !== 'manage') return 'payment_failed'
+
+ 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,
+ activeTab: AiCreditsWidgetTab,
+): AiCreditsWidgetPrimaryAction {
+ if (activeTab === 'manage') return 'refresh'
+
+ 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 '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,
+ buyerKey: merged.buyerKey,
+ buyerKeyConfirmed: merged.buyerKeyConfirmed,
+ operatorConsentSigned: merged.operatorConsentSigned,
+ depositAmount: merged.depositAmount,
+ streamAmount: merged.streamAmount,
+ error: merged.error,
+ currentStatus: merged.status,
+ activeTab: merged.activeTab,
+ })
+ const activeTab = resolveActiveTab(prev, overrides, status)
+ const primaryAction = derivePrimaryAction(status, activeTab)
+ return {
+ ...merged,
+ status,
+ activeTab: resolveActiveTab(prev, overrides, status),
+ primaryAction,
+ primaryLabel: derivePrimaryLabel(primaryAction),
+ }
+}
+
+function mergeStatePreservingManageTab(
+ prev: AiCreditsWidgetAdapterState,
+ overrides: Partial,
+): AiCreditsWidgetAdapterState {
+ if (prev.activeTab !== 'manage') {
+ return withDerivedStatus(prev, overrides, true)
+ }
+ const status = deriveStatus({
+ isConnected: true,
+ chainId: overrides.chainId ?? prev.chainId,
+ gBalance: overrides.gBalance ?? prev.gBalance,
+ buyerKey: overrides.buyerKey ?? prev.buyerKey,
+ buyerKeyConfirmed: overrides.buyerKeyConfirmed ?? prev.buyerKeyConfirmed,
+ operatorConsentSigned: overrides.operatorConsentSigned ?? prev.operatorConsentSigned,
+ depositAmount: overrides.depositAmount ?? prev.depositAmount,
+ streamAmount: overrides.streamAmount ?? prev.streamAmount,
+ error: overrides.error ?? prev.error,
+ currentStatus: overrides.status ?? prev.status,
+ activeTab: 'manage',
+ })
+ return {
+ ...prev,
+ ...overrides,
+ status,
+ activeTab: 'manage',
+ 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: buildSetupSnippet(keyForSnippet),
+ 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
+ celoRpcUrl?: string
+ fundingVaultAddress?: Address
+ vaultAddress?: Address
+ goodIdAddress?: Address
+ onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
+ onPayError?: (detail: AiCreditsPayErrorDetail) => void
+}
+
+export function useAiCreditsAdapter({
+ backendUrl,
+ baseRpcUrl,
+ celoRpcUrl,
+ fundingVaultAddress,
+ vaultAddress,
+ goodIdAddress,
+ 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,
+ celoRpcUrl,
+ fundingVaultAddress,
+ celoVaultAddress: celoVault,
+ celoGoodIdAddress: goodIdAddress ?? CELO_GOODID_ADDRESS,
+ }),
+ [backendUrl, baseRpcUrl, celoRpcUrl, fundingVaultAddress, celoVault, goodIdAddress],
+ )
+
+ 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) =>
+ withDerivedStatus(prev, { error: 'Connect your wallet before generating a buyer key' }, true),
+ )
+ 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 onManageTab = prev.activeTab === 'manage'
+ return mergeStatePreservingManageTab(prev, {
+ buyerKey: account.address,
+ buyerKeyPrivate: privateKey,
+ buyerKeyConfirmed: onManageTab,
+ operatorConsentSigned: false,
+ apiKey: null,
+ error: null,
+ setupSnippet: buildSetupSnippet(account.address),
+ ...(!onManageTab ? { status: 'purchase_setup' } : {}),
+ })
+ })
+ } catch (err: unknown) {
+ setState((prev) =>
+ withDerivedStatus(prev, {
+ error: err instanceof Error ? err.message : 'Buyer key generation was rejected',
+ }, true),
+ )
+ }
+ }, [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) =>
+ withDerivedStatus(prev, { error: 'Generate a buyer key before signing operator consent' }, true),
+ )
+ return
+ }
+
+ const ref: AccountRef = { payer: currentState.address, buyer: currentState.buyerKey }
+ const onManageTab = currentState.activeTab === 'manage'
+
+ try {
+ const operatorStatus = await chainClient.getBuyerOperatorStatus(ref)
+
+ if (!operatorStatus.enabled) {
+ throw new Error('Operator consent is not available')
+ }
+
+ if (operatorStatus.operatorAccepted) {
+ setState((prev) =>
+ mergeStatePreservingManageTab(prev, {
+ operatorConsentSigned: true,
+ error: null,
+ ...(!onManageTab ? { 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) =>
+ mergeStatePreservingManageTab(prev, {
+ operatorConsentSigned: true,
+ error: null,
+ ...(!onManageTab ? { 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,
+ activeTab: 'manage',
+ }),
+ )
+
+ 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',
+ }),
+ activeTab: prev.activeTab,
+ },
+ 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, { activeTab: 'buy', status: 'purchase_setup', error: null }, true),
+ )
+ }, [])
+
+ const handleSetActiveTab = useCallback((tab: AiCreditsWidgetTab) => {
+ if (tab === 'buy') {
+ setState((prev) =>
+ withDerivedStatus(prev, { activeTab: 'buy', status: 'purchase_setup', error: null }, true),
+ )
+ return
+ }
+ setState((prev) => mergeStatePreservingManageTab(prev, { activeTab: 'manage', error: null }))
+ }, [])
+
+ const handleStartPurchase = useCallback(() => {
+ handleSetActiveTab('buy')
+ }, [handleSetActiveTab])
+
+ 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,
+ setActiveTab: handleSetActiveTab,
+ closeChannel: handleCloseChannel,
+ withdrawCredits: handleWithdrawCredits,
+ retry: handleRetry,
+ }),
+ [
+ handleConnect,
+ handleSwitchChain,
+ handleGenerateBuyerKey,
+ handleConfirmBuyerKey,
+ handleSignOperatorConsent,
+ handleSetDepositAmount,
+ handleSetStreamAmount,
+ handleSetChannelId,
+ handleSetWithdrawAmount,
+ handlePay,
+ handleRefresh,
+ handleStartPurchase,
+ handleSetActiveTab,
+ handleCloseChannel,
+ handleWithdrawCredits,
+ handleRetry,
+ ],
+ )
+
+ return { state, actions }
+}
+
+function buildSetupSnippet(buyerAddress?: string | null): string {
+ const buyerLine = buyerAddress
+ ? `GOODDOLLAR_BUYER_ADDRESS=${buyerAddress}`
+ : 'GOODDOLLAR_BUYER_ADDRESS='
+
+ 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',
+ '',
+ buyerLine,
+ ].join('\n')
+}
diff --git a/packages/ai-credits-widget/src/backendClient.ts b/packages/ai-credits-widget/src/backendClient.ts
new file mode 100644
index 0000000..bd19d96
--- /dev/null
+++ b/packages/ai-credits-widget/src/backendClient.ts
@@ -0,0 +1,579 @@
+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,
+ 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 = await chain.isGoodIdVerified(profile.account)
+ 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
+ ? Number.parseFloat(monthlyStreamG) > 0
+ ? 20
+ : 10
+ : 0,
+ 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..5159c86
--- /dev/null
+++ b/packages/ai-credits-widget/src/celoPayment.ts
@@ -0,0 +1,144 @@
+import {
+ encodeAbiParameters,
+ parseAbi,
+ parseUnits,
+ type Address,
+ type PublicClient,
+ type WalletClient,
+} from 'viem'
+
+export const G_TOKEN_CELO_ADDRESS: Address = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A'
+
+export const CFA_V1_FORWARDER_ADDRESS: Address = '0xcfA132E353cB4E398080B9700609bb008eceB125'
+
+const SECONDS_PER_MONTH = 30n * 24n * 3600n
+
+const G_TOKEN_ABI = parseAbi([
+ '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)',
+])
+
+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 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..eafe391
--- /dev/null
+++ b/packages/ai-credits-widget/src/chainClient.ts
@@ -0,0 +1,368 @@
+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
+export const CELO_GOODID_ADDRESS = '0xC361A6E67822a0EDc17D899227dd9FC50BD62F42' as const
+export const DEFAULT_CELO_RPC_URL = 'https://forno.celo.org'
+
+const CELO_CHAIN: Chain = {
+ id: 42220,
+ name: 'Celo',
+ nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 },
+ rpcUrls: {
+ default: { http: [DEFAULT_CELO_RPC_URL] },
+ public: { http: [DEFAULT_CELO_RPC_URL] },
+ },
+}
+
+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)',
+])
+
+const GOODID_ABI = parseAbi([
+ 'function getWhitelistedRoot(address account) view returns (address)',
+])
+
+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
+ celoGoodIdAddress?: Address
+ depositsAddress?: Address
+}
+
+export interface AiCreditsChainClient {
+ fetchGdUsdPerToken(): Promise
+ isGoodIdVerified(account: string): 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 celoGoodIdAddress?: Address
+ private readonly depositsAddress: Address
+
+ constructor(options: AiCreditsChainClientOptions = {}) {
+ const baseRpcUrl = options.baseRpcUrl ?? DEFAULT_BASE_RPC_URL
+ const celoRpcUrl = options.celoRpcUrl ?? DEFAULT_CELO_RPC_URL
+ this.baseClient = createPublicClient({ chain: BASE_CHAIN, transport: http(baseRpcUrl) })
+ this.fundingVaultAddress = options.fundingVaultAddress
+ this.celoVaultAddress = options.celoVaultAddress
+ this.celoGoodIdAddress = options.celoGoodIdAddress ?? CELO_GOODID_ADDRESS
+ this.depositsAddress = options.depositsAddress ?? ANTSEED_DEPOSITS_BASE_ADDRESS
+ this.celoClient =
+ this.celoVaultAddress || this.celoGoodIdAddress
+ ? createPublicClient({ chain: CELO_CHAIN, transport: http(celoRpcUrl) })
+ : null
+ }
+
+ async isGoodIdVerified(account: string): Promise {
+ if (!this.celoClient || !this.celoGoodIdAddress) return false
+ try {
+ const root = await this.celoClient.readContract({
+ address: this.celoGoodIdAddress,
+ abi: GOODID_ABI,
+ functionName: 'getWhitelistedRoot',
+ args: [normalizeAddress(account) as Address],
+ })
+ const rootAddress = String(root).toLowerCase()
+ return rootAddress !== '0x0000000000000000000000000000000000000000'
+ } catch {
+ return false
+ }
+ }
+
+ 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
+ private readonly goodIdVerified: boolean
+
+ constructor(
+ options: { operatorAccepted?: boolean; gdUsdPerToken?: number; goodIdVerified?: boolean } = {},
+ ) {
+ this.operatorAccepted = options.operatorAccepted ?? false
+ this.gdUsdPerToken = options.gdUsdPerToken ?? 0.0015
+ this.goodIdVerified = options.goodIdVerified ?? false
+ }
+
+ async isGoodIdVerified(_account: string): Promise {
+ return this.goodIdVerified
+ }
+
+ 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,
+ celoGoodIdAddress: options.celoGoodIdAddress ?? CELO_GOODID_ADDRESS,
+ })
+}
diff --git a/packages/ai-credits-widget/src/components/buy/AiCreditsHero.tsx b/packages/ai-credits-widget/src/components/buy/AiCreditsHero.tsx
new file mode 100644
index 0000000..c7d6912
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/buy/AiCreditsHero.tsx
@@ -0,0 +1,46 @@
+import { Card, Icon, Spinner, Text, TokenAmount, XStack, YStack } from '@goodwidget/ui'
+import { BonusBadgeFrame } from '../theme/cards'
+
+interface HeroCardProps {
+ gBalance: string | null
+ isGoodIdVerified: boolean
+ bonusPercent: number
+}
+
+export function AiCreditsHero({ gBalance, isGoodIdVerified, bonusPercent }: HeroCardProps) {
+ const showBonusBadge =
+ gBalance !== null &&
+ Number.parseFloat(gBalance) > 0 &&
+ isGoodIdVerified &&
+ bonusPercent > 0
+
+ return (
+
+
+
+
+ Your G$ Balance
+
+ {gBalance !== null ? (
+
+ ) : (
+
+ )}
+
+
+ {showBonusBadge && (
+
+
+
+ +{bonusPercent}% Bonus
+
+
+ (GoodID)
+
+
+ )}
+
+
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx b/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx
new file mode 100644
index 0000000..6e7d994
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx
@@ -0,0 +1,209 @@
+import { Button, ButtonText, Card, Heading, Input, Separator, Spinner, Text, TokenAmount, XStack, YStack } from '@goodwidget/ui'
+import type { AiCreditsQuote } from '../../widgetRuntimeContract'
+import { parseGAmount } from '../../quoteMath'
+import { formatMinGDisplayLocale, getPaymentAmountValidation } from '../../vaultMinimums'
+import { AiCreditsStatusNotice, BonusBadgeFrame } from '../theme/cards'
+import { HoverTooltip } from '../shared/tooltips'
+import { compactButtonProps } from '../shared/styles'
+
+interface AmountPickerProps {
+ depositAmount: string
+ streamAmount: string
+ gBalance: string | null
+ minDepositG: string | null
+ minStreamG: string | null
+ quote: AiCreditsQuote | null
+ isGoodIdVerified: boolean
+ bonusPercent: number
+ canPay: boolean
+ payDisabledMessage: string | null
+ isPayPending: boolean
+ onDepositChange: (v: string) => void
+ onStreamChange: (v: string) => void
+ onPay: () => void
+ embedded?: boolean
+}
+
+export function AmountPicker({
+ depositAmount,
+ streamAmount,
+ gBalance,
+ minDepositG,
+ minStreamG,
+ quote,
+ isGoodIdVerified,
+ bonusPercent,
+ canPay,
+ payDisabledMessage,
+ isPayPending,
+ onDepositChange,
+ onStreamChange,
+ onPay,
+ embedded = false,
+}: AmountPickerProps) {
+ const depositG = parseGAmount(depositAmount)
+ const streamG = parseGAmount(streamAmount)
+ const appliedBonusPercent = quote?.bonusPercent ?? bonusPercent
+ const depositBonusLabel = isGoodIdVerified ? '+10% bonus' : '+10% with GoodID'
+ const streamBonusLabel = isGoodIdVerified ? '+20% bonus (GoodID)' : '+20% with GoodID'
+ 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)
+ }
+
+ const Shell = embedded ? YStack : Card
+
+ return (
+
+ Buy Credits
+
+
+
+ One-time Deposit (G$)
+
+ {depositBonusLabel}
+
+
+
+ {depositG > 0 && quote && (
+
+ ≈ ${quote.depositAmountUsd} USD
+
+ )}
+ {depositG > 0 && !quote && (
+
+ )}
+
+
+
+
+ Monthly Stream (G$)
+
+ {streamBonusLabel}
+
+
+
+ {streamG > 0 && quote && (
+
+
+ ≈ ${quote.streamAmountUsd} USD/month
+
+
+ )}
+ {streamG > 0 && !quote && (
+
+ )}
+
+
+
+
+
+ Total
+
+
+
+ {quote && (
+
+
+ Est. credits
+
+
+ {formatCredits(quote.totalCredits)}
+
+
+ )}
+
+
+
+ Applied bonus
+
+
+ {appliedBonusPercent > 0 ? (
+
+ +{appliedBonusPercent}%
+
+ ) : (
+
+ No bonus
+
+ )}
+
+
+
+ {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$.
+
+
+ )}
+
+
+
+
+
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/buy/BuyerKeyPanel.tsx b/packages/ai-credits-widget/src/components/buy/BuyerKeyPanel.tsx
new file mode 100644
index 0000000..5ec3f91
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/buy/BuyerKeyPanel.tsx
@@ -0,0 +1,146 @@
+import React, { useState } from 'react'
+import { Button, ButtonText, Card, Heading, Icon, Text, XStack, YStack } from '@goodwidget/ui'
+import { AiCreditsStatusNotice } from '../theme/cards'
+import { monospaceSingleLineStyle, compactButtonProps } from '../shared/styles'
+import { useCopyFeedback } from '../shared/useCopyFeedback'
+
+interface BuyerKeyPanelProps {
+ buyerKey: string | null
+ buyerKeyPrivate: string | null
+ buyerKeyConfirmed: boolean
+ onGenerate: () => void | Promise
+ onConfirm: () => void
+ embedded?: boolean
+}
+
+export function BuyerKeyPanel({
+ buyerKey,
+ buyerKeyPrivate,
+ buyerKeyConfirmed,
+ onGenerate,
+ onConfirm,
+ embedded = false,
+}: 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)
+ }
+ }
+
+ const Shell = embedded ? YStack : Card
+
+ 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 (registered on-chain)
+
+
+
+ {buyerKey}
+
+
+
+
+ {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
+
+
+ )}
+
+ )}
+
+
+ )
+}
diff --git a/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx b/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx
new file mode 100644
index 0000000..a8352f9
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/buy/OperatorConsentStep.tsx
@@ -0,0 +1,72 @@
+import React, { useState } from 'react'
+import { Button, ButtonText, Card, Heading, Icon, Spinner, Text, XStack, YStack } from '@goodwidget/ui'
+import { truncateAddress, compactButtonProps } from '../shared/styles'
+
+interface OperatorConsentStepProps {
+ buyerKey: string | null
+ buyerKeyPrivate: string | null
+ operatorConsentSigned: boolean
+ onSign: () => Promise
+ embedded?: boolean
+}
+
+export function OperatorConsentStep({
+ buyerKey,
+ buyerKeyPrivate,
+ operatorConsentSigned,
+ onSign,
+ embedded = false,
+}: OperatorConsentStepProps) {
+ const [isSigning, setIsSigning] = useState(false)
+ const canSign = Boolean(buyerKey && buyerKeyPrivate)
+
+ const Shell = embedded ? YStack : Card
+
+ 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
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/packages/ai-credits-widget/src/components/flow/AiCreditsFlowStepper.tsx b/packages/ai-credits-widget/src/components/flow/AiCreditsFlowStepper.tsx
new file mode 100644
index 0000000..add792a
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/flow/AiCreditsFlowStepper.tsx
@@ -0,0 +1,82 @@
+import { Heading, Stepper } from '@goodwidget/ui'
+import type { StepperStepItem } from '@goodwidget/ui'
+import type { AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract'
+import type { AiCreditsFlowStep } from './types'
+import { mapStatusToActiveStep } from './purchaseFlowUtils'
+
+const STEP_ORDER: AiCreditsFlowStep[] = ['buyer_key', 'consent', 'pay']
+
+interface AiCreditsFlowStepperProps {
+ state: AiCreditsWidgetAdapterState
+ onStepPress?: (stepId: string) => void
+}
+
+/**
+ * Wraps the Stepper component with widget-specific steps for the purchase flow.
+ */
+export function AiCreditsFlowStepper({ state, onStepPress }: AiCreditsFlowStepperProps) {
+ const activeStep = mapStatusToActiveStep(state)
+
+ function getStepStatus(step: AiCreditsFlowStep): StepperStepItem['status'] {
+ const stepIndex = STEP_ORDER.indexOf(step)
+ if (!activeStep) return 'pending'
+
+ const activeIndex = STEP_ORDER.indexOf(activeStep)
+ if (activeIndex < 0) return 'pending'
+
+ if (stepIndex > activeIndex) return 'pending'
+ if (stepIndex < activeIndex) return 'completed'
+
+ if (step === 'pay' && state.status === 'payment_failed') return 'failed'
+ if (step === 'pay' && state.status === 'payment_confirmed') return 'completed'
+ return 'active'
+ }
+
+ const steps: StepperStepItem[] = [
+ {
+ 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'),
+ },
+ ]
+
+ function handleStepPress(stepId: string) {
+ const step = steps.find((item) => item.id === stepId)
+ if (!step || step.status === 'completed' || step.status === 'pending') return
+ onStepPress?.(stepId)
+ }
+
+ return (
+
+ Purchase Flow
+
+ }
+ />
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx b/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx
new file mode 100644
index 0000000..e2fe6c9
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/flow/AiCreditsPurchaseFlow.tsx
@@ -0,0 +1,141 @@
+import React, { useCallback, useEffect, useState } from 'react'
+import { Button, ButtonText, Drawer, ScrollArea, YStack } from '@goodwidget/ui'
+import type { AiCreditsWidgetAdapterActions, AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract'
+import { AmountPicker } from '../buy/AmountPicker'
+import { BuyerKeyPanel } from '../buy/BuyerKeyPanel'
+import { OperatorConsentStep } from '../buy/OperatorConsentStep'
+import { AiCreditsFlowStepper } from './AiCreditsFlowStepper'
+import type { AiCreditsFlowStep } from './types'
+import { getActiveFlowStepActionLabel, getAiCreditsActiveFlowStep } from './purchaseFlowUtils'
+import { compactButtonProps } from '../shared/styles'
+
+interface AiCreditsPurchaseFlowProps {
+ state: AiCreditsWidgetAdapterState
+ actions: AiCreditsWidgetAdapterActions
+ canPay: boolean
+ payDisabledMessage: string | null
+ isPending: boolean
+ onPay: () => void
+}
+
+export function AiCreditsPurchaseFlow({
+ state,
+ actions,
+ canPay,
+ payDisabledMessage,
+ isPending,
+ onPay,
+}: AiCreditsPurchaseFlowProps) {
+ const activeStep = getAiCreditsActiveFlowStep(state)
+ const [drawerOpen, setDrawerOpen] = useState(true)
+ const [drawerStep, setDrawerStep] = useState(activeStep)
+
+ useEffect(() => {
+ if (!activeStep) {
+ setDrawerOpen(false)
+ return
+ }
+ setDrawerStep(activeStep)
+ setDrawerOpen(true)
+ }, [activeStep])
+
+ const openDrawer = useCallback(
+ (step: AiCreditsFlowStep) => {
+ if (step !== activeStep && !(step === 'pay' && state.status === 'payment_failed')) return
+ setDrawerStep(step)
+ setDrawerOpen(true)
+ },
+ [activeStep, state.status],
+ )
+
+ const handleStepPress = useCallback(
+ (stepId: string) => {
+ openDrawer(stepId as AiCreditsFlowStep)
+ },
+ [openDrawer],
+ )
+
+ const actionLabel = getActiveFlowStepActionLabel(state, activeStep)
+
+ function renderDrawerContent(step: AiCreditsFlowStep | null) {
+ if (!step) return null
+
+ switch (step) {
+ case 'buyer_key':
+ return (
+
+ )
+ case 'consent':
+ return (
+
+ )
+ case 'pay':
+ return (
+
+ )
+ default:
+ return null
+ }
+ }
+
+ return (
+ <>
+
+ {!drawerOpen && actionLabel && activeStep && (
+
+ )}
+ {
+ setDrawerOpen(false)
+ }}
+ height="full"
+ >
+
+
+ {renderDrawerContent(drawerStep)}
+
+
+
+ >
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts b/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts
new file mode 100644
index 0000000..d28b1ac
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/flow/purchaseFlowUtils.ts
@@ -0,0 +1,45 @@
+import type { AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract'
+import type { AiCreditsFlowStep } from './types'
+
+export function mapStatusToActiveStep(
+ state: AiCreditsWidgetAdapterState,
+): AiCreditsFlowStep | null {
+ 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' ||
+ state.status === 'payment_failed'
+ )
+ return 'pay'
+ return null
+}
+
+export function getAiCreditsActiveFlowStep(
+ state: AiCreditsWidgetAdapterState,
+): AiCreditsFlowStep | null {
+ return mapStatusToActiveStep(state)
+}
+
+export function getActiveFlowStepActionLabel(
+ state: AiCreditsWidgetAdapterState,
+ step: AiCreditsFlowStep | null,
+): string | null {
+ if (!step) return null
+
+ switch (step) {
+ case 'buyer_key':
+ if (!state.buyerKey) return 'Sign & Generate Key'
+ if (!state.buyerKeyConfirmed) return "Continue Buyer Key"
+ return 'View Buyer Key'
+ case 'consent':
+ return state.operatorConsentSigned ? 'View Operator Consent' : 'Sign Operator Consent'
+ case 'pay':
+ return 'Set Amounts & Pay'
+ default:
+ return null
+ }
+}
+
diff --git a/packages/ai-credits-widget/src/components/flow/types.ts b/packages/ai-credits-widget/src/components/flow/types.ts
new file mode 100644
index 0000000..9f7e379
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/flow/types.ts
@@ -0,0 +1 @@
+export type AiCreditsFlowStep = 'buyer_key' | 'consent' | 'pay'
diff --git a/packages/ai-credits-widget/src/components/index.ts b/packages/ai-credits-widget/src/components/index.ts
new file mode 100644
index 0000000..485092c
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/index.ts
@@ -0,0 +1,14 @@
+export * from './theme/cards'
+export { AiCreditsHero } from './buy/AiCreditsHero'
+export { BuyerKeyPanel } from './buy/BuyerKeyPanel'
+export { OperatorConsentStep } from './buy/OperatorConsentStep'
+export { AmountPicker } from './buy/AmountPicker'
+export { CreditsManagementCard } from './manage/CreditsManagementCard'
+export { BuyerOperatorCard } from './manage/BuyerOperatorCard'
+export { SetupSnippet } from './manage/SetupSnippet'
+export { UsageLog } from './manage/UsageLog'
+export type { AiCreditsFlowStep } from './flow/types'
+export { getAiCreditsActiveFlowStep } from './flow/purchaseFlowUtils'
+export { AiCreditsFlowStepper } from './flow/AiCreditsFlowStepper'
+export { AiCreditsPurchaseFlow } from './flow/AiCreditsPurchaseFlow'
+
diff --git a/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx
new file mode 100644
index 0000000..11dbf39
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/manage/BuyerOperatorCard.tsx
@@ -0,0 +1,110 @@
+import React, { useState } from 'react'
+import { Button, ButtonText, Card, Heading, Icon, Spinner, Text, XStack, YStack } from '@goodwidget/ui'
+import type { AiCreditsWidgetAdapterActions, AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract'
+import { AddressView } from '../shared/AddressView'
+import { monospaceSingleLineStyle, compactButtonProps } from '../shared/styles'
+import { useCopyFeedback } from '../shared/useCopyFeedback'
+
+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 && (
+
+
+
+ Buyer Private Key
+
+
+
+
+
+ {isPrivateKeyVisible
+ ? buyerKeyPrivate
+ : '•'.repeat(Math.min(48, buyerKeyPrivate.length))}
+
+
+
+
+ )}
+
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/manage/CreditsManagementCard.tsx b/packages/ai-credits-widget/src/components/manage/CreditsManagementCard.tsx
new file mode 100644
index 0000000..eab5a9e
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/manage/CreditsManagementCard.tsx
@@ -0,0 +1,220 @@
+import React, { useState } from 'react'
+import {
+ Button,
+ ButtonText,
+ Card,
+ Heading,
+ Input,
+ Spinner,
+ Text,
+ XStack,
+ YStack,
+} from '@goodwidget/ui'
+import type { AiCreditsWidgetAdapterActions, AiCreditsWidgetAdapterState } from '../../widgetRuntimeContract'
+import { formatUsdMicro } from '../../quoteMath'
+import {
+ BUYER_KEY_REQUIRED_CLOSE_TOOLTIP,
+ BUYER_KEY_REQUIRED_WITHDRAW_TOOLTIP,
+ WITHDRAW_TOOLTIP,
+} from '../shared/constants'
+import { HoverTooltip, InfoTooltip } from '../shared/tooltips'
+import { compactButtonProps } from '../shared/styles'
+
+interface CreditsManagementCardProps {
+ state: AiCreditsWidgetAdapterState
+ actions: Pick<
+ AiCreditsWidgetAdapterActions,
+ 'setChannelId' | 'setWithdrawAmount' | 'closeChannel' | 'withdrawCredits'
+ >
+}
+
+function StatCell({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+
+ {children}
+
+
+ )
+}
+
+function StatValueText({
+ children,
+ color,
+}: {
+ children: React.ReactNode
+ color?: string
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+function formatCompactG(amount: string): string {
+ const value = Number.parseFloat(amount)
+ if (!Number.isFinite(value)) return '0 G$'
+ const formatted = new Intl.NumberFormat('en-US', {
+ notation: 'compact',
+ maximumFractionDigits: 2,
+ }).format(value)
+ return `${formatted} G$`
+}
+
+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)}
+ ) : (
+
+ )}
+
+ {withdrawableDisplay && (
+
+
+ Withdrawable
+
+ ${withdrawableDisplay}
+
+ )}
+
+
+
+
+ {gBalance !== null ? (
+ {formatCompactG(gBalance)}
+ ) : (
+
+ )}
+
+
+ {formatCompactG(totalGdDepositedG ?? '0.00')}
+
+
+ {formatCompactG(monthlyStreamG ?? '0.00')}
+
+
+ {monthlyStreamCredits && Number.parseFloat(monthlyStreamCredits) > 0 ? (
+
+ ~{Number.parseFloat(monthlyStreamCredits).toFixed(2)}/mo
+
+ ) : (
+ —
+ )}
+
+
+
+
+
+ Close Channel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Withdraw
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/packages/ai-credits-widget/src/components/manage/SetupSnippet.tsx b/packages/ai-credits-widget/src/components/manage/SetupSnippet.tsx
new file mode 100644
index 0000000..c01cbc1
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/manage/SetupSnippet.tsx
@@ -0,0 +1,71 @@
+import React, { useState } from 'react'
+import { Anchor, Button, Card, Icon, Text, XStack, YStack, copyTextToClipboard } from '@goodwidget/ui'
+
+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
+
+
+
+ )}
+
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/manage/UsageLog.tsx b/packages/ai-credits-widget/src/components/manage/UsageLog.tsx
new file mode 100644
index 0000000..1f513c6
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/manage/UsageLog.tsx
@@ -0,0 +1,77 @@
+import React, { useState } from 'react'
+import { Card, Icon, Separator, Text, XStack, YStack } from '@goodwidget/ui'
+import type { AiCreditsUsageEntry } from '../../widgetRuntimeContract'
+
+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`
+ : `${total.toFixed(1)} 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
+
+
+
+ ))
+ )}
+
+ )}
+
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/shared/AddressView.tsx b/packages/ai-credits-widget/src/components/shared/AddressView.tsx
new file mode 100644
index 0000000..df5291b
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/shared/AddressView.tsx
@@ -0,0 +1,36 @@
+import { Button, Icon, Text, XStack, YStack } from '@goodwidget/ui'
+import { monospaceSingleLineStyle, truncateAddress } from './styles'
+import { useCopyFeedback } from './useCopyFeedback'
+
+export function AddressView({ label, address }: { label: string; address: string }) {
+ const { copied, copy } = useCopyFeedback()
+
+ return (
+
+
+ {label}
+
+
+
+ {truncateAddress(address)}
+
+
+
+
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/shared/constants.ts b/packages/ai-credits-widget/src/components/shared/constants.ts
new file mode 100644
index 0000000..ba87cd7
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/shared/constants.ts
@@ -0,0 +1,8 @@
+export 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.'
+
+export const BUYER_KEY_REQUIRED_WITHDRAW_TOOLTIP =
+ 'Sign with your payer wallet in Buyer & Operator below to generate the buyer private key before withdrawing funds.'
+
+export const WITHDRAW_TOOLTIP =
+ 'Withdraws principal to your payer wallet (not bonus). Requires the buyer private key from this session.'
diff --git a/packages/ai-credits-widget/src/components/shared/styles.ts b/packages/ai-credits-widget/src/components/shared/styles.ts
new file mode 100644
index 0000000..ecce28e
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/shared/styles.ts
@@ -0,0 +1,18 @@
+import type React from 'react'
+
+export const monospaceSingleLineStyle: React.CSSProperties = {
+ fontFamily: 'monospace',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+}
+
+export function truncateAddress(address: string): string {
+ return `${address.slice(0, 10)}…${address.slice(-6)}`
+}
+
+export const compactButtonProps = {
+ borderRadius: '$3',
+ height: '$7',
+ paddingHorizontal: '$3',
+} as const
diff --git a/packages/ai-credits-widget/src/components/shared/tooltips.tsx b/packages/ai-credits-widget/src/components/shared/tooltips.tsx
new file mode 100644
index 0000000..e6c6874
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/shared/tooltips.tsx
@@ -0,0 +1,75 @@
+import React, { useState } from 'react'
+import { Icon, Text, XStack, YStack } from '@goodwidget/ui'
+
+export 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}
+
+
+ )
+}
+
+export 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 && }
+
+ )
+}
+
diff --git a/packages/ai-credits-widget/src/components/shared/useCopyFeedback.ts b/packages/ai-credits-widget/src/components/shared/useCopyFeedback.ts
new file mode 100644
index 0000000..7775da0
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/shared/useCopyFeedback.ts
@@ -0,0 +1,13 @@
+import { useState } from 'react'
+import { copyTextToClipboard } from '@goodwidget/ui'
+
+export 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 }
+}
+
diff --git a/packages/ai-credits-widget/src/components/theme/cards.ts b/packages/ai-credits-widget/src/components/theme/cards.ts
new file mode 100644
index 0000000..c405518
--- /dev/null
+++ b/packages/ai-credits-widget/src/components/theme/cards.ts
@@ -0,0 +1,17 @@
+import { createComponent, Card, XStack } from '@goodwidget/ui'
+
+export const AiCreditsStatusNotice = createComponent(Card, {
+ name: 'AiCreditsStatusNotice',
+ extends: 'Card',
+ borderWidth: 1,
+ padding: '$3',
+})
+
+export const BonusBadgeFrame = createComponent(XStack, {
+ name: 'BonusBadgeFrame',
+ borderRadius: '$full',
+ paddingHorizontal: '$3',
+ paddingVertical: '$1',
+ alignItems: 'center' as const,
+ gap: '$1',
+})
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..7eaa973
--- /dev/null
+++ b/packages/ai-credits-widget/src/index.ts
@@ -0,0 +1,46 @@
+export { aiCreditsIntegration } from './integration'
+export type { AiCreditsIntegration } from './integration'
+
+export type {
+ AiCreditsWidgetStatus,
+ AiCreditsWidgetTab,
+ 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, CELO_GOODID_ADDRESS, DEFAULT_CELO_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..1fbda53
--- /dev/null
+++ b/packages/ai-credits-widget/src/integration.ts
@@ -0,0 +1,19 @@
+export const aiCreditsIntegration = {
+ id: 'ai-credits',
+ chains: [42220],
+ settlementChains: [8453],
+ states: [
+ 'disconnected',
+ 'purchase_setup',
+ 'quote_ready',
+ 'payment_pending',
+ 'payment_confirmed',
+ '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..5b8983a
--- /dev/null
+++ b/packages/ai-credits-widget/src/quoteMath.ts
@@ -0,0 +1,137 @@
+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()),
+ }
+}
diff --git a/packages/ai-credits-widget/src/register.ts b/packages/ai-credits-widget/src/register.ts
new file mode 100644
index 0000000..e4743b5
--- /dev/null
+++ b/packages/ai-credits-widget/src/register.ts
@@ -0,0 +1,28 @@
+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