From b87a05b38eff9efce3a2c9a3300fc44553aaab98 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:53:50 -0800 Subject: [PATCH 01/23] add balance logging, improve prompt --- tests/e2e/send-prompt-cli.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 5256567..53a922c 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import { describe, it, expect, beforeAll } from "vitest"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -import { type Hex, createWalletClient, http, parseEther } from "viem"; +import { type Hex, createPublicClient, createWalletClient, formatEther, http, parseEther } from "viem"; import { baseSepolia } from "viem/chains"; import { createSmartAccount } from "../../src/account.js"; import { signInWithAgent } from "../../src/siwe.js"; @@ -47,6 +47,14 @@ describe("send_prompt CLI end-to-end", () => { // Fund the smart account with 0.002 Base Sepolia ETH const fundingKey = process.env.PRIVATE_KEY as Hex; const fundingAccount = privateKeyToAccount(fundingKey); + const publicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), + }); + const balance = await publicClient.getBalance({ address: fundingAccount.address }); + console.log(`Funding account: ${fundingAccount.address}`); + console.log(`Funding account balance: ${formatEther(balance)} ETH`); + const walletClient = createWalletClient({ account: fundingAccount, chain: baseSepolia, @@ -79,7 +87,7 @@ describe("send_prompt CLI end-to-end", () => { 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", + "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", ]); console.log(stdout) @@ -93,7 +101,7 @@ describe("send_prompt CLI end-to-end", () => { const { stdout: stdout2, stderr: stderr2} = await runCli([ "send_prompt", - "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD. call ask_for_delegation", + "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", ]); console.log(stdout2) From 1eeab05d461993f3dd3d3a75e953c74498b16e7c Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:14:00 -0800 Subject: [PATCH 02/23] add swap test, add balance checks --- tests/e2e/send-prompt-cli.test.ts | 73 ++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 53a922c..21dca2d 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -2,7 +2,7 @@ import "dotenv/config"; import { describe, it, expect, beforeAll } from "vitest"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import { type Hex, createPublicClient, createWalletClient, formatEther, http, parseEther } from "viem"; -import { baseSepolia } from "viem/chains"; +import { base, baseSepolia } from "viem/chains"; import { createSmartAccount } from "../../src/account.js"; import { signInWithAgent } from "../../src/siwe.js"; import { BASE_URL } from "../../src/api.js"; @@ -39,21 +39,29 @@ function runCli( }); } +const sepoliaPublicClient = createPublicClient({ + chain: baseSepolia, + transport: http(), +}); +const basePublicClient = createPublicClient({ + chain: base, + transport: http(), +}); + describe("send_prompt CLI end-to-end", () => { + let smartAccountAddress: Hex; + beforeAll(async () => { const privateKey = generatePrivateKey(); const { address } = await createSmartAccount(privateKey, CHAIN); + smartAccountAddress = address as Hex; // Fund the smart account with 0.002 Base Sepolia ETH const fundingKey = process.env.PRIVATE_KEY as Hex; const fundingAccount = privateKeyToAccount(fundingKey); - const publicClient = createPublicClient({ - chain: baseSepolia, - transport: http(), - }); - const balance = await publicClient.getBalance({ address: fundingAccount.address }); + const balance = await sepoliaPublicClient.getBalance({ address: fundingAccount.address }); console.log(`Funding account: ${fundingAccount.address}`); - console.log(`Funding account balance: ${formatEther(balance)} ETH`); + console.log(`Funding account Base Sepolia balance: ${formatEther(balance)} ETH`); const walletClient = createWalletClient({ account: fundingAccount, @@ -65,6 +73,20 @@ describe("send_prompt CLI end-to-end", () => { value: parseEther("0.002"), }); + // Fund the smart account on Base mainnet (swaps only work on real Base) + const baseBalance = await basePublicClient.getBalance({ address: fundingAccount.address }); + console.log(`Funding account Base mainnet balance: ${formatEther(baseBalance)} ETH`); + + const baseWalletClient = createWalletClient({ + account: fundingAccount, + chain: base, + transport: http(), + }); + await baseWalletClient.sendTransaction({ + to: address as Hex, + value: parseEther("0.002"), + }); + const config = { private_key: privateKey as Hex, smart_account_address: address, @@ -85,6 +107,9 @@ describe("send_prompt CLI end-to-end", () => { }); it("completes the delegation flow when asked to send ETH via the CLI", async () => { + const balanceBefore = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); + console.log(`Smart account Base Sepolia balance before send: ${formatEther(balanceBefore)} ETH`); + const { stdout, stderr} = await runCli([ "send_prompt", "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", @@ -99,6 +124,11 @@ describe("send_prompt CLI end-to-end", () => { expect(stdout).toContain("Signing subdelegation..."); expect(stdout).toContain("Sending signed delegation..."); + const balanceAfterFirst = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); + console.log(`Smart account Base Sepolia balance after first send: ${formatEther(balanceAfterFirst)} ETH`); + expect(balanceAfterFirst).toBeLessThan(balanceBefore); + expect(balanceBefore - balanceAfterFirst).toBeGreaterThanOrEqual(parseEther("0.0001")); + const { stdout: stdout2, stderr: stderr2} = await runCli([ "send_prompt", "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", @@ -112,5 +142,34 @@ describe("send_prompt CLI end-to-end", () => { expect(stdout).toContain("Creating subdelegation..."); expect(stdout).toContain("Signing subdelegation..."); expect(stdout).toContain("Sending signed delegation..."); + + const balanceAfter = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); + console.log(`Smart account Base Sepolia balance after send: ${formatEther(balanceAfter)} ETH`); + expect(balanceAfter).toBeLessThan(balanceBefore); + expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0002")); + }); + + it("completes the delegation flow when asked to swap ETH for USDC via the CLI", async () => { + const balanceBefore = await basePublicClient.getBalance({ address: smartAccountAddress }); + 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); + + 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 balanceAfter = await basePublicClient.getBalance({ address: smartAccountAddress }); + console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); + expect(balanceAfter).toBeLessThan(balanceBefore); + expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0001")); }); }); From 932bbfbee31f5e4da260f6d88271ed9e46a70646 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Tue, 3 Mar 2026 21:22:23 -0800 Subject: [PATCH 03/23] test fixes --- tests/e2e/send-prompt-cli.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 21dca2d..4a094a0 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -84,7 +84,7 @@ describe("send_prompt CLI end-to-end", () => { }); await baseWalletClient.sendTransaction({ to: address as Hex, - value: parseEther("0.002"), + value: parseEther("0.00000002"), }); const config = { @@ -155,7 +155,7 @@ describe("send_prompt CLI end-to-end", () => { const { stdout, stderr } = await runCli([ "send_prompt", - "Swap 0.0001 ETH for USDC on base", + "Swap 0.00000001 ETH for USDC on base", ]); console.log(stdout); @@ -170,6 +170,6 @@ describe("send_prompt CLI end-to-end", () => { const balanceAfter = await basePublicClient.getBalance({ address: smartAccountAddress }); console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); expect(balanceAfter).toBeLessThan(balanceBefore); - expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0001")); + expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.00000001")); }); }); From 57b70279db56d74ee9b318ad471f7e831af950dc Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:05:25 -0700 Subject: [PATCH 04/23] add staking & unstaking tests --- tests/e2e/send-prompt-cli.test.ts | 78 ++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 3d4accd..3b0dd1b 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import { describe, it, expect, beforeAll } from "vitest"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -import { type Hex, createPublicClient, createWalletClient, formatEther, http, parseEther } from "viem"; +import { type Hex, createPublicClient, createWalletClient, formatEther, formatUnits, http, parseEther } from "viem"; import { base, baseSepolia } from "viem/chains"; import { createSmartAccount } from "../../src/account.js"; import { signInWithAgent } from "../../src/siwe.js"; @@ -173,4 +173,80 @@ describe("send_prompt CLI end-to-end", () => { expect(balanceAfter).toBeLessThan(balanceBefore); expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.00000001")); }); + + it("completes the staking/unstaking flow for USDC in the fluid vault on Base via the CLI", async () => { + 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: [smartAccountAddress], + }); + 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 into the fluid vault my entire USDC balance on Base", + ]); + console.log(stdout2); + console.error(stderr2); + expect(stdout2).toContain("Sending prompt..."); + expect(stdout2).toContain("Delegation requested"); + expect(stdout2).toContain("Creating subdelegation..."); + expect(stdout2).toContain("Signing subdelegation..."); + expect(stdout2).toContain("Sending signed delegation..."); + + const usdcAfterStake = await basePublicClient.readContract({ + address: USDC_ADDRESS, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [smartAccountAddress], + }); + 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", + "unstake my entire USDC balance from the fluid vault on Base", + ]); + console.log(stdout3); + console.error(stderr3); + expect(stdout3).toContain("Sending prompt..."); + expect(stdout3).toContain("Delegation requested"); + expect(stdout3).toContain("Creating subdelegation..."); + expect(stdout3).toContain("Signing subdelegation..."); + expect(stdout3).toContain("Sending signed delegation..."); + + const usdcAfterUnstake = await basePublicClient.readContract({ + address: USDC_ADDRESS, + abi: ERC20_ABI, + functionName: "balanceOf", + args: [smartAccountAddress], + }); + console.log(`Smart account USDC balance after unstaking: ${formatUnits(usdcAfterUnstake, 6)} USDC`); + expect(usdcAfterUnstake).toBeGreaterThan(usdcAfterStake); + }); }); From a969ac949edce1c837256e11c18bb77f84bf5883 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:13:39 -0700 Subject: [PATCH 05/23] test fixes --- tests/e2e/send-prompt-cli.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 3b0dd1b..fbd12f8 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -147,10 +147,12 @@ describe("send_prompt CLI end-to-end", () => { const balanceAfter = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); console.log(`Smart account Base Sepolia balance after send: ${formatEther(balanceAfter)} ETH`); expect(balanceAfter).toBeLessThan(balanceBefore); - expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0002")); + expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0001")); }); 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: smartAccountAddress }); console.log(`Smart account Base mainnet balance before swap: ${formatEther(balanceBefore)} ETH`); @@ -175,6 +177,8 @@ describe("send_prompt CLI end-to-end", () => { }); 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 = [ { @@ -206,6 +210,7 @@ describe("send_prompt CLI end-to-end", () => { expect(stdout1.trim()).toBeTruthy(); // Step 2: Stake entire USDC balance into the fluid vault + await runCli(["new_chat"]); const { stdout: stdout2, stderr: stderr2 } = await runCli([ "send_prompt", "stake into the fluid vault my entire USDC balance on Base", @@ -228,6 +233,7 @@ describe("send_prompt CLI end-to-end", () => { expect(usdcAfterStake).toBeLessThan(usdcBefore); // Step 3: Unstake entire USDC balance from the fluid vault + await runCli(["new_chat"]); const { stdout: stdout3, stderr: stderr3 } = await runCli([ "send_prompt", "unstake my entire USDC balance from the fluid vault on Base", From 8d66d2fea54fe6b0d4bb173ff8dfd1b2641f6859 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:26:08 -0700 Subject: [PATCH 06/23] test fixes --- tests/e2e/send-prompt-cli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index fbd12f8..aa9e93a 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -98,6 +98,7 @@ describe("send_prompt CLI end-to-end", () => { }); 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) @@ -108,6 +109,7 @@ describe("send_prompt CLI end-to-end", () => { }); it("completes the delegation flow when asked to send ETH via the CLI", async () => { + await runCli(["new_chat"]); const balanceBefore = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); console.log(`Smart account Base Sepolia balance before send: ${formatEther(balanceBefore)} ETH`); @@ -210,7 +212,6 @@ describe("send_prompt CLI end-to-end", () => { expect(stdout1.trim()).toBeTruthy(); // Step 2: Stake entire USDC balance into the fluid vault - await runCli(["new_chat"]); const { stdout: stdout2, stderr: stderr2 } = await runCli([ "send_prompt", "stake into the fluid vault my entire USDC balance on Base", @@ -233,7 +234,6 @@ describe("send_prompt CLI end-to-end", () => { expect(usdcAfterStake).toBeLessThan(usdcBefore); // Step 3: Unstake entire USDC balance from the fluid vault - await runCli(["new_chat"]); const { stdout: stdout3, stderr: stderr3 } = await runCli([ "send_prompt", "unstake my entire USDC balance from the fluid vault on Base", From bcd88c56245798ad6c55a5e9f7478d3b42efc134 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:42:50 -0700 Subject: [PATCH 07/23] decouple stake/unstake e2e test --- tests/e2e/send-prompt-cli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index aa9e93a..7a81b02 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -214,7 +214,7 @@ describe("send_prompt CLI end-to-end", () => { // Step 2: Stake entire USDC balance into the fluid vault const { stdout: stdout2, stderr: stderr2 } = await runCli([ "send_prompt", - "stake into the fluid vault my entire USDC balance on Base", + "stake into the fluid vault 0.0001 USDC balance on Base", ]); console.log(stdout2); console.error(stderr2); @@ -236,7 +236,7 @@ describe("send_prompt CLI end-to-end", () => { // Step 3: Unstake entire USDC balance from the fluid vault const { stdout: stdout3, stderr: stderr3 } = await runCli([ "send_prompt", - "unstake my entire USDC balance from the fluid vault on Base", + "unstake my 0.0001 USDC balance from the fluid vault on Base", ]); console.log(stdout3); console.error(stderr3); From 9b17b7c94fe8a02b358c31272443d2febd627a56 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:36:57 -0700 Subject: [PATCH 08/23] increase test eth, return funds after --- tests/e2e/send-prompt-cli.test.ts | 38 ++++++- tests/e2e/services.ts | 159 ++++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/services.ts diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 7a81b02..69f15a8 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -1,9 +1,10 @@ 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, 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"; @@ -51,9 +52,10 @@ const basePublicClient = createPublicClient({ describe("send_prompt CLI end-to-end", () => { let smartAccountAddress: Hex; + let privateKey: Hex; beforeAll(async () => { - const privateKey = generatePrivateKey(); + privateKey = generatePrivateKey(); const { address } = await createSmartAccount(privateKey, CHAIN); smartAccountAddress = address as Hex; @@ -85,7 +87,7 @@ describe("send_prompt CLI end-to-end", () => { }); await baseWalletClient.sendTransaction({ to: address as Hex, - value: parseEther("0.00000002"), + value: parseEther("0.0004"), }); const config = { @@ -97,6 +99,36 @@ describe("send_prompt CLI end-to-end", () => { await signInWithAgent(SIWE_BASE_URL, config); }); + afterAll(async () => { + const fundingAddress = privateKeyToAccount(process.env.PRIVATE_KEY as Hex).address; + + try { + await returnRemainingFunds({ + privateKey, + chain: baseSepolia, + publicClient: sepoliaPublicClient, + smartAccountAddress, + fundingAddress, + ethGasBuffer: parseEther("0.0005"), + }); + } catch (err) { + console.error("Cleanup (Base Sepolia) failed:", err); + } + + try { + await returnRemainingFunds({ + privateKey, + chain: base, + publicClient: basePublicClient, + smartAccountAddress, + fundingAddress, + ethGasBuffer: parseEther("0.00001"), + }); + } catch (err) { + console.error("Cleanup (Base mainnet) failed:", err); + } + }, 120_000); + 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"]); 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` + ); +} From b9d984abf26f3903c57ad31c1802553e1ece9169 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:45:32 -0700 Subject: [PATCH 09/23] rm expected logs --- tests/e2e/send-prompt-cli.test.ts | 28 ---------------------------- 1 file changed, 28 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 69f15a8..948008c 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -153,12 +153,6 @@ describe("send_prompt CLI end-to-end", () => { 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 balanceAfterFirst = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); console.log(`Smart account Base Sepolia balance after first send: ${formatEther(balanceAfterFirst)} ETH`); expect(balanceAfterFirst).toBeLessThan(balanceBefore); @@ -172,12 +166,6 @@ describe("send_prompt CLI end-to-end", () => { 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..."); - const balanceAfter = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); console.log(`Smart account Base Sepolia balance after send: ${formatEther(balanceAfter)} ETH`); expect(balanceAfter).toBeLessThan(balanceBefore); @@ -198,12 +186,6 @@ describe("send_prompt CLI end-to-end", () => { 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 balanceAfter = await basePublicClient.getBalance({ address: smartAccountAddress }); console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); expect(balanceAfter).toBeLessThan(balanceBefore); @@ -250,11 +232,6 @@ describe("send_prompt CLI end-to-end", () => { ]); console.log(stdout2); console.error(stderr2); - expect(stdout2).toContain("Sending prompt..."); - expect(stdout2).toContain("Delegation requested"); - expect(stdout2).toContain("Creating subdelegation..."); - expect(stdout2).toContain("Signing subdelegation..."); - expect(stdout2).toContain("Sending signed delegation..."); const usdcAfterStake = await basePublicClient.readContract({ address: USDC_ADDRESS, @@ -272,11 +249,6 @@ describe("send_prompt CLI end-to-end", () => { ]); console.log(stdout3); console.error(stderr3); - expect(stdout3).toContain("Sending prompt..."); - expect(stdout3).toContain("Delegation requested"); - expect(stdout3).toContain("Creating subdelegation..."); - expect(stdout3).toContain("Signing subdelegation..."); - expect(stdout3).toContain("Sending signed delegation..."); const usdcAfterUnstake = await basePublicClient.readContract({ address: USDC_ADDRESS, From c4b8d7e39ddd3229e8f80e10018164d375ac5219 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:24:34 -0700 Subject: [PATCH 10/23] use paid rpc for tests --- .github/workflows/e2e.yml | 3 ++ src/services/createPublicClient.ts | 53 +++++++++++++++++------------- 2 files changed, 33 insertions(+), 23 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 99a6a71..e8a7960 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,3 +34,6 @@ jobs: env: PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} CI: true + RPC_API_KEY: ${{ secrets.RPC_API_KEY }} + RPC_BASE_URL: ${{ secrets.RPC_BASE_URL }} + diff --git a/src/services/createPublicClient.ts b/src/services/createPublicClient.ts index 91f588d..8c2b27b 100644 --- a/src/services/createPublicClient.ts +++ b/src/services/createPublicClient.ts @@ -1,31 +1,38 @@ -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', + 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.ANVIL_RPC_URL) { + return http(process.env.ANVIL_RPC_URL) + } + const slug = QUICKNODE_SLUGS[chainId] + if (slug === undefined) { + return http() + } + return http(`${getBaseUrl()}${slug}.quiknode.pro/${getApiKey()}`) } 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 }) } From 7ece57b40080138d13332552dc3d68985761cc0d Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:24:49 -0700 Subject: [PATCH 11/23] wait for funding txn to finish before tests --- tests/e2e/send-prompt-cli.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 948008c..8c18252 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -71,10 +71,11 @@ describe("send_prompt CLI end-to-end", () => { chain: baseSepolia, transport: http(), }); - await walletClient.sendTransaction({ + const txHash = await walletClient.sendTransaction({ to: address as Hex, value: parseEther("0.002"), }); + await sepoliaPublicClient.waitForTransactionReceipt({ hash: txHash }); // Fund the smart account on Base mainnet (swaps only work on real Base) const baseBalance = await basePublicClient.getBalance({ address: fundingAccount.address }); @@ -97,6 +98,7 @@ describe("send_prompt CLI end-to-end", () => { }; await signInWithAgent(SIWE_BASE_URL, config); + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) }); afterAll(async () => { @@ -158,6 +160,7 @@ describe("send_prompt CLI end-to-end", () => { expect(balanceAfterFirst).toBeLessThan(balanceBefore); expect(balanceBefore - balanceAfterFirst).toBeGreaterThanOrEqual(parseEther("0.0001")); + // 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", From e65e48078a8a58e168713a1074d3ee49e328da92 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:27:58 -0700 Subject: [PATCH 12/23] write secrets to env --- .github/workflows/e2e.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e8a7960..5f8b0bc 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -29,6 +29,13 @@ 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 + - name: Run e2e tests run: pnpm test:e2e env: From 0851ebb88a90d4caaefefbf4c98879c4a73c9e91 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:32:14 -0700 Subject: [PATCH 13/23] comment out swap and yield test --- tests/e2e/send-prompt-cli.test.ts | 192 +++++++++++++++--------------- 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 8c18252..870d089 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -81,15 +81,15 @@ describe("send_prompt CLI end-to-end", () => { const baseBalance = await basePublicClient.getBalance({ address: fundingAccount.address }); console.log(`Funding account Base mainnet balance: ${formatEther(baseBalance)} ETH`); - const baseWalletClient = createWalletClient({ - account: fundingAccount, - chain: base, - transport: http(), - }); - await baseWalletClient.sendTransaction({ - to: address as Hex, - value: parseEther("0.0004"), - }); + // const baseWalletClient = createWalletClient({ + // account: fundingAccount, + // chain: base, + // transport: http(), + // }); + // await baseWalletClient.sendTransaction({ + // to: address as Hex, + // value: parseEther("0.0001"), + // }); const config = { private_key: privateKey as Hex, @@ -175,91 +175,91 @@ describe("send_prompt CLI end-to-end", () => { expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0001")); }); - 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: smartAccountAddress }); - console.log(`Smart account Base mainnet balance before swap: ${formatEther(balanceBefore)} ETH`); - - const { stdout, stderr } = await runCli([ - "send_prompt", - "Swap 0.00000001 ETH for USDC on base", - ]); - - console.log(stdout); - console.error(stderr); - - const balanceAfter = await basePublicClient.getBalance({ address: smartAccountAddress }); - console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); - expect(balanceAfter).toBeLessThan(balanceBefore); - expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.00000001")); - }); - - 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: [smartAccountAddress], - }); - 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 into the fluid vault 0.0001 USDC balance on Base", - ]); - console.log(stdout2); - console.error(stderr2); - - const usdcAfterStake = await basePublicClient.readContract({ - address: USDC_ADDRESS, - abi: ERC20_ABI, - functionName: "balanceOf", - args: [smartAccountAddress], - }); - 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", - "unstake my 0.0001 USDC balance from the fluid vault on Base", - ]); - console.log(stdout3); - console.error(stderr3); - - const usdcAfterUnstake = await basePublicClient.readContract({ - address: USDC_ADDRESS, - abi: ERC20_ABI, - functionName: "balanceOf", - args: [smartAccountAddress], - }); - console.log(`Smart account USDC balance after unstaking: ${formatUnits(usdcAfterUnstake, 6)} USDC`); - expect(usdcAfterUnstake).toBeGreaterThan(usdcAfterStake); - }); + // 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: smartAccountAddress }); + // console.log(`Smart account Base mainnet balance before swap: ${formatEther(balanceBefore)} ETH`); + + // const { stdout, stderr } = await runCli([ + // "send_prompt", + // "Swap 0.00000001 ETH for USDC on base", + // ]); + + // console.log(stdout); + // console.error(stderr); + + // const balanceAfter = await basePublicClient.getBalance({ address: smartAccountAddress }); + // console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); + // expect(balanceAfter).toBeLessThan(balanceBefore); + // expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.00000001")); + // }); + + // 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: [smartAccountAddress], + // }); + // 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 into the fluid vault 0.0001 USDC balance on Base", + // ]); + // console.log(stdout2); + // console.error(stderr2); + + // const usdcAfterStake = await basePublicClient.readContract({ + // address: USDC_ADDRESS, + // abi: ERC20_ABI, + // functionName: "balanceOf", + // args: [smartAccountAddress], + // }); + // 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", + // "unstake my 0.0001 USDC balance from the fluid vault on Base", + // ]); + // console.log(stdout3); + // console.error(stderr3); + + // const usdcAfterUnstake = await basePublicClient.readContract({ + // address: USDC_ADDRESS, + // abi: ERC20_ABI, + // functionName: "balanceOf", + // args: [smartAccountAddress], + // }); + // console.log(`Smart account USDC balance after unstaking: ${formatUnits(usdcAfterUnstake, 6)} USDC`); + // expect(usdcAfterUnstake).toBeGreaterThan(usdcAfterStake); + // }); }); From d4e7ee1b40cc1dc91cb312a0ffa1e264b6bf3865 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Fri, 13 Mar 2026 15:49:17 -0700 Subject: [PATCH 14/23] update transports, enable swap test --- src/services/createPublicClient.ts | 1 + tests/e2e/send-prompt-cli.test.ts | 53 +++++++++++++++--------------- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/services/createPublicClient.ts b/src/services/createPublicClient.ts index 8c2b27b..b00a0ed 100644 --- a/src/services/createPublicClient.ts +++ b/src/services/createPublicClient.ts @@ -10,6 +10,7 @@ const QUICKNODE_SLUGS: Record = { 56: '.bsc', 59144: '.linea-mainnet', 8453: '.base-mainnet', + 84532: '.base-sepolia', 10: '.optimism', 42161: '.arbitrum-mainnet', 11155111: '.ethereum-sepolia', diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 870d089..c2720c4 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -import { type Hex, createPublicClient, createWalletClient, formatEther, formatUnits, http, parseEther } from "viem"; +import { type Hex, createPublicClient, createWalletClient, formatEther, http, parseEther } from "viem"; import { base, baseSepolia } from "viem/chains"; import { createSmartAccount } from "../../src/account.js"; import { returnRemainingFunds } from "./services.js"; @@ -10,6 +10,7 @@ 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"); @@ -69,7 +70,7 @@ describe("send_prompt CLI end-to-end", () => { const walletClient = createWalletClient({ account: fundingAccount, chain: baseSepolia, - transport: http(), + transport: getChainTransport(baseSepolia.id), }); const txHash = await walletClient.sendTransaction({ to: address as Hex, @@ -81,15 +82,15 @@ describe("send_prompt CLI end-to-end", () => { const baseBalance = await basePublicClient.getBalance({ address: fundingAccount.address }); console.log(`Funding account Base mainnet balance: ${formatEther(baseBalance)} ETH`); - // const baseWalletClient = createWalletClient({ - // account: fundingAccount, - // chain: base, - // transport: http(), - // }); - // await baseWalletClient.sendTransaction({ - // to: address as Hex, - // value: parseEther("0.0001"), - // }); + const baseWalletClient = createWalletClient({ + account: fundingAccount, + chain: base, + transport: getChainTransport(base.id), + }); + await baseWalletClient.sendTransaction({ + to: address as Hex, + value: parseEther("0.000000001"), + }); const config = { private_key: privateKey as Hex, @@ -175,25 +176,25 @@ describe("send_prompt CLI end-to-end", () => { expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0001")); }); - // it("completes the delegation flow when asked to swap ETH for USDC via the CLI", async () => { - // await runCli(["new_chat"]); + 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: smartAccountAddress }); - // console.log(`Smart account Base mainnet balance before swap: ${formatEther(balanceBefore)} ETH`); + const balanceBefore = await basePublicClient.getBalance({ address: smartAccountAddress }); + console.log(`Smart account Base mainnet balance before swap: ${formatEther(balanceBefore)} ETH`); - // const { stdout, stderr } = await runCli([ - // "send_prompt", - // "Swap 0.00000001 ETH for USDC on base", - // ]); + const { stdout, stderr } = await runCli([ + "send_prompt", + "Swap 0.0000000001 ETH for USDC on base", + ]); - // console.log(stdout); - // console.error(stderr); + console.log(stdout); + console.error(stderr); - // const balanceAfter = await basePublicClient.getBalance({ address: smartAccountAddress }); - // console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); - // expect(balanceAfter).toBeLessThan(balanceBefore); - // expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.00000001")); - // }); + const balanceAfter = await basePublicClient.getBalance({ address: smartAccountAddress }); + console.log(`Smart account Base mainnet balance after swap: ${formatEther(balanceAfter)} ETH`); + expect(balanceAfter).toBeLessThan(balanceBefore); + expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.00000001")); + }); // it("completes the staking/unstaking flow for USDC in the fluid vault on Base via the CLI", async () => { // await runCli(["new_chat"]); From 58e4997b23fc19865d107513aaead30280496020 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:48:30 -0700 Subject: [PATCH 15/23] refactor tests --- src/services/createPublicClient.ts | 3 +- tests/e2e/send-prompt-cli.test.ts | 397 +++++++++++++++-------------- 2 files changed, 209 insertions(+), 191 deletions(-) diff --git a/src/services/createPublicClient.ts b/src/services/createPublicClient.ts index b00a0ed..cb50d23 100644 --- a/src/services/createPublicClient.ts +++ b/src/services/createPublicClient.ts @@ -29,7 +29,8 @@ export function getChainTransport(chainId: number): Transport { if (slug === undefined) { return http() } - return http(`${getBaseUrl()}${slug}.quiknode.pro/${getApiKey()}`) + const rpcUrl = `${getBaseUrl()}${slug}.quiknode.pro/${getApiKey()}` + return http(rpcUrl) } export function createPublicClient(chain: Chain) { diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index c2720c4..f0e2482 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -15,7 +15,6 @@ 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`. @@ -52,85 +51,6 @@ const basePublicClient = createPublicClient({ }); describe("send_prompt CLI end-to-end", () => { - let smartAccountAddress: Hex; - let privateKey: Hex; - - beforeAll(async () => { - privateKey = generatePrivateKey(); - const { address } = await createSmartAccount(privateKey, CHAIN); - smartAccountAddress = address as Hex; - - // Fund the smart account with 0.002 Base Sepolia ETH - 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: address as Hex, - value: parseEther("0.002"), - }); - await sepoliaPublicClient.waitForTransactionReceipt({ hash: txHash }); - - // Fund the smart account on Base mainnet (swaps only work on real Base) - const baseBalance = await basePublicClient.getBalance({ address: fundingAccount.address }); - console.log(`Funding account Base mainnet balance: ${formatEther(baseBalance)} ETH`); - - const baseWalletClient = createWalletClient({ - account: fundingAccount, - chain: base, - transport: getChainTransport(base.id), - }); - await baseWalletClient.sendTransaction({ - to: address as Hex, - value: parseEther("0.000000001"), - }); - - const config = { - private_key: privateKey as Hex, - smart_account_address: address, - chain: CHAIN, - }; - - await signInWithAgent(SIWE_BASE_URL, config); - await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) - }); - - afterAll(async () => { - const fundingAddress = privateKeyToAccount(process.env.PRIVATE_KEY as Hex).address; - - try { - await returnRemainingFunds({ - privateKey, - chain: baseSepolia, - publicClient: sepoliaPublicClient, - smartAccountAddress, - fundingAddress, - ethGasBuffer: parseEther("0.0005"), - }); - } catch (err) { - console.error("Cleanup (Base Sepolia) failed:", err); - } - - try { - await returnRemainingFunds({ - privateKey, - chain: base, - publicClient: basePublicClient, - smartAccountAddress, - fundingAddress, - ethGasBuffer: parseEther("0.00001"), - }); - } catch (err) { - console.error("Cleanup (Base mainnet) failed:", err); - } - }, 120_000); it("returns a text response for a read-only prompt via the CLI", async () => { await runCli(["new_chat"]); @@ -143,124 +63,221 @@ describe("send_prompt CLI end-to-end", () => { expect(stdout.trim()).toBeTruthy(); }); - it("completes the delegation flow when asked to send ETH via the CLI", async () => { - await runCli(["new_chat"]); - const balanceBefore = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); - console.log(`Smart account Base Sepolia balance before send: ${formatEther(balanceBefore)} ETH`); - - const { stdout, stderr} = await runCli([ - "send_prompt", - "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", - ]); - - console.log(stdout) - console.error(stderr) + describe('testnet', () => { + const testnetPrivateKey = generatePrivateKey() + let testnetSmartAcctAddress: Hex = '0x' + + beforeAll(async () => { + const { address } = await createSmartAccount(testnetPrivateKey, "baseSepolia"); + testnetSmartAcctAddress = address as Hex + + // 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 balanceAfterFirst = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); - console.log(`Smart account Base Sepolia balance after first send: ${formatEther(balanceAfterFirst)} ETH`); - expect(balanceAfterFirst).toBeLessThan(balanceBefore); - expect(balanceBefore - balanceAfterFirst).toBeGreaterThanOrEqual(parseEther("0.0001")); + const config = { + private_key: testnetPrivateKey as Hex, + smart_account_address: address, + chain: "baseSepolia", + }; - // 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", - ]); + await signInWithAgent(SIWE_BASE_URL, config); + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) + }); - console.log(stdout2) - console.error(stderr2) + 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); + + /** + * @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`); + + const { stdout, stderr} = await runCli([ + "send_prompt", + "send 0.0001 ETH on Base Sepolia to 0x000000000000000000000000000000000000dEaD", + ]); + + console.log(stdout) + console.error(stderr) + + 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")); + + // 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) + + 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)) + }); - const balanceAfter = await sepoliaPublicClient.getBalance({ address: smartAccountAddress }); - console.log(`Smart account Base Sepolia balance after send: ${formatEther(balanceAfter)} ETH`); - expect(balanceAfter).toBeLessThan(balanceBefore); - expect(balanceBefore - balanceAfter).toBeGreaterThanOrEqual(parseEther("0.0001")); - }); + it("completes the delegation flow when asked to swap ETH for USDC via the CLI", async () => { + await runCli(["new_chat"]); - 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 balanceBefore = await basePublicClient.getBalance({ address: smartAccountAddress }); - 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", + ]); - const { stdout, stderr } = await runCli([ - "send_prompt", - "Swap 0.0000000001 ETH for USDC on base", - ]); + console.log(stdout); + console.error(stderr); - 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")); - const balanceAfter = await basePublicClient.getBalance({ address: smartAccountAddress }); - 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: [smartAccountAddress], - // }); - // 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 into the fluid vault 0.0001 USDC balance on Base", - // ]); - // console.log(stdout2); - // console.error(stderr2); - - // const usdcAfterStake = await basePublicClient.readContract({ - // address: USDC_ADDRESS, - // abi: ERC20_ABI, - // functionName: "balanceOf", - // args: [smartAccountAddress], - // }); - // 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", - // "unstake my 0.0001 USDC balance from the fluid vault on Base", - // ]); - // console.log(stdout3); - // console.error(stderr3); - - // const usdcAfterUnstake = await basePublicClient.readContract({ - // address: USDC_ADDRESS, - // abi: ERC20_ABI, - // functionName: "balanceOf", - // args: [smartAccountAddress], - // }); - // console.log(`Smart account USDC balance after unstaking: ${formatUnits(usdcAfterUnstake, 6)} USDC`); - // expect(usdcAfterUnstake).toBeGreaterThan(usdcAfterStake); - // }); + // it.only("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 0.1 USDC into the fluid vault on Base", + // ]); + // console.log(stdout2); + // console.error(stderr2); + + // 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", + // "unstake my 0.1 USDC position from the fluid vault on Base", + // ]); + // console.log(stdout3); + // console.error(stderr3); + + // 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); + // }); + }) }); From 35bf098888502d5061f6cf6bc475987eb7bb6182 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 12:54:33 -0700 Subject: [PATCH 16/23] update env for e2e tests --- .github/workflows/e2e.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 5f8b0bc..91081c8 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -34,7 +34,8 @@ jobs: 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 "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 @@ -43,4 +44,5 @@ jobs: 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 }} From 995db3fa44da7267f302a59d63216806e0ee356a Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:37:27 -0700 Subject: [PATCH 17/23] enable staking test --- tests/e2e/send-prompt-cli.test.ts | 142 ++++++++++++++++-------------- 1 file changed, 74 insertions(+), 68 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index f0e2482..7aa7ae4 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -1,7 +1,7 @@ import "dotenv/config"; import { describe, it, expect, beforeAll, afterAll } from "vitest"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; -import { type Hex, createPublicClient, createWalletClient, formatEther, http, parseEther } from "viem"; +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"; @@ -212,72 +212,78 @@ describe("send_prompt CLI end-to-end", () => { console.error(stderrCleanup); }); - // it.only("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 0.1 USDC into the fluid vault on Base", - // ]); - // console.log(stdout2); - // console.error(stderr2); - - // 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", - // "unstake my 0.1 USDC position from the fluid vault on Base", - // ]); - // console.log(stdout3); - // console.error(stderr3); - - // 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); - // }); + 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); + }); }) }); From 343326791d21d5c15430d89dbd620d2c6880c4af Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:43:57 -0700 Subject: [PATCH 18/23] wait 2 blocks on send eth --- tests/e2e/send-prompt-cli.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 7aa7ae4..36a0964 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -135,6 +135,9 @@ describe("send_prompt CLI end-to-end", () => { console.log(stdout) console.error(stderr) + + // wait for 2 blocks so balance check gets fresh data + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) const balanceAfterFirst = await sepoliaPublicClient.getBalance({ address: testnetSmartAcctAddress }); console.log(`Smart account Base Sepolia balance after first send: ${formatEther(balanceAfterFirst)} ETH`); From d77f67cec3bc9add4a960ed47565875521724cde Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 15:53:37 -0700 Subject: [PATCH 19/23] add 3 block wait --- tests/e2e/send-prompt-cli.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 36a0964..2eb0777 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -70,6 +70,7 @@ describe("send_prompt CLI end-to-end", () => { 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){ @@ -135,9 +136,9 @@ describe("send_prompt CLI end-to-end", () => { console.log(stdout) console.error(stderr) - + // wait for 2 blocks so balance check gets fresh data - await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 4000)) + await new Promise((resolve)=>setTimeout(()=>{resolve(1)}, 6000)) const balanceAfterFirst = await sepoliaPublicClient.getBalance({ address: testnetSmartAcctAddress }); console.log(`Smart account Base Sepolia balance after first send: ${formatEther(balanceAfterFirst)} ETH`); @@ -153,6 +154,9 @@ describe("send_prompt CLI end-to-end", () => { 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); From 2325db83d2ee46f4984cd9f334ecfaec4ba2448a Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:03:40 -0700 Subject: [PATCH 20/23] fix send hello prompt --- tests/e2e/send-prompt-cli.test.ts | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/e2e/send-prompt-cli.test.ts b/tests/e2e/send-prompt-cli.test.ts index 2eb0777..660974a 100644 --- a/tests/e2e/send-prompt-cli.test.ts +++ b/tests/e2e/send-prompt-cli.test.ts @@ -51,18 +51,6 @@ const basePublicClient = createPublicClient({ }); describe("send_prompt CLI end-to-end", () => { - - 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(); - }); - describe('testnet', () => { const testnetPrivateKey = generatePrivateKey() let testnetSmartAcctAddress: Hex = '0x' @@ -120,6 +108,19 @@ describe("send_prompt CLI end-to-end", () => { } }, 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(); + }); + /** * @dev tests deploying a new smart acct & sending eth in 1 txn * then sending eth with the deployed account in a separate txn From a6399fc07b4f61bbf7aee491c7ffea76b0da0006 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:25:24 -0700 Subject: [PATCH 21/23] update readme, skill, change ANVIL_RPC_URL to RPC_URL_OVERRIDE --- README.md | 16 ++++++++++++ coinfello/SKILL.md | 22 +++++++++++++--- coinfello/references/REFERENCE.md | 41 +++++++++++++++++++----------- package.json | 2 +- src/services/createPublicClient.ts | 4 +-- 5 files changed, 64 insertions(+), 21 deletions(-) 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..868f8e0 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..91ab608 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 cb50d23..892b7ca 100644 --- a/src/services/createPublicClient.ts +++ b/src/services/createPublicClient.ts @@ -22,8 +22,8 @@ const QUICKNODE_SLUGS: Record = { */ export function getChainTransport(chainId: number): Transport { // Local development/testing: route all RPC calls through local anvil - if (process.env.ANVIL_RPC_URL) { - return http(process.env.ANVIL_RPC_URL) + if (process.env.RPC_URL_OVERRIDE) { + return http(process.env.RPC_URL_OVERRIDE) } const slug = QUICKNODE_SLUGS[chainId] if (slug === undefined) { From a57d69a2f19936464774927fdcddf3d1e386d8c6 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:27:26 -0700 Subject: [PATCH 22/23] add concurrency to e2e --- .github/workflows/e2e.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 91081c8..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 From b3b5f903b8824ddc4915dc1b01734483c21648b5 Mon Sep 17 00:00:00 2001 From: Brett Cleary <27568879+BrettCleary@users.noreply.github.com> Date: Mon, 16 Mar 2026 18:27:45 -0700 Subject: [PATCH 23/23] prettier --- coinfello/SKILL.md | 12 ++++++------ coinfello/references/REFERENCE.md | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/coinfello/SKILL.md b/coinfello/SKILL.md index 868f8e0..954fdad 100644 --- a/coinfello/SKILL.md +++ b/coinfello/SKILL.md @@ -36,12 +36,12 @@ 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 | -| `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) | +| 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. diff --git a/coinfello/references/REFERENCE.md b/coinfello/references/REFERENCE.md index 91ab608..8496743 100644 --- a/coinfello/references/REFERENCE.md +++ b/coinfello/references/REFERENCE.md @@ -260,12 +260,12 @@ 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 | -| `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) | +| 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:**