Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/ai-credits-widget/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# AI Credits Widget

```tsx
import { AiCreditsWidget } from '@goodwidget/ai-credits-widget'

<AiCreditsWidget provider={provider} backendUrl="https://your-ai-credits-backend.example" />
```

For a Custom Element, import `@goodwidget/ai-credits-widget/register`. This import is safe during
Node.js and Next.js server evaluation; it registers `<ai-credits-widget>` only in a browser. Set
the injected EIP-1193 provider and optional `themeOverrides` as element properties.

Production requires `backendUrl`. The widget keeps its Celo and Base RPC and contract defaults
internally and fails closed with a backend-unavailable state when the production backend is absent.
Only the `development` environment may use the built-in mock backend and chain clients.

## EIP-1193 capabilities

Grant only these EIP-1193 methods to the injected provider:

- `eth_accounts` and `eth_chainId` to read the connected wallet.
- `eth_requestAccounts` when the user selects **Connect Wallet**.
- `wallet_switchEthereumChain` when the user selects the Celo network switch action.
- `personal_sign` when the user generates the buyer key.
- `eth_sendTransaction` when the user confirms a Celo payment.
8 changes: 8 additions & 0 deletions packages/ai-credits-widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,18 @@
"name": "@goodwidget/ai-credits-widget",
"version": "0.1.0",
"description": "GoodWidget for buying AI coding credits with G$ on Celo",
"files": [
"dist",
"README.md"
],
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": [
"./dist/register.js",
"./dist/register.cjs"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-credits-widget/src/AiCreditsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
} from '@goodwidget/ui'
import { useAiCreditsAdapter } from './adapter'
import { useAiCreditsHistory } from './useAiCreditsHistory'
import { DEPOSIT_BONUS_PERCENT, STREAM_BONUS_PERCENT } from './quoteMath'
import {
AiCreditsHero,
AiCreditsFlowStepper,
Expand Down Expand Up @@ -308,6 +307,7 @@ function AiCreditsInner({
const history = useAiCreditsHistory({
address: state.address,
backendUrl,
environment,
})

const handlePay = useCallback(
Expand Down
57 changes: 34 additions & 23 deletions packages/ai-credits-widget/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,16 +110,6 @@ const WALLET_LOADING_STATE: Partial<AiCreditsWidgetAdapterState> = {
operatorAddress: null,
}

function isInBuyFlowStatus(status: AiCreditsWidgetStatus): boolean {
return (
status === 'purchase_setup' ||
status === 'quote_ready' ||
status === 'payment_pending' ||
status === 'payment_confirmed' ||
status === 'payment_failed'
)
}

function isNonBuyTab(tab: AiCreditsWidgetTab): boolean {
return tab === 'manage' || tab === 'history'
}
Expand Down Expand Up @@ -341,6 +331,10 @@ export function useAiCreditsAdapter({
}: UseAiCreditsAdapterOptions): AiCreditsWidgetAdapterResult {
const { address, chainId, isConnected, provider, connect } = useWallet()
const [state, setState] = useState<AiCreditsWidgetAdapterState>(INITIAL_STATE)
const configurationError =
environment === 'development' || backendUrl
? null
: 'AI Credits backend is not configured'

const providerRef = useRef<EIP1193Provider | null>(null)
providerRef.current = provider as EIP1193Provider | null
Expand All @@ -349,27 +343,37 @@ export function useAiCreditsAdapter({
const celoVault = vaultAddress ?? CELO_GD_ANTSEED_VAULT_FALLBACK

const backendClient = useMemo<AiCreditsBackendClient>(
() => createBackendClient(backendUrl),
[backendUrl],
() => createBackendClient(backendUrl, environment),
[backendUrl, environment],
)

const chainClient = useMemo<AiCreditsChainClient>(
() =>
createChainClient(backendUrl, {
createChainClient({
baseRpcUrl,
celoRpcUrl,
fundingVaultAddress,
celoVaultAddress: celoVault,
celoGoodIdAddress: goodIdAddress ?? CELO_GOODID_ADDRESS,
}),
[backendUrl, baseRpcUrl, celoRpcUrl, fundingVaultAddress, celoVault, goodIdAddress],
}, environment),
[environment, baseRpcUrl, celoRpcUrl, fundingVaultAddress, celoVault, goodIdAddress],
)

useEffect(() => {
if (!isConnected || !address) {
setState((prev) => (prev.status === 'connecting' ? prev : { ...INITIAL_STATE }))
return
}
if (configurationError) {
setState((prev) => ({
...prev,
address,
chainId,
status: 'backend_unavailable',
error: configurationError,
}))
return
}

let cancelled = false
const sessionPatch = patchPayerSessionFields(address)
Expand All @@ -378,8 +382,7 @@ export function useAiCreditsAdapter({
if (
prev.status === 'payment_pending' ||
prev.status === 'payment_confirmed' ||
prev.status === 'payment_failed' ||
prev.status === 'backend_unavailable'
prev.status === 'payment_failed'
) {
return prev
}
Expand Down Expand Up @@ -511,7 +514,7 @@ export function useAiCreditsAdapter({
return () => {
cancelled = true
}
}, [isConnected, address, chainId, backendClient, chainClient, celoVault])
}, [isConnected, address, chainId, backendClient, chainClient, celoVault, configurationError])

const handleConnect = useCallback(async () => {
setState((prev) => withDerivedStatus(prev, { status: 'connecting', error: null }, false))
Expand Down Expand Up @@ -901,7 +904,9 @@ export function useAiCreditsAdapter({
const statusSeed =
options?.afterGoodIdVerify && prev.status === 'payment_failed'
? 'quote_ready'
: prev.status
: prev.status === 'backend_unavailable'
? 'purchase_setup'
: prev.status
return withDerivedStatus(
{ ...prev, status: statusSeed },
{
Expand Down Expand Up @@ -1101,10 +1106,16 @@ export function useAiCreditsAdapter({
)

const handleRetry = useCallback(async () => {
setState((prev) =>
withDerivedStatus(prev, { activeTab: 'buy', status: 'purchase_setup', error: null }, true),
)
}, [])
if (configurationError) {
setState((prev) => ({
...prev,
status: 'backend_unavailable',
error: configurationError,
}))
return
}
await handleRefresh()
}, [configurationError, handleRefresh])

const handleSetActiveTab = useCallback((tab: AiCreditsWidgetTab) => {
if (tab === 'buy') {
Expand Down
71 changes: 57 additions & 14 deletions packages/ai-credits-widget/src/backendClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,10 +490,7 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient {
return { txHash, events: [entry] }
}

async waitForSettlement(
ref: AccountRef,
_options: { txHashes?: string[]; previousBalance?: string } = {},
): Promise<SettlementResult> {
async waitForSettlement(ref: AccountRef): Promise<SettlementResult> {
await sleep(MOCK_DELAY_MS)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it really waiting for settlement? what is the mock delay for

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is mock-only behavior. It waits for the shared 600ms mock delay to exercise the pending/loading UI, then marks pending mock transactions as funded. It does not poll real settlement; the production client handles actual settlement polling.

const state = this.getState(ref.payer)
for (const entry of state.transactions) {
Expand All @@ -505,10 +502,7 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient {
return { totalCreditUsd: totalCreditUsdFromProfile(this.buildProfile(ref.payer)) }
}

async closeChannel(
channelId: string,
_body: ChannelOperationRequest = {},
): Promise<ChannelOperationResponse> {
async closeChannel(channelId: string): Promise<ChannelOperationResponse> {
await sleep(MOCK_DELAY_MS)
return { channelId, action: 'close', bridge: { enabled: true, txHash: '0xmock' } }
}
Expand All @@ -525,10 +519,7 @@ export class MockAiCreditsBackendClient implements AiCreditsBackendClient {
}
}

async submitOperatorConsent(
buyer: string,
_body: OperatorConsentRequest,
): Promise<OperatorConsentResponse> {
async submitOperatorConsent(buyer: string): Promise<OperatorConsentResponse> {
await sleep(MOCK_DELAY_MS)
const normalizedBuyer = normalizeAddress(buyer)
markMockOperatorConsent(normalizedBuyer)
Expand Down Expand Up @@ -710,10 +701,62 @@ export class ProductionAiCreditsBackendClient implements AiCreditsBackendClient
}
}

export function createBackendClient(backendUrl: string | undefined): AiCreditsBackendClient {
if (!backendUrl) {
export class UnavailableAiCreditsBackendClient implements AiCreditsBackendClient {
private unavailable(): never {
throw new Error('AI Credits backend is not configured')
}

async getDiscountConfig() {
return this.unavailable()
}

async getAccountCredit() {
return this.unavailable()
}

async getCreditHistory() {
return this.unavailable()
}

async getOutstanding() {
return this.unavailable()
}

async getTransactions() {
return this.unavailable()
}

async notifyPayment() {
return this.unavailable()
}

async waitForSettlement() {
return this.unavailable()
}

async closeChannel() {
return this.unavailable()
}

async withdrawCredits() {
return this.unavailable()
}

async submitOperatorConsent() {
return this.unavailable()
}
}

export function createBackendClient(
backendUrl: string | undefined,
environment: 'production' | 'staging' | 'development' = 'production',
): AiCreditsBackendClient {
if (!backendUrl && environment === 'development') {
return new MockAiCreditsBackendClient()
}
if (!backendUrl) {
return new UnavailableAiCreditsBackendClient()
Comment thread
blueogin marked this conversation as resolved.
}
return new ProductionAiCreditsBackendClient(backendUrl)
}

Expand Down
34 changes: 29 additions & 5 deletions packages/ai-credits-widget/src/chainClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type Chain,
type PublicClient,
} from 'viem'
import type { AiCreditsQuote } from './widgetRuntimeContract'
import type { AiCreditsQuote, AiCreditsWidgetEnvironment } from './widgetRuntimeContract'
import type { BuyerOperatorStatus, OperatorConsentPayloadResponse } from './operatorConsent'
import { ANTSEED_DEPOSITS_BASE_ADDRESS, buildSetOperatorPayload } from './operatorConsent'
import type { AccountRef } from './backendTypes'
Expand Down Expand Up @@ -254,7 +254,7 @@ export class MockAiCreditsChainClient implements AiCreditsChainClient {
this.goodIdVerified = options.goodIdVerified ?? false
}

async isGoodIdVerified(_account: string): Promise<boolean> {
async isGoodIdVerified(): Promise<boolean> {
return this.goodIdVerified
}

Expand Down Expand Up @@ -308,16 +308,40 @@ export class MockAiCreditsChainClient implements AiCreditsChainClient {
}
}

async getWithdrawableUsd(_buyer: string): Promise<string> {
async getWithdrawableUsd(): Promise<string> {
return '0'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are these mock values?

@blueogin blueogin Jul 30, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mock intentionally returns "0" because it does not model FundingVault principal balances

}
}

export function createChainClient(
backendUrl: string | undefined,
options: AiCreditsChainClientOptions = {},
options?: AiCreditsChainClientOptions,
environment?: AiCreditsWidgetEnvironment,
): AiCreditsChainClient
export function createChainClient(
options?: AiCreditsChainClientOptions,
environment?: AiCreditsWidgetEnvironment,
): AiCreditsChainClient
export function createChainClient(
backendUrlOrOptions: string | undefined | AiCreditsChainClientOptions = {},
optionsOrEnvironment: AiCreditsChainClientOptions | AiCreditsWidgetEnvironment = {},
maybeEnvironment?: AiCreditsWidgetEnvironment,
): AiCreditsChainClient {
if (!backendUrl) {
const usingLegacySignature =
typeof backendUrlOrOptions === 'string' ||
(backendUrlOrOptions === undefined &&
(typeof optionsOrEnvironment === 'object' || typeof optionsOrEnvironment === 'undefined'))
const backendUrl = usingLegacySignature ? backendUrlOrOptions : undefined
const options = (usingLegacySignature
? typeof optionsOrEnvironment === 'object'
? optionsOrEnvironment
: {}
: backendUrlOrOptions) as AiCreditsChainClientOptions
const environment: AiCreditsWidgetEnvironment = usingLegacySignature
? (maybeEnvironment ?? (backendUrl ? 'production' : 'development'))
: (typeof optionsOrEnvironment === 'string' ? optionsOrEnvironment : 'production')

if (environment === 'development') {
Comment thread
blueogin marked this conversation as resolved.
return new MockAiCreditsChainClient()
}
return new ProductionAiCreditsChainClient({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,7 @@ function StatusFilterSelect({
<YStack flex={1} minWidth={0} position="relative" zIndex={open ? 20 : 1}>
<XStack
tag="button"
role="listbox"
role="combobox"
height="$7"
alignItems="center"
justifyContent="space-between"
Expand Down
9 changes: 8 additions & 1 deletion packages/ai-credits-widget/src/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,18 @@ import type React from 'react'
* el.backendUrl = 'https://api.antseed.xyz'
* el.themeOverrides = { tokens: { color: { primary: '#00AFFE' } } }
*/
export const AiCreditsWidgetElement = createMiniAppElement(
const AiCreditsWidgetElementBase = createMiniAppElement(
AiCreditsWidget as React.ComponentType<Record<string, unknown>>,
{
shadow: true,
defaultTheme: 'dark',
props: {
backendUrl: 'property',
},
events: ['pay-success', 'pay-error'],
},
)

export class AiCreditsWidgetElement extends AiCreditsWidgetElementBase {
declare backendUrl: string | undefined
}
1 change: 1 addition & 0 deletions packages/ai-credits-widget/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export type {
export {
MockAiCreditsBackendClient,
ProductionAiCreditsBackendClient,
UnavailableAiCreditsBackendClient,
createBackendClient,
buildAccountView,
enrichAccountView,
Expand Down
Loading