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
18 changes: 9 additions & 9 deletions packages/ai-credits-widget/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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]!
Expand Down
214 changes: 136 additions & 78 deletions packages/ai-credits-widget/src/celoPayment.ts
Original file line number Diff line number Diff line change
@@ -1,118 +1,162 @@
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',
nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 },
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<BatchOperation> {
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,
functionName: 'getFlowInfo',
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(
Expand All @@ -126,33 +170,47 @@ async function waitForMinedTransaction(
}

export async function executeCeloPayment(params: CeloPaymentParams): Promise<CeloPaymentResult> {
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] }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading