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
207 changes: 154 additions & 53 deletions packages/ai-credits-widget/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
parseAbi,
type Address,
type Chain,
type PublicClient,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { buildBuyerKeyMessage, deriveBuyerPrivateKeyFromSignature } from './buyerKeyDerivation'
Expand Down Expand Up @@ -123,7 +124,6 @@ const WALLET_LOADING_STATE: Partial<AiCreditsWidgetAdapterState> = {
gBalance: null,
gdUsdPerToken: null,
totalCreditUsd: null,
isGoodIdVerified: false,
minDepositUsd: null,
minStreamUsd: null,
totalGdDepositedG: null,
Expand Down Expand Up @@ -225,6 +225,36 @@ function hasCreditBalance(totalCreditUsd: string | null): boolean {
return totalCreditUsd !== null && BigInt(totalCreditUsd) > 0n
}

/**
* Reads the wallet's G$ balance from Celo. Both the connect load and the
* refresh action go through here so the Manage tab's "Refresh Balance" button
* actually re-reads the balance it names.
*/
async function readGBalance(client: PublicClient, account: string): Promise<string> {
const [raw, decimals] = await Promise.all([
client.readContract({
address: G_TOKEN_CELO_ADDRESS,
abi: G_TOKEN_ABI,
functionName: 'balanceOf',
args: [account as Address],
}),
client.readContract({
address: G_TOKEN_CELO_ADDRESS,
abi: G_TOKEN_ABI,
functionName: 'decimals',
}),
])
return formatUnits(raw as bigint, decimals as number)
}

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

function deriveStatus(params: {
isConnected: boolean
chainId: number | null
Expand Down Expand Up @@ -335,7 +365,6 @@ function viewToStatePatch(
balanceMode?: 'if_positive' | 'always'
},
): Partial<AiCreditsWidgetAdapterState> {
const operatorAccepted = view.operator.operatorAccepted
const totalCreditUsd = enriched.totalCreditUsd
const totalBonusUsd = view.profile?.totalBonusUsd ?? null
const balanceMode = options?.balanceMode ?? 'if_positive'
Expand All @@ -346,11 +375,17 @@ function viewToStatePatch(
? totalCreditUsd
: prev.totalCreditUsd,
totalBonusUsd,
isGoodIdVerified: enriched.goodIdVerified,
operatorConsented: operatorAccepted,
operatorAddress: view.operator.operatorAddress ?? null,
currentOperator: view.operator.currentOperator ?? null,
withdrawableUsd: view.withdrawableUsd,
// A read that never answered contributes nothing: omitting the key leaves
// the last known value in place instead of asserting "not authorized" or
// wiping the withdrawable balance.
...(view.operator
? {
operatorConsented: view.operator.operatorAccepted,
operatorAddress: view.operator.operatorAddress ?? null,
currentOperator: view.operator.currentOperator ?? null,
}
: {}),
...(view.withdrawableUsd !== null ? { withdrawableUsd: view.withdrawableUsd } : {}),
totalGdDepositedG: enriched.totalGdDepositedG,
monthlyStreamG: enriched.monthlyStreamG,
}
Expand Down Expand Up @@ -413,6 +448,10 @@ export function useAiCreditsAdapter({
const deepLinkApplyInFlightRef = useRef(false)

const celoVault = vaultAddress ?? CELO_GD_ANTSEED_VAULT_FALLBACK
const celoPublicClient = useMemo(
() => createPublicClient({ chain: CELO_CHAIN, transport: http() }),
[],
)
Comment on lines +451 to +454

const backendClient = useMemo<AiCreditsBackendClient>(
() => backendClientOverride ?? createBackendClient(backendUrl),
Expand All @@ -432,6 +471,33 @@ export function useAiCreditsAdapter({
[chainClientOverride, baseRpcUrl, celoRpcUrl, fundingVaultAddress, celoVault, goodIdAddress],
)

/**
* Reads GoodID verification for `account` straight from Celo, independently of
* the backend account fetch. A failed read leaves the previous value alone:
* reporting "not verified" because the RPC rate-limited us is exactly what
* made a verified wallet lose its bonus on some loads.
*/
const readGoodIdVerification = useCallback(
async (account: string) => {
try {
const verified = await chainClient.isGoodIdVerified(account)
setState((prev) =>
addressesMatch(prev.address, account) ? { ...prev, isGoodIdVerified: verified } : prev,
)
} catch {
// Transport failure — says nothing about the wallet, so leave it be.
}
},
[chainClient],
)

// Verification is a property of the wallet, not of the credit balance, so it
// is read when the account changes rather than on every refresh.
useEffect(() => {
if (!isConnected || !address) return
void readGoodIdVerification(address)
}, [isConnected, address, readGoodIdVerification])

useEffect(() => {
if (!isConnected || !address) {
setState((prev) => {
Expand Down Expand Up @@ -483,20 +549,11 @@ export function useAiCreditsAdapter({
})

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 publicClient = celoPublicClient
// Guarded like every other member of the load batch: a forno hiccup on the
// balance read used to reject the whole batch and drop the account view,
// minimums, price, discount config and buyer list along with it.
const balancePromise = readGBalance(publicClient, address!).catch(() => null)

const pendingDeepLink = pendingDeepLinkRef.current
const sessionBuyer = patchPayerSessionFields(address!).buyerPubKey
Expand All @@ -510,7 +567,7 @@ export function useAiCreditsAdapter({
})
.then(async (view) => ({
view,
enriched: await enrichAccountView(view, chainClient),
enriched: enrichAccountView(view),
}))
.catch(() => null)

Expand Down Expand Up @@ -539,7 +596,7 @@ export function useAiCreditsAdapter({
)

try {
const [[rawBalance, decimals], account, minimums, gdUsdPerToken, discountConfig, buyers] =
const [balanceRead, account, minimums, gdUsdPerToken, discountConfig, buyers] =
await Promise.all([
balancePromise,
accountPromise,
Expand All @@ -550,10 +607,13 @@ export function useAiCreditsAdapter({
])
if (cancelled) return

const gBalance = balanceRead

const patch: Partial<AiCreditsWidgetAdapterState> = {
address,
chainId,
gBalance: formatUnits(rawBalance as bigint, decimals as number),
// An unread balance stays null rather than becoming a false '0'.
...(gBalance !== null ? { gBalance } : {}),
gdUsdPerToken,
minDepositUsd: minimums?.minDepositUsd ?? null,
minStreamUsd: minimums?.minStreamUsd ?? null,
Expand All @@ -563,12 +623,20 @@ export function useAiCreditsAdapter({
discountConfig?.streamBonusPercent ?? DEFAULT_DISCOUNT_CONFIG.streamBonusPercent,
}

// `deriveStatus` parks on 'connecting' while the balance is null, so an
// unread balance needs an explicit step off that spinner.
const balanceStalledStatus = (prev: AiCreditsWidgetAdapterState) =>
gBalance === null && !isPaymentInFlight(prev.status)
? { status: 'purchase_setup' as const }
: {}

if (pendingDeepLink || deepLinkApplyInFlightRef.current) {
setState((prev) =>
withDerivedStatus(
prev,
{
...patch,
...balanceStalledStatus(prev),
buyers: mergeBuyerAddressList(prev.buyers, ...buyers),
},
true,
Expand All @@ -589,7 +657,7 @@ export function useAiCreditsAdapter({
resolvedAccount = await buildAccountView(address!, backendClient, chainClient, {
buyerAddress: selectedBuyer,
})
.then(async (view) => ({ view, enriched: await enrichAccountView(view, chainClient) }))
.then((view) => ({ view, enriched: enrichAccountView(view) }))
.catch(() => account)
if (cancelled) return
}
Expand Down Expand Up @@ -617,6 +685,7 @@ export function useAiCreditsAdapter({
prev,
{
...patch,
...balanceStalledStatus(prev),
...accountPatch,
...buyerFields,
operatorConsented:
Expand All @@ -627,19 +696,18 @@ export function useAiCreditsAdapter({
)
} catch {
if (cancelled) return
// Last resort: every individual read is guarded above, so reaching here
// means something unexpected failed. Keep the locally-known session —
// clearing the buyer keys made a configured wallet look brand new — and
// leave the balance unread rather than claiming it is zero.
setState((prev) => {
return withDerivedStatus(
prev,
{
address,
chainId,
gBalance: '0',
buyers: [],
derivedBuyerAddress: null,
buyerPubKey: null,
buyerPrvKey: null,
operatorSignature: null,
operatorConsented: false,
...sessionPatch,
buyers: mergeBuyerAddressList(prev.buyers, ...listKnownBuyerAddresses(address!)),
status:
chainId !== null && chainId !== CELO_CHAIN_ID
? 'unsupported_chain'
Expand All @@ -655,7 +723,16 @@ export function useAiCreditsAdapter({
return () => {
cancelled = true
}
}, [isConnected, address, chainId, backendClient, chainClient, celoVault, configurationError])
}, [
isConnected,
address,
chainId,
backendClient,
chainClient,
celoPublicClient,
celoVault,
configurationError,
])

const handleConnect = useCallback(async () => {
setState((prev) => withDerivedStatus(prev, { status: 'connecting', error: null }, false))
Expand Down Expand Up @@ -808,7 +885,7 @@ export function useAiCreditsAdapter({
const view = await buildAccountView(address, backendClient, chainClient, {
buyerAddress,
})
const enriched = await enrichAccountView(view, chainClient)
const enriched = enrichAccountView(view)
const accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, {
balanceMode: 'always',
})
Expand Down Expand Up @@ -904,7 +981,7 @@ export function useAiCreditsAdapter({
const view = await buildAccountView(address, backendClient, chainClient, {
buyerAddress: buyerAccount.address,
})
const enriched = await enrichAccountView(view, chainClient)
const enriched = enrichAccountView(view)
const accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, {
balanceMode: 'always',
})
Expand Down Expand Up @@ -1380,24 +1457,32 @@ export function useAiCreditsAdapter({
const currentState = state
if (!currentState.address) return

if (options?.afterGoodIdVerify) {
void readGoodIdVerification(currentState.address)
}

try {
const preferredBuyer = currentState.buyerPubKey
const buyerList = resolveBuyerList(currentState.address, preferredBuyer)
const [view, discountConfig] = await Promise.all([

// Each read stands alone: a failed credit read should not also discard a
// G$ balance or discount config that came back fine, and vice versa.
const [account, discountConfig, gBalance] = await Promise.all([
buildAccountView(currentState.address, backendClient, chainClient, {
buyerAddress: preferredBuyer,
}),
})
.then((view) => ({ view, enriched: enrichAccountView(view) }))
.catch(() => null),
backendClient.getDiscountConfig().catch(() => null),
readGBalance(celoPublicClient, currentState.address).catch(() => null),
])
const enriched = await enrichAccountView(view, chainClient)
const accountPatch = viewToStatePatch(view, enriched, INITIAL_STATE, {
balanceMode: 'always',
})
if (
preferredBuyer &&
accountPatch.operatorConsented !== undefined &&
currentState.address
) {

const accountPatch = account
? viewToStatePatch(account.view, account.enriched, INITIAL_STATE, {
balanceMode: 'always',
})
: {}
if (preferredBuyer && accountPatch.operatorConsented !== undefined) {
setBuyerOperatorConsented(
currentState.address,
preferredBuyer,
Expand All @@ -1414,7 +1499,7 @@ export function useAiCreditsAdapter({
const statusSeed =
options?.afterGoodIdVerify && prev.status === 'payment_failed'
? 'quote_ready'
: prev.status === 'backend_unavailable'
: prev.status === 'backend_unavailable' && account
? 'purchase_setup'
: prev.status
return withDerivedStatus(
Expand All @@ -1425,7 +1510,16 @@ export function useAiCreditsAdapter({
operatorConsented:
accountPatch.operatorConsented ?? buyerFields.operatorConsented,
activeTab: prev.activeTab,
error: null,
// An unread balance keeps the one already on screen.
...(gBalance !== null ? { gBalance } : {}),
// Only the credit read failing means the backend is unreachable —
// every peripheral read degrades inside `buildAccountView` now.
...(account
? { error: null }
: {
status: 'backend_unavailable' as const,
error: 'Could not reach backend — check your connection',
}),
depositBonusPercent:
discountConfig?.depositBonusPercent ?? prev.depositBonusPercent,
streamBonusPercent: discountConfig?.streamBonusPercent ?? prev.streamBonusPercent,
Expand All @@ -1434,15 +1528,22 @@ export function useAiCreditsAdapter({
)
})
} catch {
// Every network read above is individually guarded, so this only
// fires for a local session-storage failure. Report it without
// blaming the backend and without dropping what was applied.
setState((prev) =>
mergeStatePreservingNonBuyTab(prev, {
status: 'backend_unavailable',
error: 'Could not reach backend — check your connection',
}),
mergeStatePreservingNonBuyTab(prev, { error: 'Could not refresh — try again' }),
)
}
},
[state, backendClient, chainClient, resolveBuyerList],
[
state,
backendClient,
chainClient,
celoPublicClient,
resolveBuyerList,
readGoodIdVerification,
],
)

const handleVerifyGoodId = useCallback(async (): Promise<boolean> => {
Expand Down
Loading
Loading