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