From 934a2a91482493caaebb0dc93c5bd40086590927 Mon Sep 17 00:00:00 2001 From: blueogin Date: Thu, 23 Jul 2026 13:40:06 -0400 Subject: [PATCH 1/2] Enhance AI Credits payment validation and stream handling - Introduced `isStreamAmountChanged` function to determine if the stream amount has changed. - Updated payment validation logic to require either a deposit or a change in the monthly stream amount. - Refactored `CeloPaymentParams` to include `currentStreamAmountG` for better state management. - Adjusted error messages and validation checks across various components to improve user feedback and clarity in the payment process. --- packages/ai-credits-widget/src/adapter.ts | 18 ++-- packages/ai-credits-widget/src/celoPayment.ts | 90 +++++++++++++------ .../src/components/buy/AmountPicker.tsx | 6 +- .../ai-credits-widget/src/vaultMinimums.ts | 25 ++++-- 4 files changed, 93 insertions(+), 46 deletions(-) diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 5c2b2145..933e0000 100644 --- a/packages/ai-credits-widget/src/adapter.ts +++ b/packages/ai-credits-widget/src/adapter.ts @@ -38,7 +38,7 @@ import { patchPayerSession, readPayerSession, } from './payerSession' -import { executeCeloPayment, G_TOKEN_CELO_ADDRESS } from './celoPayment' +import { executeCeloPayment, G_TOKEN_CELO_ADDRESS, isStreamAmountChanged } from './celoPayment' import { startGoodIdVerification, isUserRejectedWalletRequest } from './goodIdVerification' import { mapPaymentError } from './paymentErrors' import { fetchVaultPaymentMinimums, validateVaultPaymentAmounts } from './vaultMinimums' @@ -711,12 +711,10 @@ export function useAiCreditsAdapter({ throw new Error('Connect your wallet and generate a buyer key before paying') } - const depositAmountG = Number.parseFloat(quote.depositAmountG) - const streamAmountG = Number.parseFloat(quote.streamAmountG) - const hasDeposit = depositAmountG > 0 - const hasStream = streamAmountG > 0 - if (!hasDeposit && !hasStream) { - throw new Error('Enter a deposit or monthly stream amount') + const hasDeposit = Number.parseFloat(quote.depositAmountG) > 0 + const streamChanged = isStreamAmountChanged(quote.streamAmountG, currentState.monthlyStreamG) + if (!hasDeposit && !streamChanged) { + throw new Error('Enter a deposit or change the monthly stream amount') } let gdUsdPerToken = currentState.gdUsdPerToken @@ -746,6 +744,7 @@ export function useAiCreditsAdapter({ payer: currentState.address as Address, depositAmount: quote.depositAmountG, streamAmount: quote.streamAmountG, + currentStreamAmount: currentState.monthlyStreamG, }) } catch (error) { const message = @@ -802,8 +801,9 @@ export function useAiCreditsAdapter({ payer: payerAddress, buyer: buyerAddress, vault, - depositAmountG, - streamAmountG, + depositAmountG: quote.depositAmountG, + streamAmountG: quote.streamAmountG, + currentStreamAmountG: currentState.monthlyStreamG, }) const txHash = txHashes[txHashes.length - 1]! diff --git a/packages/ai-credits-widget/src/celoPayment.ts b/packages/ai-credits-widget/src/celoPayment.ts index bb54e853..de76453f 100644 --- a/packages/ai-credits-widget/src/celoPayment.ts +++ b/packages/ai-credits-widget/src/celoPayment.ts @@ -1,11 +1,11 @@ import { encodeAbiParameters, parseAbi, - parseUnits, type Address, type PublicClient, type WalletClient, } from 'viem' +import { gToWei } from './quoteMath' export const G_TOKEN_CELO_ADDRESS: Address = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A' @@ -20,6 +20,7 @@ const G_TOKEN_ABI = parseAbi([ 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 deleteFlow(address token, address sender, address receiver, bytes userData) returns (bool)', 'function getFlowInfo(address token, address sender, address receiver) view returns (uint256 lastUpdated, int96 flowrate, uint256 deposit, uint256 owedDeposit)', ]) @@ -36,16 +37,25 @@ export interface CeloPaymentParams { payer: Address buyer: Address vault: Address - depositAmountG: number - streamAmountG: number + depositAmountG: string + streamAmountG: string + currentStreamAmountG: string | null } export interface CeloPaymentResult { txHashes: `0x${string}`[] } -function monthlyToFlowRate(monthlyAmountG: number): bigint { - const monthlyWei = parseUnits(monthlyAmountG.toString(), 18) +export function isStreamAmountChanged( + streamAmountG: string, + currentStreamAmountG: string | null | undefined, +): boolean { + return gToWei(streamAmountG) !== gToWei(currentStreamAmountG ?? '0') +} + +function monthlyToFlowRate(monthlyAmountG: string): bigint { + const monthlyWei = gToWei(monthlyAmountG) + if (monthlyWei <= 0n) return 0n return monthlyWei / SECONDS_PER_MONTH } @@ -59,13 +69,9 @@ async function submitStreamSetup( payer: Address, buyer: Address, vault: Address, - streamAmountG: number, + streamAmountG: string, ): 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}`[] = [] @@ -76,16 +82,32 @@ async function submitStreamSetup( args: [G_TOKEN_CELO_ADDRESS, payer, vault], })) as readonly [bigint, bigint, bigint, bigint] const existingFlowRate = flowInfo[1] - const flowFunction = existingFlowRate > 0n ? 'updateFlow' : 'createFlow' - const flowTx = await walletClient.writeContract({ - account: payer, - chain: CELO_CHAIN, - address: CFA_V1_FORWARDER_ADDRESS, - abi: CFA_FORWARDER_ABI, - functionName: flowFunction, - args: [G_TOKEN_CELO_ADDRESS, payer, vault, flowRatePerSecond, userData], - }) + let flowTx: `0x${string}` + if (flowRatePerSecond <= 0n) { + if (existingFlowRate <= 0n) { + throw new Error('No existing stream to cancel') + } + flowTx = await walletClient.writeContract({ + account: payer, + chain: CELO_CHAIN, + address: CFA_V1_FORWARDER_ADDRESS, + abi: CFA_FORWARDER_ABI, + functionName: 'deleteFlow', + args: [G_TOKEN_CELO_ADDRESS, payer, vault, userData], + }) + } else { + const flowFunction = existingFlowRate > 0n ? 'updateFlow' : 'createFlow' + 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], + }) + } + await waitForMinedTransaction(publicClient, flowTx) txHashes.push(flowTx) @@ -98,9 +120,12 @@ async function submitOneTimeDeposit( payer: Address, vault: Address, buyer: Address, - depositAmountG: number, + depositAmountG: string, ): Promise<`0x${string}`> { - const depositWei = parseUnits(depositAmountG.toString(), 18) + const depositWei = gToWei(depositAmountG) + if (depositWei <= 0n) { + throw new Error('Deposit amount must be greater than zero') + } const userData = encodeBuyerUserData(buyer) const txHash = await walletClient.writeContract({ @@ -126,17 +151,26 @@ async function waitForMinedTransaction( } 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 { + walletClient, + publicClient, + payer, + buyer, + vault, + depositAmountG, + streamAmountG, + currentStreamAmountG, + } = params + const hasDeposit = gToWei(depositAmountG) > 0n + const streamChanged = isStreamAmountChanged(streamAmountG, currentStreamAmountG) + + if (!hasDeposit && !streamChanged) { + throw new Error('Enter a deposit or change the monthly stream amount') } const txHashes: `0x${string}`[] = [] - if (hasStream) { + if (streamChanged) { const streamTxHashes = await submitStreamSetup( walletClient, publicClient, diff --git a/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx b/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx index 5d65d9a6..72b061c9 100644 --- a/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx +++ b/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx @@ -171,20 +171,20 @@ export function AmountPicker({ getPaymentAmountValidation({ depositAmount, streamAmount, + currentStreamAmount: monthlyStreamG, minDepositUsd, minStreamUsd, quote, gdUsdPerToken, gBalance, }), - [depositAmount, streamAmount, minDepositUsd, minStreamUsd, quote, gdUsdPerToken, gBalance], + [depositAmount, streamAmount, monthlyStreamG, minDepositUsd, minStreamUsd, quote, gdUsdPerToken, gBalance], ) const minsLoaded = minStreamUsd !== null - const hasAmounts = depositG > 0 || streamG > 0 const canPay = status === 'quote_ready' && minsLoaded && - hasAmounts && + paymentValidation.hasPaymentAction && paymentValidation.vaultMinimumsMet && !paymentValidation.overBalance && !quotePending && diff --git a/packages/ai-credits-widget/src/vaultMinimums.ts b/packages/ai-credits-widget/src/vaultMinimums.ts index 97275f8d..3f5b964f 100644 --- a/packages/ai-credits-widget/src/vaultMinimums.ts +++ b/packages/ai-credits-widget/src/vaultMinimums.ts @@ -44,6 +44,7 @@ function parseUsdThreshold(usd: string | null): number { export function getPaymentAmountValidation(params: { depositAmount: string streamAmount: string + currentStreamAmount?: string | null minDepositUsd: string | null minStreamUsd: string | null quote: AiCreditsQuote | null @@ -54,10 +55,14 @@ export function getPaymentAmountValidation(params: { streamBelowMin: boolean overBalance: boolean vaultMinimumsMet: boolean + streamChanged: boolean + hasPaymentAction: boolean } { const depositG = parseGAmount(params.depositAmount) const streamG = parseGAmount(params.streamAmount) const balance = parseGAmount(params.gBalance ?? '0') + const streamChanged = gToWei(params.streamAmount) !== gToWei(params.currentStreamAmount ?? '0') + const hasPaymentAction = depositG > 0 || streamChanged const minDepositUsd = parseUsdThreshold(params.minDepositUsd) const minStreamUsd = parseUsdThreshold(params.minStreamUsd) const depositUsd = @@ -71,7 +76,11 @@ export function getPaymentAmountValidation(params: { const depositBelowMin = depositG > 0 && minDepositUsd > 0 && params.quote !== null && depositUsd < minDepositUsd const streamBelowMin = - streamG > 0 && minStreamUsd > 0 && params.quote !== null && streamUsd < minStreamUsd + streamChanged && + streamG > 0 && + minStreamUsd > 0 && + params.quote !== null && + streamUsd < minStreamUsd const overBalance = depositG > balance const minsLoaded = params.minStreamUsd !== null const vaultMinimumsMet = !minsLoaded || (!depositBelowMin && !streamBelowMin) @@ -81,6 +90,8 @@ export function getPaymentAmountValidation(params: { streamBelowMin, overBalance, vaultMinimumsMet, + streamChanged, + hasPaymentAction, } } @@ -108,7 +119,7 @@ export function getPayDisabledMessage(params: { return `Monthly stream must be at least ${formatMinUsdDisplay(params.minStreamUsd)}.` } if (params.status !== 'quote_ready') { - return 'Enter a deposit or monthly stream amount to continue.' + return 'Enter a deposit or change the monthly stream amount to continue.' } return 'Adjust the amounts to continue.' } @@ -184,14 +195,16 @@ export async function validateVaultPaymentAmounts(params: { payer: Address depositAmount: string streamAmount: string + currentStreamAmount?: string | null }): Promise { const depositG = parseGAmount(params.depositAmount) const streamG = parseGAmount(params.streamAmount) const hasDeposit = depositG > 0 - const hasStream = streamG > 0 + const streamChanged = gToWei(params.streamAmount) !== gToWei(params.currentStreamAmount ?? '0') + const hasStreamUpdate = streamChanged && streamG > 0 - if (!hasDeposit && !hasStream) { - throw new Error('Enter a deposit or monthly stream amount') + if (!hasDeposit && !streamChanged) { + throw new Error('Enter a deposit or change the monthly stream amount') } const [minFirstDepositUsd, minMonthlyStreamUsd, totalDeposited] = await Promise.all([ @@ -213,7 +226,7 @@ export async function validateVaultPaymentAmounts(params: { }), ]) - if (hasStream) { + if (hasStreamUpdate) { const monthlyWei = gToWei(params.streamAmount) const streamUsd = await readGdUsd18(params.publicClient, params.vault, monthlyWei) if (streamUsd < minMonthlyStreamUsd) { From 278c2f9088b08ecec7cc76b2b9eec164558276b7 Mon Sep 17 00:00:00 2001 From: blueogin Date: Fri, 24 Jul 2026 08:38:48 -0400 Subject: [PATCH 2/2] Enhance Celo payment handling and update FAQ for clarity - Introduced new batch operation types and refactored payment functions to streamline the process of approving deposits and managing streams. - Updated the `CeloPaymentParams` interface to support new operation structures. - Improved error handling and user feedback in payment functions. - Clarified the FAQ in the BuyCreditsFaq component to explain the batching of transactions during the credit purchase process. --- packages/ai-credits-widget/src/celoPayment.ts | 168 ++++++++++-------- .../src/components/buy/BuyCreditsFaq.tsx | 3 +- 2 files changed, 98 insertions(+), 73 deletions(-) diff --git a/packages/ai-credits-widget/src/celoPayment.ts b/packages/ai-credits-widget/src/celoPayment.ts index de76453f..16830e82 100644 --- a/packages/ai-credits-widget/src/celoPayment.ts +++ b/packages/ai-credits-widget/src/celoPayment.ts @@ -1,7 +1,9 @@ import { encodeAbiParameters, + encodeFunctionData, parseAbi, type Address, + type Hex, type PublicClient, type WalletClient, } from 'viem' @@ -11,19 +13,34 @@ export const G_TOKEN_CELO_ADDRESS: Address = '0x62B8B11039FcfE5aB0C56E502b1C372A export const CFA_V1_FORWARDER_ADDRESS: Address = '0xcfA132E353cB4E398080B9700609bb008eceB125' -const SECONDS_PER_MONTH = 30n * 24n * 3600n +export const CFA_V1_ADDRESS: Address = '0x9d369e78e1a682cE0F8d9aD849BeA4FE1c3bD3Ad' -const G_TOKEN_ABI = parseAbi([ - 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)', -]) +export const SUPERFLUID_HOST_CELO_ADDRESS: Address = '0xA4Ff07cF81C02CFD356184879D953970cA957585' + +const OPERATION_TYPE_ERC20_APPROVE = 1 +const OPERATION_TYPE_SUPERFLUID_CALL_AGREEMENT = 201 +const OPERATION_TYPE_CALL_APP_ACTION = 202 + +const SECONDS_PER_MONTH = 30n * 24n * 3600n 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 deleteFlow(address token, address sender, address receiver, bytes userData) returns (bool)', 'function getFlowInfo(address token, address sender, address receiver) view returns (uint256 lastUpdated, int96 flowrate, uint256 deposit, uint256 owedDeposit)', ]) +const CFA_ABI = parseAbi([ + 'function createFlow(address token, address receiver, int96 flowRate, bytes ctx) returns (bytes newCtx)', + 'function updateFlow(address token, address receiver, int96 flowRate, bytes ctx) returns (bytes newCtx)', + 'function deleteFlow(address token, address sender, address receiver, bytes ctx) returns (bytes newCtx)', +]) + +const VAULT_ABI = parseAbi([ + 'function depositFromAction(uint256 amount, bytes data, bytes ctx) returns (bytes newCtx)', +]) + +const HOST_ABI = parseAbi([ + 'function batchCall((uint32 operationType, address target, bytes data)[] operations) payable', +]) + const CELO_CHAIN = { id: 42220, name: 'Celo', @@ -31,6 +48,12 @@ const CELO_CHAIN = { rpcUrls: { default: { http: ['https://forno.celo.org'] } }, } as const +type BatchOperation = { + operationType: number + target: Address + data: Hex +} + export interface CeloPaymentParams { walletClient: WalletClient publicClient: PublicClient @@ -59,22 +82,44 @@ function monthlyToFlowRate(monthlyAmountG: string): bigint { return monthlyWei / SECONDS_PER_MONTH } -function encodeBuyerUserData(buyer: Address): `0x${string}` { +function encodeBuyerUserData(buyer: Address): Hex { return encodeAbiParameters([{ type: 'address' }], [buyer]) } -async function submitStreamSetup( - walletClient: WalletClient, +function buildApproveOperation(vault: Address, depositWei: bigint): BatchOperation { + return { + operationType: OPERATION_TYPE_ERC20_APPROVE, + target: G_TOKEN_CELO_ADDRESS, + data: encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [vault, depositWei]), + } +} + +function buildDepositFromActionOperation( + vault: Address, + buyer: Address, + depositWei: bigint, +): BatchOperation { + const callData = encodeFunctionData({ + abi: VAULT_ABI, + functionName: 'depositFromAction', + args: [depositWei, encodeBuyerUserData(buyer), '0x'], + }) + return { + operationType: OPERATION_TYPE_CALL_APP_ACTION, + target: vault, + data: callData, + } +} + +async function buildStreamOperation( publicClient: PublicClient, payer: Address, buyer: Address, vault: Address, streamAmountG: string, -): Promise<`0x${string}`[]> { +): Promise { const flowRatePerSecond = monthlyToFlowRate(streamAmountG) const userData = encodeBuyerUserData(buyer) - const txHashes: `0x${string}`[] = [] - const flowInfo = (await publicClient.readContract({ address: CFA_V1_FORWARDER_ADDRESS, abi: CFA_FORWARDER_ABI, @@ -83,61 +128,35 @@ async function submitStreamSetup( })) as readonly [bigint, bigint, bigint, bigint] const existingFlowRate = flowInfo[1] - let flowTx: `0x${string}` + let callData: Hex if (flowRatePerSecond <= 0n) { if (existingFlowRate <= 0n) { throw new Error('No existing stream to cancel') } - flowTx = await walletClient.writeContract({ - account: payer, - chain: CELO_CHAIN, - address: CFA_V1_FORWARDER_ADDRESS, - abi: CFA_FORWARDER_ABI, + callData = encodeFunctionData({ + abi: CFA_ABI, functionName: 'deleteFlow', - args: [G_TOKEN_CELO_ADDRESS, payer, vault, userData], + args: [G_TOKEN_CELO_ADDRESS, payer, vault, '0x'], + }) + } else if (existingFlowRate > 0n) { + callData = encodeFunctionData({ + abi: CFA_ABI, + functionName: 'updateFlow', + args: [G_TOKEN_CELO_ADDRESS, vault, flowRatePerSecond, '0x'], }) } else { - const flowFunction = existingFlowRate > 0n ? 'updateFlow' : 'createFlow' - 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], + callData = encodeFunctionData({ + abi: CFA_ABI, + functionName: 'createFlow', + args: [G_TOKEN_CELO_ADDRESS, vault, flowRatePerSecond, '0x'], }) } - await waitForMinedTransaction(publicClient, flowTx) - txHashes.push(flowTx) - - return txHashes -} - -async function submitOneTimeDeposit( - walletClient: WalletClient, - publicClient: PublicClient, - payer: Address, - vault: Address, - buyer: Address, - depositAmountG: string, -): Promise<`0x${string}`> { - const depositWei = gToWei(depositAmountG) - if (depositWei <= 0n) { - throw new Error('Deposit amount must be greater than zero') + return { + operationType: OPERATION_TYPE_SUPERFLUID_CALL_AGREEMENT, + target: CFA_V1_ADDRESS, + data: encodeAbiParameters([{ type: 'bytes' }, { type: 'bytes' }], [callData, userData]), } - const userData = encodeBuyerUserData(buyer) - - const txHash = await walletClient.writeContract({ - account: payer, - chain: CELO_CHAIN, - address: G_TOKEN_CELO_ADDRESS, - abi: G_TOKEN_ABI, - functionName: 'transferAndCall', - args: [vault, depositWei, userData], - }) - await waitForMinedTransaction(publicClient, txHash) - return txHash } async function waitForMinedTransaction( @@ -161,32 +180,37 @@ export async function executeCeloPayment(params: CeloPaymentParams): Promise 0n + const depositWei = gToWei(depositAmountG) + const hasDeposit = depositWei > 0n const streamChanged = isStreamAmountChanged(streamAmountG, currentStreamAmountG) if (!hasDeposit && !streamChanged) { throw new Error('Enter a deposit or change the monthly stream amount') } - const txHashes: `0x${string}`[] = [] + const operations: BatchOperation[] = [] + + if (hasDeposit) { + operations.push(buildApproveOperation(vault, depositWei)) + } if (streamChanged) { - const streamTxHashes = await submitStreamSetup( - walletClient, - publicClient, - payer, - buyer, - vault, - streamAmountG, - ) - txHashes.push(...streamTxHashes) + operations.push(await buildStreamOperation(publicClient, payer, buyer, vault, streamAmountG)) } if (hasDeposit) { - txHashes.push( - await submitOneTimeDeposit(walletClient, publicClient, payer, vault, buyer, depositAmountG), - ) + operations.push(buildDepositFromActionOperation(vault, buyer, depositWei)) } - return { txHashes } + const txHash = await walletClient.writeContract({ + account: payer, + chain: CELO_CHAIN, + address: SUPERFLUID_HOST_CELO_ADDRESS, + abi: HOST_ABI, + functionName: 'batchCall', + args: [operations], + }) + await waitForMinedTransaction(publicClient, txHash) + + return { txHashes: [txHash] } } diff --git a/packages/ai-credits-widget/src/components/buy/BuyCreditsFaq.tsx b/packages/ai-credits-widget/src/components/buy/BuyCreditsFaq.tsx index 2f07c521..fd6b0faa 100644 --- a/packages/ai-credits-widget/src/components/buy/BuyCreditsFaq.tsx +++ b/packages/ai-credits-widget/src/components/buy/BuyCreditsFaq.tsx @@ -15,7 +15,8 @@ const FAQ_ITEMS = [ question: 'How do I buy credits?', answer: 'Complete the Buy Credits steps: generate a buyer key, sign operator consent, ' + - 'then enter a one-time G$ deposit and/or a monthly G$ stream and confirm the Celo transaction.', + 'then enter a one-time G$ deposit and/or a monthly G$ stream and confirm one Celo transaction ' + + '(approve, deposit, and stream changes are batched when needed).', }, { id: 'deposit-vs-stream',