@coinbase/agentkit
Version:
Coinbase AgentKit core primitives
354 lines (353 loc) • 18.9 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const viem_1 = require("viem");
const compoundActionProvider_1 = require("./compoundActionProvider");
const constants_1 = require("./constants");
const utils_1 = require("../../utils");
jest.mock("../../utils");
const mockApprove = utils_1.approve;
describe("Compound Action Provider", () => {
const actionProvider = new compoundActionProvider_1.CompoundActionProvider();
let mockWallet;
const MOCK_NETWORK = { protocolFamily: "evm", networkId: "base-mainnet" };
const MOCK_COMET_ADDRESS = constants_1.COMET_ADDRESSES["base-mainnet"];
const MOCK_WETH_ADDRESS = constants_1.ASSET_ADDRESSES["base-mainnet"]["weth"];
const MOCK_USDC_ADDRESS = constants_1.ASSET_ADDRESSES["base-mainnet"]["usdc"];
const MOCK_TX_HASH = "0xtesttxhash";
const MOCK_RECEIPT = { status: 1, blockNumber: 123456 };
const MOCK_WALLET_BALANCE = BigInt("10000000000000000000"); // 10 tokens in wei
const MOCK_TOKEN_SYMBOL = "WETH";
beforeEach(() => {
mockWallet = {
getAddress: jest.fn().mockResolvedValue("0xMockAddress"),
getNetwork: jest.fn().mockReturnValue(MOCK_NETWORK),
sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH),
waitForTransactionReceipt: jest.fn().mockResolvedValue(MOCK_RECEIPT),
readContract: jest.fn().mockImplementation(async ({ functionName, address }) => {
// Basic ERC20 mocks
if (functionName === "decimals") {
return address === MOCK_USDC_ADDRESS ? 6 : 18;
}
if (functionName === "symbol") {
return address === MOCK_USDC_ADDRESS ? "USDC" : "WETH";
}
if (functionName === "balanceOf") {
return address === MOCK_USDC_ADDRESS ? BigInt("1000000") : (0, viem_1.parseEther)("10");
}
// Comet contract mocks
if (functionName === "collateralBalanceOf") {
return (0, viem_1.parseEther)("5");
}
if (functionName === "borrowBalanceOf") {
return (0, viem_1.parseUnits)("1000", 6);
}
if (functionName === "baseToken") {
return MOCK_USDC_ADDRESS;
}
if (functionName === "baseTokenPriceFeed") {
return "0xMockPriceFeed";
}
if (functionName === "numAssets") {
return 1;
}
if (functionName === "getAssetInfo") {
return {
offset: 0,
asset: MOCK_WETH_ADDRESS,
priceFeed: "0xMockPriceFeed",
scale: BigInt(0),
borrowCollateralFactor: (0, viem_1.parseEther)("0.8"),
liquidateCollateralFactor: BigInt(0),
liquidationFactor: BigInt(0),
supplyCap: BigInt(0),
};
}
if (functionName === "latestRoundData") {
return [BigInt(1), (0, viem_1.parseUnits)("1", 8), BigInt(0), BigInt(1000), BigInt(1)];
}
throw new Error(`Unmocked contract call: ${functionName}`);
}),
};
jest.clearAllMocks();
mockApprove.mockResolvedValue("Approval successful");
});
describe("supply", () => {
it("should successfully supply assets", async () => {
const args = { assetId: "weth", amount: "1.0" };
const response = await actionProvider.supply(mockWallet, args);
expect(mockApprove).toHaveBeenCalledWith(mockWallet, MOCK_WETH_ADDRESS, MOCK_COMET_ADDRESS, (0, viem_1.parseEther)("1.0"));
const expectedTxData = (0, viem_1.encodeFunctionData)({
abi: constants_1.COMET_ABI,
functionName: "supply",
args: [MOCK_WETH_ADDRESS, (0, viem_1.parseEther)("1.0")],
});
expect(mockWallet.sendTransaction).toHaveBeenCalledWith({
to: MOCK_COMET_ADDRESS,
data: expectedTxData,
});
expect(response).toContain("Supplied 1.0 WETH to Compound");
expect(response).toContain(MOCK_TX_HASH);
});
it("should return error if approval fails", async () => {
mockApprove.mockResolvedValueOnce("Error: Approval failed");
const args = { assetId: "weth", amount: "1.0" };
const response = await actionProvider.supply(mockWallet, args);
expect(response).toContain("Error approving token: Error: Approval failed");
});
it("should return error if wallet balance is insufficient", async () => {
mockWallet.readContract = jest.fn().mockImplementation(async ({ functionName }) => {
if (functionName === "decimals")
return 18;
if (functionName === "balanceOf")
return BigInt("500000000000000000"); // 0.5 tokens
return MOCK_WALLET_BALANCE;
});
const args = { assetId: "weth", amount: "1.0" };
const response = await actionProvider.supply(mockWallet, args);
expect(response).toContain("Error: Insufficient balance. You have 0.5, but trying to supply 1.0");
});
it("should handle errors during the supply transaction", async () => {
mockWallet.sendTransaction.mockRejectedValueOnce(new Error("Failed TX"));
const args = { assetId: "weth", amount: "1.0" };
const response = await actionProvider.supply(mockWallet, args);
expect(response).toContain("Error supplying to Compound: Failed TX");
});
});
describe("withdraw", () => {
it("should successfully withdraw assets", async () => {
// Patch readContract so that borrow balance is 0 (healthy condition)
const originalReadContract = mockWallet.readContract;
mockWallet.readContract = jest
.fn()
.mockImplementation(async ({ functionName, address, args, abi }) => {
if (functionName === "borrowBalanceOf")
return BigInt(0); // No borrows
if (functionName === "collateralBalanceOf")
return (0, viem_1.parseEther)("5"); // 5 WETH supplied
if (functionName === "latestRoundData")
return [BigInt(1), (0, viem_1.parseUnits)("1", 8), BigInt(0), BigInt(1000), BigInt(1)];
return originalReadContract({ functionName, address, abi, args: args ?? [] });
});
const args = { assetId: "weth", amount: "1.0" };
const response = await actionProvider.withdraw(mockWallet, args);
const expectedTxData = (0, viem_1.encodeFunctionData)({
abi: constants_1.COMET_ABI,
functionName: "withdraw",
args: [MOCK_WETH_ADDRESS, (0, viem_1.parseEther)("1.0")],
});
expect(mockWallet.sendTransaction).toHaveBeenCalledWith({
to: MOCK_COMET_ADDRESS,
data: expectedTxData,
});
expect(response).toContain("Withdrawn 1.0 WETH from Compound");
expect(response).toContain(MOCK_TX_HASH);
});
it("should return error if collateral balance is insufficient", async () => {
mockWallet.readContract = jest.fn().mockImplementation(async ({ functionName }) => {
if (functionName === "decimals")
return 18;
if (functionName === "collateralBalanceOf")
return (0, viem_1.parseEther)("0.5"); // Less than we're trying to withdraw
if (functionName === "symbol")
return MOCK_TOKEN_SYMBOL;
return MOCK_WALLET_BALANCE;
});
const args = { assetId: "weth", amount: "1.0" };
const response = await actionProvider.withdraw(mockWallet, args);
expect(response).toContain("Error: Insufficient balance. Trying to withdraw 1.0, but only have 0.5 supplied");
});
it("should handle errors during withdrawal", async () => {
// Patch readContract to yield a healthy condition so the TX is attempted
const originalReadContract = mockWallet.readContract;
mockWallet.readContract = jest
.fn()
.mockImplementation(async ({ functionName, address, args, abi }) => {
if (functionName === "borrowBalanceOf")
return BigInt(0);
if (functionName === "collateralBalanceOf")
return (0, viem_1.parseEther)("5");
if (functionName === "latestRoundData")
return [BigInt(1), (0, viem_1.parseUnits)("1", 8), BigInt(0), BigInt(1000), BigInt(1)];
return originalReadContract({ functionName, address, abi, args: args ?? [] });
});
mockWallet.sendTransaction.mockRejectedValueOnce(new Error("Withdraw TX Failed"));
const args = { assetId: "weth", amount: "1.0" };
const response = await actionProvider.withdraw(mockWallet, args);
expect(response).toContain("Error withdrawing from Compound: Error: Withdraw TX Failed");
});
});
describe("borrow", () => {
it("should successfully borrow assets", async () => {
// For a healthy borrow, override:
const originalReadContract = mockWallet.readContract;
mockWallet.readContract = jest
.fn()
.mockImplementation(async ({ functionName, address, abi }) => {
if (functionName === "borrowBalanceOf")
return BigInt(0);
if (functionName === "collateralBalanceOf")
return (0, viem_1.parseEther)("5000");
if (functionName === "baseToken")
return MOCK_USDC_ADDRESS;
if (functionName === "baseTokenPriceFeed")
return "0xMockPriceFeed";
if (functionName === "latestRoundData") {
return [BigInt(1), (0, viem_1.parseUnits)("1", 8), BigInt(0), BigInt(1000), BigInt(1)];
}
if (functionName === "getAssetInfo") {
return {
offset: 0,
asset: MOCK_WETH_ADDRESS,
priceFeed: "0xMockPriceFeed",
scale: BigInt(0),
borrowCollateralFactor: (0, viem_1.parseEther)("0.8"),
liquidateCollateralFactor: BigInt(0),
liquidationFactor: BigInt(0),
supplyCap: BigInt(0),
};
}
return originalReadContract({ functionName, address, abi, args: [] });
});
const args = { assetId: "usdc", amount: "1000" };
const response = await actionProvider.borrow(mockWallet, args);
const expectedTxData = (0, viem_1.encodeFunctionData)({
abi: constants_1.COMET_ABI,
functionName: "withdraw",
args: [MOCK_USDC_ADDRESS, (0, viem_1.parseUnits)("1000", 6)],
});
expect(mockWallet.sendTransaction).toHaveBeenCalledWith({
to: MOCK_COMET_ADDRESS,
data: expectedTxData,
});
expect(response).toContain("Borrowed 1000 USDC from Compound");
});
it("should handle errors during borrowing", async () => {
// Set up the same mocks as the success case first
const originalReadContract = mockWallet.readContract;
mockWallet.readContract = jest
.fn()
.mockImplementation(async ({ functionName, address, abi }) => {
if (functionName === "borrowBalanceOf")
return BigInt(0);
if (functionName === "collateralBalanceOf")
return (0, viem_1.parseEther)("5000");
if (functionName === "baseToken")
return MOCK_USDC_ADDRESS;
if (functionName === "baseTokenPriceFeed")
return "0xMockPriceFeed";
if (functionName === "latestRoundData") {
return [BigInt(1), (0, viem_1.parseUnits)("1", 8), BigInt(0), BigInt(1000), BigInt(1)];
}
if (functionName === "getAssetInfo") {
return {
offset: 0,
asset: MOCK_WETH_ADDRESS,
priceFeed: "0xMockPriceFeed",
scale: BigInt(0),
borrowCollateralFactor: (0, viem_1.parseEther)("0.8"),
liquidateCollateralFactor: BigInt(0),
liquidationFactor: BigInt(0),
supplyCap: BigInt(0),
};
}
return originalReadContract({ functionName, address, abi, args: [] });
});
mockWallet.sendTransaction.mockRejectedValueOnce(new Error("Borrow TX Failed"));
const args = { assetId: "usdc", amount: "1000" };
const response = await actionProvider.borrow(mockWallet, args);
expect(response).toContain("Error borrowing from Compound: Error: Borrow TX Failed");
});
});
describe("repay", () => {
it("should successfully repay assets", async () => {
// Override token balance for USDC to be sufficient for repayment.
const originalReadContract = mockWallet.readContract;
mockWallet.readContract = jest
.fn()
.mockImplementation(async ({ functionName, address, args, abi }) => {
if (functionName === "balanceOf" && address === MOCK_USDC_ADDRESS) {
// Return 2000 USDC in atomic units (for 6 decimals, 2000 USDC = 2000 * 10^6)
return BigInt("2000000000");
}
if (functionName === "latestRoundData")
return [BigInt(1), (0, viem_1.parseUnits)("1", 8), BigInt(0), BigInt(1000), BigInt(1)];
return originalReadContract({ functionName, address, args, abi });
});
const args = { assetId: "usdc", amount: "1000" };
const response = await actionProvider.repay(mockWallet, args);
expect(mockApprove).toHaveBeenCalledWith(mockWallet, MOCK_USDC_ADDRESS, MOCK_COMET_ADDRESS, (0, viem_1.parseUnits)("1000", 6));
expect(mockWallet.sendTransaction).toHaveBeenCalledWith({
to: MOCK_COMET_ADDRESS,
data: (0, viem_1.encodeFunctionData)({
abi: constants_1.COMET_ABI,
functionName: "supply",
args: [MOCK_USDC_ADDRESS, (0, viem_1.parseUnits)("1000", 6)],
}),
});
expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH);
expect(response).toContain(`Repaid ${args.amount} USDC to Compound`);
});
it("should return error if wallet balance is insufficient for repay", async () => {
mockWallet.readContract = jest.fn().mockImplementation(async ({ functionName }) => {
if (functionName === "decimals")
return 6;
if (functionName === "balanceOf")
return BigInt("500000"); // 0.5 USDC
return MOCK_WALLET_BALANCE;
});
const args = { assetId: "usdc", amount: "1000" };
const response = await actionProvider.repay(mockWallet, args);
expect(response).toContain("Error: Insufficient balance. You have 0.5, but trying to repay 1000");
});
it("should handle errors during repayment", async () => {
// Override token balance to be sufficient, but make transaction fail
const originalReadContract = mockWallet.readContract;
mockWallet.readContract = jest
.fn()
.mockImplementation(async ({ functionName, address, args, abi }) => {
if (functionName === "balanceOf" && address === MOCK_USDC_ADDRESS) {
// Return 2000 USDC in atomic units (sufficient balance)
return BigInt("2000000000");
}
if (functionName === "latestRoundData")
return [BigInt(1), (0, viem_1.parseUnits)("1", 8), BigInt(0), BigInt(1000), BigInt(1)];
return originalReadContract({ functionName, address, args, abi });
});
mockApprove.mockResolvedValueOnce("Approval successful");
mockWallet.sendTransaction.mockRejectedValueOnce(new Error("Repay TX Failed"));
const args = { assetId: "usdc", amount: "1000" };
const response = await actionProvider.repay(mockWallet, args);
expect(response).toContain("Error repaying to Compound: Error: Repay TX Failed");
});
});
describe("getPortfolio", () => {
it("should return portfolio details in markdown format", async () => {
const response = await actionProvider.getPortfolio(mockWallet, {});
expect(response).toContain("# Portfolio Details");
expect(response).toContain("## Supply Details");
expect(response).toContain("## Borrow Details");
expect(response).toContain("## Overall Health");
});
it("should handle errors during portfolio retrieval", async () => {
mockWallet.readContract.mockRejectedValueOnce(new Error("Failed to fetch"));
const response = await actionProvider.getPortfolio(mockWallet, {});
expect(response).toContain("Error getting portfolio details: Failed to fetch");
});
});
describe("supportsNetwork", () => {
it("should return true for supported network", () => {
const result = actionProvider.supportsNetwork({
protocolFamily: "evm",
networkId: "base-mainnet",
});
expect(result).toBe(true);
});
it("should return false for unsupported network", () => {
const result = actionProvider.supportsNetwork({
protocolFamily: "evm",
networkId: "ethereum",
});
expect(result).toBe(false);
});
});
});