diff --git a/packages/apps/aave-yield-v1/README.md b/packages/apps/aave-yield-v1/README.md new file mode 100644 index 000000000..dd3d551bd --- /dev/null +++ b/packages/apps/aave-yield-v1/README.md @@ -0,0 +1,13 @@ +# Aave Yield V1 (Base-only) + +Minimal CLI to select the highest APY Aave V3 stablecoin pool on Base and deposit via the Vincent Aave ability. + +## Run + +```bash +pnpm nx run aave-yield-v1:run +``` + +## Env + +See `src/lib/config.ts` for required environment variables. diff --git a/packages/apps/aave-yield-v1/package.json b/packages/apps/aave-yield-v1/package.json new file mode 100644 index 000000000..69d1a3d47 --- /dev/null +++ b/packages/apps/aave-yield-v1/package.json @@ -0,0 +1,21 @@ +{ + "name": "@lit-protocol/aave-yield-v1", + "version": "0.0.0", + "private": true, + "scripts": { + "run": "tsx src/cli/run.ts" + }, + "dependencies": { + "@lit-protocol/vincent-ability-aave": "workspace:*", + "@lit-protocol/vincent-ability-relay-link": "workspace:*", + "@lit-protocol/vincent-app-sdk": "workspace:*", + "dotenv": "^16.4.5", + "ethers": "5.8.0", + "tslib": "2.8.1", + "viem": "^2.41.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "tsx": "^4.19.4" + } +} diff --git a/packages/apps/aave-yield-v1/project.json b/packages/apps/aave-yield-v1/project.json new file mode 100644 index 000000000..10b5e6ed4 --- /dev/null +++ b/packages/apps/aave-yield-v1/project.json @@ -0,0 +1,18 @@ +{ + "name": "aave-yield-v1", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/apps/aave-yield-v1/src", + "projectType": "application", + "tags": [], + "targets": { + "run": { + "executor": "nx:run-commands", + "options": { + "command": "pnpm run run", + "cwd": "{projectRoot}" + } + }, + "lint": {}, + "clean": {} + } +} diff --git a/packages/apps/aave-yield-v1/src/cli/run.ts b/packages/apps/aave-yield-v1/src/cli/run.ts new file mode 100644 index 000000000..38d34d57c --- /dev/null +++ b/packages/apps/aave-yield-v1/src/cli/run.ts @@ -0,0 +1,96 @@ +import { disconnectVincentAbilityClients } from '@lit-protocol/vincent-app-sdk/abilityClient'; +import { createPublicClient, formatUnits, http, parseUnits } from 'viem'; + +import { loadConfig } from '../lib/config'; +import { buildUserOp } from '../lib/executor/buildUserOp'; +import { signUserOp } from '../lib/executor/signUserOp'; +import { submitUserOp } from '../lib/executor/submitUserOp'; +import { selectTopPool } from '../lib/strategy/selectTopPool'; +import { ERC20_ABI } from '../lib/utils/erc20'; + +async function main() { + // 1. Load env/config. + const config = loadConfig(); + + // 2. Create Base RPC client. + const baseClient = createPublicClient({ + chain: config.chain, + transport: http(config.baseRpcUrl), + }); + + // 3. Select the top Aave pool on Base. + const topPool = await selectTopPool({ + client: baseClient, + chainId: config.chainId, + allowlistSymbols: config.allowlistSymbols, + }); + + console.log('[strategy] selected pool', { + asset: topPool.asset, + symbol: topPool.symbol, + apr: topPool.apr, + totalSupply: topPool.totalSupply, + }); + + // 4. Resolve amount + balance check. + const amount = parseUnits(config.depositAmount, topPool.decimals); + const balance = (await baseClient.readContract({ + address: topPool.asset, + abi: ERC20_ABI, + functionName: 'balanceOf', + args: [config.agentAddress], + })) as bigint; + + if (balance < amount) { + throw new Error( + `Insufficient ${topPool.symbol} balance on agent. Need ${config.depositAmount}, have ${formatUnits( + balance, + topPool.decimals, + )}.`, + ); + } + + // 5. Build the UserOp (approval + supply). + const userOp = await buildUserOp({ + baseClient, + agentAddress: config.agentAddress, + asset: topPool.asset, + amount, + appId: config.appId, + chain: config.chain, + zerodevRpcUrl: config.zerodevRpcUrl, + serializedPermissionAccount: config.serializedPermissionAccount, + }); + + // 6. Precheck + execute to get a signature. + const { signature } = await signUserOp({ + userOp, + alchemyRpcUrl: config.alchemyRpcUrl, + delegateePrivateKey: config.delegateePrivateKey, + delegatorPkpEthAddress: config.delegatorPkpAddress, + agentAddress: config.agentAddress, + }); + + // 7. Submit the signed UserOp. + const receipt = await submitUserOp({ + agentAddress: config.agentAddress, + serializedPermissionAccount: config.serializedPermissionAccount, + userOpSignature: signature, + userOp, + chain: config.chain, + zerodevRpcUrl: config.zerodevRpcUrl, + }); + + console.log('[submit] userOp included', receipt); +} + +void (async () => { + try { + await main(); + } catch (error) { + console.error('[aave-yield-v1] failed', error); + process.exitCode = 1; + } finally { + await disconnectVincentAbilityClients(); + } +})(); diff --git a/packages/apps/aave-yield-v1/src/lib/config.ts b/packages/apps/aave-yield-v1/src/lib/config.ts new file mode 100644 index 000000000..18ab10b8b --- /dev/null +++ b/packages/apps/aave-yield-v1/src/lib/config.ts @@ -0,0 +1,63 @@ +import { config as loadEnv } from 'dotenv'; +import { z } from 'zod'; +import { getAddress } from 'viem'; +import { base, baseSepolia } from 'viem/chains'; +import type { Address, Chain } from 'viem'; + +const ConfigSchema = z.object({ + APP_ID: z.string(), + DELEGATEE_PRIVATE_KEY: z.string(), + AGENT_ADDRESS: z.string(), + DELEGATOR_PKP_ADDRESS: z.string(), + BASE_RPC_URL: z.string().url(), + ALCHEMY_RPC_URL: z.string().url(), + ZERODEV_RPC_URL: z.string().url(), + SERIALIZED_PERMISSION_ACCOUNT: z.string(), + DEPOSIT_AMOUNT: z.string().regex(/^\d+(\.\d+)?$/), + CHAIN_ID: z.string().optional(), +}); + +export type AppConfig = { + appId: number; + delegateePrivateKey: string; + agentAddress: Address; + delegatorPkpAddress: Address; + baseRpcUrl: string; + alchemyRpcUrl: string; + zerodevRpcUrl: string; + serializedPermissionAccount: string; + depositAmount: string; + chainId: number; + chain: Chain; + allowlistSymbols: string[]; +}; + +export function loadConfig(): AppConfig { + loadEnv(); + const env = ConfigSchema.parse(process.env); + const chainId = Number(env.CHAIN_ID ?? 84532); + + let chain: Chain; + if (chainId === 8453) { + chain = base; + } else if (chainId === 84532) { + chain = baseSepolia; + } else { + throw new Error(`Unsupported CHAIN_ID ${chainId}. Use 8453 or 84532.`); + } + + return { + appId: Number(env.APP_ID), + delegateePrivateKey: env.DELEGATEE_PRIVATE_KEY, + agentAddress: getAddress(env.AGENT_ADDRESS), + delegatorPkpAddress: getAddress(env.DELEGATOR_PKP_ADDRESS), + baseRpcUrl: env.BASE_RPC_URL, + alchemyRpcUrl: env.ALCHEMY_RPC_URL, + zerodevRpcUrl: env.ZERODEV_RPC_URL, + serializedPermissionAccount: env.SERIALIZED_PERMISSION_ACCOUNT, + depositAmount: env.DEPOSIT_AMOUNT, + chainId, + chain, + allowlistSymbols: ['USDC', 'USDbC', 'DAI', 'USDT'], + }; +} diff --git a/packages/apps/aave-yield-v1/src/lib/executor/buildUserOp.ts b/packages/apps/aave-yield-v1/src/lib/executor/buildUserOp.ts new file mode 100644 index 000000000..4371cab28 --- /dev/null +++ b/packages/apps/aave-yield-v1/src/lib/executor/buildUserOp.ts @@ -0,0 +1,82 @@ +import type { Address, Chain, PublicClient } from 'viem'; + +import { + getAaveApprovalTx, + getAaveSupplyTx, + getFeeContractAddress, + toVincentUserOp, +} from '@lit-protocol/vincent-ability-aave'; +import { transactionsToZerodevUserOp } from '@lit-protocol/vincent-ability-relay-link'; + +import { ERC20_ABI } from '../utils/erc20'; + +export async function buildUserOp(params: { + baseClient: PublicClient; + agentAddress: Address; + asset: Address; + amount: bigint; + appId: number; + chain: Chain; + zerodevRpcUrl: string; + serializedPermissionAccount: string; +}) { + const { + baseClient, + agentAddress, + asset, + amount, + appId, + chain, + zerodevRpcUrl, + serializedPermissionAccount, + } = params; + + const feeDiamond = getFeeContractAddress(chain.id); + if (!feeDiamond) { + throw new Error(`Fee Diamond not configured for chain ${chain.id}`); + } + + const allowance = (await baseClient.readContract({ + address: asset, + abi: ERC20_ABI, + functionName: 'allowance', + args: [agentAddress, feeDiamond], + })) as bigint; + + const txs = [] as Array<{ to: Address; data: `0x${string}`; value: `0x${string}` }>; + + if (allowance < amount) { + txs.push( + getAaveApprovalTx({ + accountAddress: agentAddress, + assetAddress: asset, + amount: amount.toString(), + chainId: chain.id, + }), + ); + } + + txs.push( + getAaveSupplyTx({ + appId, + accountAddress: agentAddress, + assetAddress: asset, + amount: amount.toString(), + chainId: chain.id, + }), + ); + + const userOp = await transactionsToZerodevUserOp({ + permittedAddress: agentAddress, + serializedPermissionAccount, + transactions: txs.map((tx) => ({ + to: tx.to, + data: tx.data, + value: BigInt(tx.value).toString(), + })), + chain, + zerodevRpcUrl, + }); + + return toVincentUserOp(userOp as any); +} diff --git a/packages/apps/aave-yield-v1/src/lib/executor/signUserOp.ts b/packages/apps/aave-yield-v1/src/lib/executor/signUserOp.ts new file mode 100644 index 000000000..a03220f3e --- /dev/null +++ b/packages/apps/aave-yield-v1/src/lib/executor/signUserOp.ts @@ -0,0 +1,54 @@ +import type { Hex } from 'viem'; + +import { getVincentAbilityClient } from '@lit-protocol/vincent-app-sdk/abilityClient'; +import { bundledVincentAbility } from '@lit-protocol/vincent-ability-aave'; +import { ethers } from 'ethers'; + +export async function signUserOp(params: { + userOp: Record; + alchemyRpcUrl: string; + delegateePrivateKey: string; + delegatorPkpEthAddress: string; + agentAddress: string; +}): Promise<{ signature: Hex }> { + const { userOp, alchemyRpcUrl, delegateePrivateKey, delegatorPkpEthAddress, agentAddress } = + params; + + const delegateeSigner = new ethers.Wallet(delegateePrivateKey); + const abilityClient = getVincentAbilityClient({ + ethersSigner: delegateeSigner, + bundledVincentAbility, + }); + + const precheck = await abilityClient.precheck( + { + userOp, + alchemyRpcUrl, + }, + { + delegatorPkpEthAddress, + agentAddress, + }, + ); + + if (!precheck.success) { + throw new Error(precheck.result?.error || precheck.runtimeError || 'Ability precheck failed'); + } + + const execute = await abilityClient.execute( + { + userOp, + alchemyRpcUrl, + }, + { + delegatorPkpEthAddress, + agentAddress, + }, + ); + + if (!execute.success) { + throw new Error(execute.result?.error || execute.runtimeError || 'Ability execute failed'); + } + + return { signature: execute.result.signature as Hex }; +} diff --git a/packages/apps/aave-yield-v1/src/lib/executor/submitUserOp.ts b/packages/apps/aave-yield-v1/src/lib/executor/submitUserOp.ts new file mode 100644 index 000000000..a6422fda9 --- /dev/null +++ b/packages/apps/aave-yield-v1/src/lib/executor/submitUserOp.ts @@ -0,0 +1,30 @@ +import type { Address, Chain, Hex } from 'viem'; + +import { submitSignedUserOp } from '@lit-protocol/vincent-ability-relay-link'; + +export async function submitUserOp(params: { + agentAddress: Address; + serializedPermissionAccount: string; + userOpSignature: Hex; + userOp: Record; + chain: Chain; + zerodevRpcUrl: string; +}) { + const { + agentAddress, + serializedPermissionAccount, + userOpSignature, + userOp, + chain, + zerodevRpcUrl, + } = params; + + return submitSignedUserOp({ + permittedAddress: agentAddress, + serializedPermissionAccount, + userOpSignature, + userOp, + chain, + zerodevRpcUrl, + }); +} diff --git a/packages/apps/aave-yield-v1/src/lib/strategy/selectTopPool.ts b/packages/apps/aave-yield-v1/src/lib/strategy/selectTopPool.ts new file mode 100644 index 000000000..291614411 --- /dev/null +++ b/packages/apps/aave-yield-v1/src/lib/strategy/selectTopPool.ts @@ -0,0 +1,77 @@ +import type { Address, PublicClient } from 'viem'; + +import { formatUnits } from 'viem'; + +import { AAVE_POOL_RESERVE_ABI, getPoolAddress, resolveAllowlistedMarkets } from '../utils/aave'; +import { ERC20_ABI } from '../utils/erc20'; + +export type PoolCandidate = { + asset: Address; + symbol: string; + apr: number; + totalSupply: number; + decimals: number; + aTokenAddress: Address; +}; + +export async function selectTopPool(params: { + client: PublicClient; + chainId: number; + allowlistSymbols: string[]; +}): Promise { + const { client, chainId, allowlistSymbols } = params; + const pool = getPoolAddress(chainId); + const allowlisted = resolveAllowlistedMarkets(chainId, allowlistSymbols); + + if (allowlisted.length === 0) { + throw new Error('No allowlisted markets available on this chain.'); + } + + const candidates: PoolCandidate[] = []; + + for (const token of allowlisted) { + const reserveData = (await client.readContract({ + address: pool, + abi: AAVE_POOL_RESERVE_ABI, + functionName: 'getReserveData', + args: [token.address], + })) as { + currentLiquidityRate: bigint; + aTokenAddress: Address; + }; + + const [totalSupply, decimals] = await Promise.all([ + client.readContract({ + address: reserveData.aTokenAddress, + abi: ERC20_ABI, + functionName: 'totalSupply', + }) as Promise, + client.readContract({ + address: token.address, + abi: ERC20_ABI, + functionName: 'decimals', + }) as Promise, + ]); + + const apr = Number(reserveData.currentLiquidityRate) / 1e27; + const normalizedSupply = Number(formatUnits(totalSupply, decimals)); + + if (normalizedSupply >= 1_000_000) { + candidates.push({ + asset: token.address, + symbol: token.symbol, + apr, + totalSupply: normalizedSupply, + decimals, + aTokenAddress: reserveData.aTokenAddress, + }); + } + } + + if (candidates.length === 0) { + throw new Error('No pools meet the minimum liquidity threshold.'); + } + + candidates.sort((a, b) => b.apr - a.apr); + return candidates[0]; +} diff --git a/packages/apps/aave-yield-v1/src/lib/utils/aave.ts b/packages/apps/aave-yield-v1/src/lib/utils/aave.ts new file mode 100644 index 000000000..9d2d1dff6 --- /dev/null +++ b/packages/apps/aave-yield-v1/src/lib/utils/aave.ts @@ -0,0 +1,42 @@ +import type { Address } from 'viem'; + +import { getAaveAddresses, getAvailableMarkets } from '@lit-protocol/vincent-ability-aave'; + +export const AAVE_POOL_RESERVE_ABI = [ + { + name: 'getReserveData', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'asset', type: 'address' }], + outputs: [ + { name: 'configuration', type: 'uint256' }, + { name: 'liquidityIndex', type: 'uint128' }, + { name: 'currentLiquidityRate', type: 'uint128' }, + { name: 'variableBorrowIndex', type: 'uint128' }, + { name: 'currentVariableBorrowRate', type: 'uint128' }, + { name: 'currentStableBorrowRate', type: 'uint128' }, + { name: 'lastUpdateTimestamp', type: 'uint40' }, + { name: 'aTokenAddress', type: 'address' }, + { name: 'stableDebtTokenAddress', type: 'address' }, + { name: 'variableDebtTokenAddress', type: 'address' }, + { name: 'interestRateStrategyAddress', type: 'address' }, + { name: 'id', type: 'uint8' }, + ], + }, +] as const; + +export function getPoolAddress(chainId: number): Address { + return getAaveAddresses(chainId).POOL as Address; +} + +export function resolveAllowlistedMarkets( + chainId: number, + allowlistSymbols: string[], +): Array<{ symbol: string; address: Address }> { + const markets = getAvailableMarkets(chainId); + const allowlist = new Set(allowlistSymbols); + + return Object.entries(markets) + .filter(([symbol]) => allowlist.has(symbol)) + .map(([symbol, address]) => ({ symbol, address: address as Address })); +} diff --git a/packages/apps/aave-yield-v1/src/lib/utils/erc20.ts b/packages/apps/aave-yield-v1/src/lib/utils/erc20.ts new file mode 100644 index 000000000..910e4f565 --- /dev/null +++ b/packages/apps/aave-yield-v1/src/lib/utils/erc20.ts @@ -0,0 +1,48 @@ +import type { Address } from 'viem'; + +export const ERC20_ABI = [ + { + name: 'allowance', + type: 'function', + stateMutability: 'view', + inputs: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + ], + outputs: [{ type: 'uint256' }], + }, + { + name: 'balanceOf', + type: 'function', + stateMutability: 'view', + inputs: [{ name: 'account', type: 'address' }], + outputs: [{ type: 'uint256' }], + }, + { + name: 'decimals', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint8' }], + }, + { + name: 'symbol', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'string' }], + }, + { + name: 'totalSupply', + type: 'function', + stateMutability: 'view', + inputs: [], + outputs: [{ type: 'uint256' }], + }, +] as const; + +export type TokenInfo = { + address: Address; + symbol: string; + decimals: number; +}; diff --git a/packages/apps/aave-yield-v1/tsconfig.json b/packages/apps/aave-yield-v1/tsconfig.json new file mode 100644 index 000000000..dc2188df4 --- /dev/null +++ b/packages/apps/aave-yield-v1/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "module": "CommonJS", + "target": "ES2020", + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +}