Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type AiCreditsWidgetAdapterState,
type AiCreditsWidgetStatus,
} from '@goodwidget/ai-credits-widget'
import { MockAiCreditsWidget } from '@goodwidget/ai-credits-widget/mocked'
import {
DefaultAppKitProvider,
useAppKit,
Expand Down Expand Up @@ -295,7 +296,7 @@ export function MockBackendStory() {

return (
<YStack data-testid="AiCreditsWidget-mock-backend" style={{ width: 380 }}>
<AiCreditsWidget provider={injectedProvider} />
<MockAiCreditsWidget provider={injectedProvider} />
</YStack>
)
}
Expand Down
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.
13 changes: 13 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 All @@ -21,6 +29,11 @@
"types": "./dist/register.d.ts",
"import": "./dist/register.js",
"require": "./dist/register.cjs"
},
"./mocked": {
"types": "./dist/mocked/index.d.ts",
"import": "./dist/mocked/index.js",
"require": "./dist/mocked/index.cjs"
}
},
"scripts": {
Expand Down
12 changes: 11 additions & 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 All @@ -36,6 +35,7 @@ import type {
AiCreditsPaySuccessDetail,
AiCreditsPayErrorDetail,
AiCreditsWidgetAdapterFactory,
AiCreditsWidgetAdapterOptions,
AiCreditsWidgetAdapterActions,
AiCreditsWidgetAdapterState,
AiCreditsWidgetTab,
Expand All @@ -54,6 +54,7 @@ interface AiCreditsInnerProps {
vaultAddress?: string
goodIdAddress?: string
adapterFactory?: AiCreditsWidgetAdapterFactory
adapterOptions?: AiCreditsWidgetAdapterOptions
onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
onPayError?: (detail: AiCreditsPayErrorDetail) => void
}
Expand Down Expand Up @@ -283,6 +284,7 @@ function AiCreditsInner({
vaultAddress,
goodIdAddress,
adapterFactory,
adapterOptions,
onPaySuccess,
onPayError,
}: AiCreditsInnerProps) {
Expand All @@ -296,6 +298,10 @@ function AiCreditsInner({
goodIdAddress: goodIdAddress as `0x${string}` | undefined,
onPaySuccess,
onPayError,
backendClient: adapterOptions?.backendClient,
chainClient: adapterOptions?.chainClient,
skipVaultPaymentValidation: adapterOptions?.skipVaultPaymentValidation,
prepareSettlement: adapterOptions?.prepareSettlement,
})

const activeAdapter = useMemo(
Expand All @@ -308,6 +314,8 @@ function AiCreditsInner({
const history = useAiCreditsHistory({
address: state.address,
backendUrl,
environment,
backendClient: adapterOptions?.backendClient,
})

const handlePay = useCallback(
Expand Down Expand Up @@ -403,6 +411,7 @@ export function AiCreditsWidget({
onPaySuccess,
onPayError,
adapterFactory,
adapterOptions,
testId,
}: AiCreditsWidgetProps) {
return (
Expand All @@ -423,6 +432,7 @@ export function AiCreditsWidget({
vaultAddress={vaultAddress}
goodIdAddress={goodIdAddress}
adapterFactory={adapterFactory}
adapterOptions={adapterOptions}
onPaySuccess={onPaySuccess}
onPayError={onPayError}
/>
Expand Down
84 changes: 56 additions & 28 deletions packages/ai-credits-widget/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { privateKeyToAccount } from 'viem/accounts'
import { buildBuyerKeyMessage, deriveBuyerPrivateKeyFromSignature } from './buyerKeyDerivation'
import { normalizeChannelId, signRequestClose, signWithdrawPrincipal } from './buyerSignatures'
import {
MockAiCreditsBackendClient,
totalCreditUsdFromProfile,
buildAccountView,
createBackendClient,
Expand Down Expand Up @@ -110,16 +109,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 @@ -325,6 +314,10 @@ export interface UseAiCreditsAdapterOptions {
goodIdReturnUrl?: string
onPaySuccess?: (detail: AiCreditsPaySuccessDetail) => void
onPayError?: (detail: AiCreditsPayErrorDetail) => void
backendClient?: AiCreditsBackendClient
chainClient?: AiCreditsChainClient
skipVaultPaymentValidation?: boolean
prepareSettlement?: (ref: AccountRef, creditUsd: bigint) => void
}

export function useAiCreditsAdapter({
Expand All @@ -338,9 +331,17 @@ export function useAiCreditsAdapter({
goodIdReturnUrl,
onPaySuccess,
onPayError,
backendClient: backendClientOverride,
chainClient: chainClientOverride,
skipVaultPaymentValidation = false,
prepareSettlement,
}: UseAiCreditsAdapterOptions): AiCreditsWidgetAdapterResult {
const { address, chainId, isConnected, provider, connect } = useWallet()
const [state, setState] = useState<AiCreditsWidgetAdapterState>(INITIAL_STATE)
const configurationError =
backendClientOverride || backendUrl
? null
: 'AI Credits backend is not configured'

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

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

const chainClient = useMemo<AiCreditsChainClient>(
() =>
createChainClient(backendUrl, {
chainClientOverride ??
createChainClient({
baseRpcUrl,
celoRpcUrl,
fundingVaultAddress,
celoVaultAddress: celoVault,
celoGoodIdAddress: goodIdAddress ?? CELO_GOODID_ADDRESS,
}),
[backendUrl, baseRpcUrl, celoRpcUrl, fundingVaultAddress, celoVault, goodIdAddress],
[chainClientOverride, 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 +390,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 @@ -425,7 +436,7 @@ export function useAiCreditsAdapter({
.catch(() => null)

const minimumsPromise =
backendClient instanceof MockAiCreditsBackendClient
skipVaultPaymentValidation
? Promise.resolve({
minDepositUsd: '1.00',
minStreamUsd: '1.00',
Expand Down Expand Up @@ -511,7 +522,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 @@ -735,7 +746,7 @@ export function useAiCreditsAdapter({
throw new Error('Could not build quote — check chain connectivity')
}

if (!(backendClient instanceof MockAiCreditsBackendClient)) {
if (!skipVaultPaymentValidation) {
try {
const publicClient = createPublicClient({ chain: CELO_CHAIN, transport: http() })
await validateVaultPaymentAmounts({
Expand Down Expand Up @@ -787,12 +798,12 @@ export function useAiCreditsAdapter({
buyer: currentState.buyerPubKey,
}

if (backendClient instanceof MockAiCreditsBackendClient) {
if (prepareSettlement) {
const creditUsdMicro = quoteTotalUsdMicro(quote, gdUsdPerToken, currentState.isGoodIdVerified, {
depositBonusPercent: currentState.depositBonusPercent,
streamBonusPercent: currentState.streamBonusPercent,
})
backendClient.prepareSettlement(accountRef, creditUsdMicro)
prepareSettlement(accountRef, creditUsdMicro)
}

const { txHashes } = await executeCeloPayment({
Expand Down Expand Up @@ -861,7 +872,16 @@ export function useAiCreditsAdapter({
throw new Error(message)
}
},
[state, backendClient, chainClient, celoVault, onPaySuccess, onPayError],
[
state,
backendClient,
chainClient,
celoVault,
onPaySuccess,
onPayError,
prepareSettlement,
skipVaultPaymentValidation,
],
)

const handleRefresh = useCallback(
Expand Down Expand Up @@ -901,7 +921,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 +1123,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
Loading