diff --git a/packages/ai-credits-widget/src/adapter.ts b/packages/ai-credits-widget/src/adapter.ts index 5c2b214..933e000 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 bb54e85..16830e8 100644 --- a/packages/ai-credits-widget/src/celoPayment.ts +++ b/packages/ai-credits-widget/src/celoPayment.ts @@ -1,28 +1,46 @@ import { encodeAbiParameters, + encodeFunctionData, parseAbi, - parseUnits, type Address, + type Hex, type PublicClient, type WalletClient, } from 'viem' +import { gToWei } from './quoteMath' export const G_TOKEN_CELO_ADDRESS: Address = '0x62B8B11039FcfE5aB0C56E502b1C372A3d2a9c7A' 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 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', @@ -30,45 +48,78 @@ 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 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 } -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: number, -): Promise<`0x${string}`[]> { + streamAmountG: string, +): Promise { 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, @@ -76,43 +127,36 @@ 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], - }) - await waitForMinedTransaction(publicClient, flowTx) - txHashes.push(flowTx) - - return txHashes -} -async function submitOneTimeDeposit( - walletClient: WalletClient, - publicClient: PublicClient, - payer: Address, - vault: Address, - buyer: Address, - depositAmountG: number, -): Promise<`0x${string}`> { - const depositWei = parseUnits(depositAmountG.toString(), 18) - const userData = encodeBuyerUserData(buyer) + let callData: Hex + if (flowRatePerSecond <= 0n) { + if (existingFlowRate <= 0n) { + throw new Error('No existing stream to cancel') + } + callData = encodeFunctionData({ + abi: CFA_ABI, + functionName: 'deleteFlow', + 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 { + callData = encodeFunctionData({ + abi: CFA_ABI, + functionName: 'createFlow', + args: [G_TOKEN_CELO_ADDRESS, vault, flowRatePerSecond, '0x'], + }) + } - 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 + return { + operationType: OPERATION_TYPE_SUPERFLUID_CALL_AGREEMENT, + target: CFA_V1_ADDRESS, + data: encodeAbiParameters([{ type: 'bytes' }, { type: 'bytes' }], [callData, userData]), + } } async function waitForMinedTransaction( @@ -126,33 +170,47 @@ 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 + const { + walletClient, + publicClient, + payer, + buyer, + vault, + depositAmountG, + streamAmountG, + currentStreamAmountG, + } = params + 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 operations: BatchOperation[] = [] - if (!hasDeposit && !hasStream) { - throw new Error('At least one of deposit or stream amount must be greater than zero') + if (hasDeposit) { + operations.push(buildApproveOperation(vault, depositWei)) } - const txHashes: `0x${string}`[] = [] - - if (hasStream) { - const streamTxHashes = await submitStreamSetup( - walletClient, - publicClient, - payer, - buyer, - vault, - streamAmountG, - ) - txHashes.push(...streamTxHashes) + if (streamChanged) { + 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/AmountPicker.tsx b/packages/ai-credits-widget/src/components/buy/AmountPicker.tsx index 5d65d9a..72b061c 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/components/buy/BuyCreditsFaq.tsx b/packages/ai-credits-widget/src/components/buy/BuyCreditsFaq.tsx index 2f07c52..fd6b0fa 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', diff --git a/packages/ai-credits-widget/src/vaultMinimums.ts b/packages/ai-credits-widget/src/vaultMinimums.ts index 97275f8..3f5b964 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) {