diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 99a6a71..d1e0bef 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main] +concurrency: + group: e2e-${{ github.ref }} + cancel-in-progress: true + jobs: e2e: runs-on: ubuntu-latest @@ -29,8 +33,20 @@ jobs: run: | echo "PRIVATE_KEY=${{ secrets.PRIVATE_KEY }}" >> .env + - name: Add other env secrets + run: | + echo "PRIVATE_KEY=${{ secrets.PRIVATE_KEY }}" >> .env && \ + echo "CI='true'" >> .env && \ + echo "RPC_API_KEY=${{ secrets.RPC_API_KEY }}" >> .env && \ + echo "RPC_BASE_URL=${{ secrets.RPC_BASE_URL }}" >> .env && \ + echo "PRIVATE_KEY_BASE_WALLET=${{ secrets.PRIVATE_KEY_BASE_WALLET }}" >> .env + - name: Run e2e tests run: pnpm test:e2e env: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} CI: true + RPC_API_KEY: ${{ secrets.RPC_API_KEY }} + RPC_BASE_URL: ${{ secrets.RPC_BASE_URL }} + PRIVATE_KEY_BASE_WALLET: ${{ secrets.PRIVATE_KEY_BASE_WALLET }} + diff --git a/README.md b/README.md index a209227..0eb7b48 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,22 @@ pnpm install pnpm build ``` +## RPC Configuration + +The CLI uses [QuickNode](https://www.quicknode.com/) as its RPC provider. Configure it via environment variables: + +| Variable | Required | Description | +|---|---|---| +| `RPC_BASE_URL` | Yes (for paid RPC) | QuickNode base URL (e.g. `https://your-endpoint-name`) | +| `RPC_API_KEY` | Yes (for paid RPC) | QuickNode API key | +| `RPC_URL_OVERRIDE` | No | Custom RPC URL override for development/testing (overrides all other RPC settings) | + +If `RPC_BASE_URL` and `RPC_API_KEY` are both set, the CLI routes requests through QuickNode for supported chains. If either is missing, it falls back to the chain's default public RPC. + +**Supported chains:** Ethereum (1), Polygon (137), BSC (56), Linea (59144), Base (8453), Base Sepolia (84532), Optimism (10), Arbitrum (42161), Ethereum Sepolia (11155111). Unsupported chains fall back to the default public RPC. + +**Local development:** Set `RPC_URL_OVERRIDE` (e.g. `http://127.0.0.1:8545`) to route all RPC calls through a custom URL, regardless of chain. + ## Manual Testing You can run the CLI via `node dist/index.js` after building. diff --git a/coinfello/SKILL.md b/coinfello/SKILL.md index 5908f61e6..954fdad 100644 --- a/coinfello/SKILL.md +++ b/coinfello/SKILL.md @@ -13,6 +13,15 @@ metadata: description: 'Base URL for the CoinFello API server' required: false default: 'https://app.coinfello.com/' + - name: RPC_BASE_URL + description: 'QuickNode RPC base URL (e.g. https://your-endpoint-name)' + required: false + - name: RPC_API_KEY + description: 'QuickNode API key' + required: false + - name: RPC_URL_OVERRIDE + description: 'Custom RPC URL override for development/testing (overrides all other RPC settings)' + required: false --- # CoinFello CLI Skill @@ -27,9 +36,16 @@ The CLI is available via `npx @coinfello/agent-cli@latest`. No manual build step ## Environment Variables -| Variable | Required | Default | Description | -| -------------------- | -------- | ---------------------------- | ------------------------------ | -| `COINFELLO_BASE_URL` | No | `https://app.coinfello.com/` | Base URL for the CoinFello API | +| Variable | Required | Default | Description | +| -------------------- | -------- | ---------------------------- | ---------------------------------------------------------------------------------- | +| `COINFELLO_BASE_URL` | No | `https://app.coinfello.com/` | Base URL for the CoinFello API | +| `RPC_BASE_URL` | No | — | QuickNode RPC base URL (e.g. `https://your-endpoint-name`) | +| `RPC_API_KEY` | No | — | QuickNode API key | +| `RPC_URL_OVERRIDE` | No | — | Custom RPC URL override for development/testing (overrides all other RPC settings) | + +If both `RPC_BASE_URL` and `RPC_API_KEY` are set, the CLI routes RPC requests through QuickNode for supported chains (Ethereum, Polygon, BSC, Linea, Base, Base Sepolia, Optimism, Arbitrum, Ethereum Sepolia). If either is missing or the chain is not supported, it falls back to the chain's default public RPC. + +Set `RPC_URL_OVERRIDE` (e.g. `http://127.0.0.1:8545`) to route all RPC calls through a custom URL, regardless of chain or other RPC settings. ## Security Notice diff --git a/coinfello/references/REFERENCE.md b/coinfello/references/REFERENCE.md index 81b0da5..8496743 100644 --- a/coinfello/references/REFERENCE.md +++ b/coinfello/references/REFERENCE.md @@ -139,18 +139,20 @@ The server determines whether a delegation is needed and, if so, what scope and ## Supported Chains -Any chain exported by `viem/chains`. Common examples: - -| Chain Name | Chain ID | Network | -| ----------- | -------- | ------------------------ | -| `mainnet` | 1 | Ethereum mainnet | -| `sepolia` | 11155111 | Ethereum Sepolia testnet | -| `polygon` | 137 | Polygon PoS | -| `arbitrum` | 42161 | Arbitrum One | -| `optimism` | 10 | OP Mainnet | -| `base` | 8453 | Base | -| `avalanche` | 43114 | Avalanche C-Chain | -| `bsc` | 56 | BNB Smart Chain | +Any chain exported by `viem/chains` is supported. Chains with a QuickNode slug configured get routed through the paid QuickNode RPC when `RPC_BASE_URL` and `RPC_API_KEY` are set; all others fall back to the chain's default public RPC. + +| Chain Name | Chain ID | Network | QuickNode Support | +| ------------- | -------- | ------------------------ | ----------------- | +| `mainnet` | 1 | Ethereum mainnet | Yes | +| `sepolia` | 11155111 | Ethereum Sepolia testnet | Yes | +| `polygon` | 137 | Polygon PoS | Yes | +| `arbitrum` | 42161 | Arbitrum One | Yes | +| `optimism` | 10 | OP Mainnet | Yes | +| `base` | 8453 | Base | Yes | +| `baseSepolia` | 84532 | Base Sepolia testnet | Yes | +| `linea` | 59144 | Linea mainnet | Yes | +| `bsc` | 56 | BNB Smart Chain | Yes | +| `avalanche` | 43114 | Avalanche C-Chain | No (public RPC) | ## API Endpoints @@ -258,9 +260,18 @@ All `amount` fields are in the token's smallest unit (e.g. `5000000` for 5 USDC ## Environment Variables -| Variable | Required | Default | Description | -| -------------------- | -------- | ---------------------------- | ------------------------------ | -| `COINFELLO_BASE_URL` | No | `https://app.coinfello.com/` | Base URL for the CoinFello API | +| Variable | Required | Default | Description | +| -------------------- | -------- | ---------------------------- | ---------------------------------------------------------------------------------- | +| `COINFELLO_BASE_URL` | No | `https://app.coinfello.com/` | Base URL for the CoinFello API | +| `RPC_BASE_URL` | No | — | QuickNode RPC base URL (e.g. `https://your-endpoint-name`) | +| `RPC_API_KEY` | No | — | QuickNode API key | +| `RPC_URL_OVERRIDE` | No | — | Custom RPC URL override for development/testing (overrides all other RPC settings) | + +**RPC resolution order:** + +1. If `RPC_URL_OVERRIDE` is set, all RPC calls go through the specified URL (any chain). +2. If `RPC_BASE_URL` and `RPC_API_KEY` are both set and the chain has a QuickNode slug, requests use `{RPC_BASE_URL}{slug}.quiknode.pro/{RPC_API_KEY}`. +3. Otherwise, the chain's default public RPC is used. ## Security Considerations diff --git a/package.json b/package.json index d035b78..4b65efc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coinfello/agent-cli", - "version": "0.2.1", + "version": "0.2.2", "description": "CLI for managing a web3 smart account and executing blockchain transactions via CoinFello", "repository": "CoinFello/agent-cli", "homepage": "https://coinfello.com", diff --git a/src/services/createPublicClient.ts b/src/services/createPublicClient.ts index 91f588d..892b7ca 100644 --- a/src/services/createPublicClient.ts +++ b/src/services/createPublicClient.ts @@ -1,31 +1,40 @@ -import { createPublicClient as viemCreatePublicClient, http, type Chain } from 'viem' +import { createPublicClient as viemCreatePublicClient, http, type Chain, Transport } from 'viem' -const INFURA_API_KEY = process.env.INFURA_API_KEY ?? 'b6bf7d3508c941499b10025c0776eaf8' +// @dev quicknode base url and api key +const getBaseUrl = () => process.env.RPC_BASE_URL +const getApiKey = () => process.env.RPC_API_KEY -const INFURA_CHAIN_NAMES: Record = { - 1: 'mainnet', - 11155111: 'sepolia', - 137: 'polygon-mainnet', - 80002: 'polygon-amoy', - 42161: 'arbitrum-mainnet', - 421614: 'arbitrum-sepolia', - 10: 'optimism-mainnet', - 11155420: 'optimism-sepolia', - 8453: 'base-mainnet', - 84532: 'base-sepolia', - 59144: 'linea-mainnet', - 59141: 'linea-sepolia', - 43114: 'avalanche-mainnet', - 43113: 'avalanche-fuji', - 56: 'bsc-mainnet', - 97: 'bsc-testnet', +const QUICKNODE_SLUGS: Record = { + 1: '', + 137: '.matic', + 56: '.bsc', + 59144: '.linea-mainnet', + 8453: '.base-mainnet', + 84532: '.base-sepolia', + 10: '.optimism', + 42161: '.arbitrum-mainnet', + 11155111: '.ethereum-sepolia', +} + +/** + * Returns an `http()` transport using the paid QuickNode RPC for the given chain. + * Falls back to the default public RPC if the chain has no QuickNode endpoint configured. + */ +export function getChainTransport(chainId: number): Transport { + // Local development/testing: route all RPC calls through local anvil + if (process.env.RPC_URL_OVERRIDE) { + return http(process.env.RPC_URL_OVERRIDE) + } + const slug = QUICKNODE_SLUGS[chainId] + if (slug === undefined) { + return http() + } + const rpcUrl = `${getBaseUrl()}${slug}.quiknode.pro/${getApiKey()}` + return http(rpcUrl) } export function createPublicClient(chain: Chain) { - const infuraName = INFURA_CHAIN_NAMES[chain.id] - const transport = infuraName - ? http(`https://${infuraName}.infura.io/v3/${INFURA_API_KEY}`) - : http() + const transport = getBaseUrl() && getApiKey() ? getChainTransport(chain.id) : http() return viemCreatePublicClient({ chain, transport }) } diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 4229c14..660974a 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -1,19 +1,20 @@ import "dotenv/config"; -import { describe, it, expect, beforeAll } from "vitest"; +import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -import { type Hex, createWalletClient, http, parseEther } from "viem"; -import { baseSepolia } from "viem/chains"; +import { type Hex, createPublicClient, createWalletClient, formatEther, formatUnits, http, parseEther } from "viem"; +import { base, baseSepolia } from "viem/chains"; import { createSmartAccount } from "../../src/account.js"; +import { returnRemainingFunds } from "./services.js"; import { signInWithAgent } from "../../src/siwe.js"; import { BASE_URL } from "../../src/api.js"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; +import { getChainTransport } from "../../src/services/createPublicClient.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const CLI_PATH = resolve(__dirname, "../../dist/index.js"); const SIWE_BASE_URL = `${BASE_URL}api/auth`; -const CHAIN = "baseSepolia"; // NOTE: This test makes real network calls, writes to // ~/.clawdbot/skills/coinfello/config.json, and requires a prior `pnpm build`. @@ -40,70 +41,257 @@ function runCli( }); } +const sepoliaPublicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), +}); +const basePublicClient = createPublicClient({ + chain: base, + transport: http(), +}); + describe("send_prompt CLI end-to-end", () => { - beforeAll(async () => { - const privateKey = generatePrivateKey(); - const { address } = await createSmartAccount(privateKey, CHAIN); - - // Fund the smart account with 0.002 Base Sepolia ETH - const fundingKey = process.env.PRIVATE_KEY as Hex; - const fundingAccount = privateKeyToAccount(fundingKey); - const walletClient = createWalletClient({ - account: fundingAccount, - chain: baseSepolia, - transport: http(), + describe('testnet', () => { + const testnetPrivateKey = generatePrivateKey() + let testnetSmartAcctAddress: Hex = '0x' + + beforeAll(async () => { + const { address } = await createSmartAccount(testnetPrivateKey, "baseSepolia"); + testnetSmartAcctAddress = address as Hex + console.log('new testnet smart account address ', testnetSmartAcctAddress) + + // Fund the smart account with 0.002 Base Sepolia ETH + if (!process.env.PRIVATE_KEY){ + throw new Error('no PRIVATE_KEY set') + } + const fundingKey = process.env.PRIVATE_KEY as Hex; + const fundingAccount = privateKeyToAccount(fundingKey); + const balance = await sepoliaPublicClient.getBalance({ address: fundingAccount.address }); + console.log(`Funding account: ${fundingAccount.address}`); + console.log(`Funding account Base Sepolia balance: ${formatEther(balance)} ETH`); + + const walletClient = createWalletClient({ + account: fundingAccount, + chain: baseSepolia, + transport: getChainTransport(baseSepolia.id), + }); + const txHash = await walletClient.sendTransaction({ + to: testnetSmartAcctAddress as Hex, + value: parseEther("0.002"), + }); + await sepoliaPublicClient.waitForTransactionReceipt({ hash: txHash }); + + const config = { + private_key: testnetPrivateKey as Hex, + smart_account_address: address, + chain: "baseSepolia", + }; + + await signInWithAgent(SIWE_BASE_URL, config); + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) }); - await walletClient.sendTransaction({ - to: address as Hex, - value: parseEther("0.002"), + + afterAll(async () => { + const fundingAddress = privateKeyToAccount(process.env.PRIVATE_KEY as Hex).address; + + try { + await returnRemainingFunds({ + privateKey: testnetPrivateKey, + chain: baseSepolia, + publicClient: sepoliaPublicClient, + smartAccountAddress: testnetSmartAcctAddress, + fundingAddress, + ethGasBuffer: parseEther("0.0005"), + }); + } catch (err) { + console.error("Cleanup (Base Sepolia) failed:", err); + } + }, 120_000); + + + // needs to happen after sign in and account creation + it("returns a text response for a read-only prompt via the CLI", async () => { + await runCli(["new_chat"]); + const { stdout, stderr, exitCode } = await runCli(["send_prompt", "hello"]); + + console.log(stdout) + console.error(stderr) + + expect(exitCode).toBe(0); + expect(stdout.trim()).toBeTruthy(); }); - const config = { - private_key: privateKey as Hex, - smart_account_address: address, - chain: CHAIN, - }; + /** + * @dev tests deploying a new smart acct & sending eth in 1 txn + * then sending eth with the deployed account in a separate txn + */ + it("completes the delegation flow when asked to send ETH via the CLI", async () => { + await runCli(["new_chat"]); + const balanceBefore = await sepoliaPublicClient.getBalance({ address: testnetSmartAcctAddress }); + console.log(`Smart account Base Sepolia balance before send: ${formatEther(balanceBefore)} ETH`); - await signInWithAgent(SIWE_BASE_URL, config); - }); + const { stdout, stderr} = await runCli([ + "send_prompt", + "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", + ]); - it("returns a text response for a read-only prompt via the CLI", async () => { - const { stdout, stderr, exitCode } = await runCli(["send_prompt", "hello"]); + console.log(stdout) + console.error(stderr) - console.log(stdout) - console.error(stderr) + // wait for 2 blocks so balance check gets fresh data + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 6000)) - expect(exitCode).toBe(0); - expect(stdout.trim()).toBeTruthy(); - }); + const balanceAfterFirst = await sepoliaPublicClient.getBalance({ address: testnetSmartAcctAddress }); + console.log(`Smart account Base Sepolia balance after first send: ${formatEther(balanceAfterFirst)} ETH`); + expect(balanceAfterFirst).toBeLessThan(balanceBefore); + expect(balanceBefore - balanceAfterFirst).toBeGreaterThanOrEqual(parseEther("0.0001")); - it("completes the delegation flow when asked to send ETH via the CLI", async () => { - const { stdout, stderr} = await runCli([ - "send_prompt", - "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD. call ask_for_delegation", - ]); - - console.log(stdout) - console.error(stderr) - - expect(stdout).toContain("Sending prompt..."); - expect(stdout).toContain("Delegation requested"); - expect(stdout).toContain("Creating subdelegation..."); - expect(stdout).toContain("Signing subdelegation..."); - expect(stdout).toContain("Sending signed delegation..."); - - const { stdout: stdout2, stderr: stderr2} = await runCli([ - "send_prompt", - "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD. call ask_for_delegation", - ]); - - console.log(stdout2) - console.error(stderr2) - - expect(stdout).toContain("Sending prompt..."); - expect(stdout).toContain("Delegation requested"); - expect(stdout).toContain("Creating subdelegation..."); - expect(stdout).toContain("Signing subdelegation..."); - expect(stdout).toContain("Sending signed delegation..."); - }); + // now we check again which will use the deployed smart account sig flow + const { stdout: stdout2, stderr: stderr2} = await runCli([ + "send_prompt", + "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", + ]); + + console.log(stdout2) + console.error(stderr2) + + // wait for 2 blocks so balance check gets fresh data + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 6000)) + + const balanceAfter = await sepoliaPublicClient.getBalance({ address: testnetSmartAcctAddress }); + console.log(`Smart account Base Sepolia balance after send: ${formatEther(balanceAfter)} ETH`); + expect(balanceAfter).toBeLessThan(balanceBefore); + expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0001")); + }); + }) + + describe('base', ()=>{ + let baseSmartAccountAddress: Hex; + let basePrivateKey: Hex; + + beforeAll(async () => { + if (!process.env.PRIVATE_KEY_BASE_WALLET){ + throw new Error('no PRIVATE_KEY_BASE_WALLET set') + } + basePrivateKey = process.env.PRIVATE_KEY_BASE_WALLET as Hex + const { address } = await createSmartAccount(basePrivateKey, 'base'); + console.log('base mainnet smart acct address ', address) + baseSmartAccountAddress = address as Hex; + + // Fund the smart account on Base mainnet (swaps only work on real Base) + const baseBalance = await basePublicClient.getBalance({ address: baseSmartAccountAddress }); + console.log(`Account on Base mainnet has balance: ${formatEther(baseBalance)} ETH`); + + const config = { + private_key: basePrivateKey as Hex, + smart_account_address: address, + chain: 'base', + }; + + await signInWithAgent(SIWE_BASE_URL, config); + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) + }); + + it("completes the delegation flow when asked to swap ETH for USDC via the CLI", async () => { + await runCli(["new_chat"]); + + const balanceBefore = await basePublicClient.getBalance({ address: baseSmartAccountAddress }); + console.log(`Smart account Base mainnet balance before swap: ${formatEther(balanceBefore)} ETH`); + + const { stdout, stderr } = await runCli([ + "send_prompt", + "Swap 0.0001 ETH for USDC on base", + ]); + + console.log(stdout); + console.error(stderr); + + const balanceAfter = await basePublicClient.getBalance({ address: baseSmartAccountAddress }); + console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); + expect(balanceAfter).toBeLessThan(balanceBefore); + expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.00000001")); + + // clean up + const { stdout: stdoutCleanup, stderr: stderrCleanup } = await runCli([ + "send_prompt", + "Swap 0.2 USDC for ETH on base", + ]); + console.log(stdoutCleanup); + console.error(stderrCleanup); + }); + + it("completes the staking/unstaking flow for USDC in the fluid vault on Base via the CLI", async () => { + await runCli(["new_chat"]); + + const USDC_ADDRESS = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" as Hex; + const ERC20_ABI = [ + { + name: "balanceOf", + type: "function", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, + ] as const; + + const usdcBefore = await basePublicClient.readContract({ + address: USDC_ADDRESS, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [baseSmartAccountAddress], + }); + console.log(`Smart account USDC balance before staking: ${formatUnits(usdcBefore, 6)} USDC`); + expect(usdcBefore).toBeGreaterThan(0n); + + // Step 1: Get staking opportunities (read-only) + const { stdout: stdout1, stderr: stderr1 } = await runCli([ + "send_prompt", + "get staking opportunities for usdc on base", + ]); + console.log(stdout1); + console.error(stderr1); + expect(stdout1).toContain("Sending prompt..."); + expect(stdout1.trim()).toBeTruthy(); + + // Step 2: Stake entire USDC balance into the fluid vault + const { stdout: stdout2, stderr: stderr2 } = await runCli([ + "send_prompt", + "stake 2 USDC into the fluid vault on Base", + ]); + console.log(stdout2); + console.error(stderr2); + + // wait for 2 blocks so balance check gets fresh data + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) + + const usdcAfterStake = await basePublicClient.readContract({ + address: USDC_ADDRESS, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [baseSmartAccountAddress], + }); + console.log(`Smart account USDC balance after staking: ${formatUnits(usdcAfterStake, 6)} USDC`); + expect(usdcAfterStake).toBeLessThan(usdcBefore); + + // Step 3: Unstake entire USDC balance from the fluid vault + const { stdout: stdout3, stderr: stderr3 } = await runCli([ + "send_prompt", + "swap ALL of my FUSDC to USDC on Base", + ]); + console.log(stdout3); + console.error(stderr3); + + // wait for 2 blocks so balance check gets fresh data + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) + + const usdcAfterUnstake = await basePublicClient.readContract({ + address: USDC_ADDRESS, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [baseSmartAccountAddress], + }); + console.log(`Smart account USDC balance after unstaking: ${formatUnits(usdcAfterUnstake, 6)} USDC`); + expect(usdcAfterUnstake).toBeGreaterThan(usdcAfterStake); + }); + }) }); diff --git a/tests/e2e/services.ts b/tests/e2e/services.ts new file mode 100644 index 0000000..0a10ffa --- /dev/null +++ b/tests/e2e/services.ts @@ -0,0 +1,159 @@ +import { + type Hex, + type Chain, + encodeFunctionData, + formatEther, + formatUnits, + http, + parseEther, +} from "viem"; +import { createSmartAccount } from "../../src/account.js"; +import { createInfuraBundlerClient } from "@metamask/smart-accounts-kit"; + +const INFURA_API_KEY = + process.env.INFURA_API_KEY ?? "b6bf7d3508c941499b10025c0776eaf8"; + +const INFURA_CHAIN_NAMES: Record = { + 1: "mainnet", + 8453: "base-mainnet", + 84532: "base-sepolia", +}; + +const USDC_ADDRESSES: Partial> = { + 8453: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", +}; + +const ERC20_BALANCE_ABI = [ + { + name: "balanceOf", + type: "function", + inputs: [{ name: "account", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + stateMutability: "view", + }, +] as const; + +const ERC20_TRANSFER_ABI = [ + { + name: "transfer", + type: "function", + inputs: [ + { name: "to", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + stateMutability: "nonpayable", + }, +] as const; + +/** A public client with the methods we need for cleanup. */ +type ReadClient = { + getBalance(args: { address: Hex }): Promise; + readContract(args: { + address: Hex; + abi: readonly unknown[]; + functionName: string; + args: readonly unknown[]; + }): Promise; +}; + +export async function createBundlerForChain(privateKey: Hex, chain: Chain) { + const infuraName = INFURA_CHAIN_NAMES[chain.id]; + if (!infuraName) { + throw new Error(`No Infura bundler mapping for chain ID ${chain.id}`); + } + + const { smartAccount } = await createSmartAccount(privateKey, chain.id); + return createInfuraBundlerClient({ + transport: http( + `https://${infuraName}.infura.io/v3/${INFURA_API_KEY}` + ), + chain, + account: smartAccount, + }); +} + +export async function getUsdcBalance( + publicClient: ReadClient, + chainId: number, + address: Hex +): Promise { + const usdcAddress = USDC_ADDRESSES[chainId]; + if (!usdcAddress) return 0n; + + return publicClient.readContract({ + address: usdcAddress, + abi: ERC20_BALANCE_ABI, + functionName: "balanceOf", + args: [address], + }) as Promise; +} + +function buildUsdcTransferCall( + chainId: number, + to: Hex, + amount: bigint +): { to: Hex; data: Hex } | null { + const usdcAddress = USDC_ADDRESSES[chainId]; + if (!usdcAddress || amount <= 0n) return null; + + return { + to: usdcAddress, + data: encodeFunctionData({ + abi: ERC20_TRANSFER_ABI, + functionName: "transfer", + args: [to, amount], + }), + }; +} + +export async function returnRemainingFunds({ + privateKey, + chain, + publicClient, + smartAccountAddress, + fundingAddress, + ethGasBuffer = parseEther("0.0005"), +}: { + privateKey: Hex; + chain: Chain; + publicClient: ReadClient; + smartAccountAddress: Hex; + fundingAddress: Hex; + ethGasBuffer?: bigint; +}): Promise { + const ethBalance = await publicClient.getBalance({ + address: smartAccountAddress, + }); + const usdcBalance = await getUsdcBalance( + publicClient, + chain.id, + smartAccountAddress + ); + const chainName = chain.name; + + console.log( + `Cleanup [${chainName}]: ${formatEther(ethBalance)} ETH, ${formatUnits(usdcBalance, 6)} USDC` + ); + + const calls: { to: Hex; value?: bigint; data?: Hex }[] = []; + + const usdcCall = buildUsdcTransferCall(chain.id, fundingAddress, usdcBalance); + if (usdcCall) calls.push(usdcCall); + + if (ethBalance > ethGasBuffer) { + calls.push({ to: fundingAddress, value: ethBalance - ethGasBuffer }); + } + + if (calls.length === 0) { + console.log(`Cleanup [${chainName}]: Nothing to return`); + return; + } + + const bundler = await createBundlerForChain(privateKey, chain); + const hash = await bundler.sendUserOperation({ calls }); + await bundler.waitForUserOperationReceipt({ hash }); + console.log( + `Cleanup [${chainName}]: Returned remaining funds to funding account` + ); +}