Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions typescript/.changeset/twelve-planes-swim.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@coinbase/agentkit": minor
---

Added ListSpendPermissions and UseSpendPermission actions to new CdpEvmWalletActionProvider and CdpSmartWalletActionProvider
26 changes: 26 additions & 0 deletions typescript/agentkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,32 @@ const agent = createReactAgent({

## Action Providers

<details>
<summary><strong>CDP EVM Wallet</strong></summary>
<table width="100%">
<tr>
<td width="200"><code>list_spend_permissions</code></td>
<td width="768">Lists spend permissions that have been granted to the current EVM wallet by a smart account.</td>
</tr>
<tr>
<td width="200"><code>use_spend_permission</code></td>
<td width="768">Uses a spend permission to spend tokens on behalf of a smart account that the current EVM wallet has permission to spend.</td>
</tr>
</table>
</details>
<details>
<summary><strong>CDP Smart Wallet</strong></summary>
<table width="100%">
<tr>
<td width="200"><code>list_spend_permissions</code></td>
<td width="768">Lists spend permissions that have been granted to the current smart wallet by a smart account.</td>
</tr>
<tr>
<td width="200"><code>use_spend_permission</code></td>
<td width="768">Uses a spend permission to spend tokens on behalf of a smart account that the current smart wallet has permission to spend.</td>
</tr>
</table>
</details>
<details>
<summary><strong>Across</strong></summary>
<table width="100%">
Expand Down
2 changes: 1 addition & 1 deletion typescript/agentkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"dependencies": {
"@across-protocol/app-sdk": "^0.2.0",
"@alloralabs/allora-sdk": "^0.1.0",
"@coinbase/cdp-sdk": "^1.26.0",
"@coinbase/cdp-sdk": "^1.34.0",
"@coinbase/coinbase-sdk": "^0.20.0",
"@jup-ag/api": "^6.0.39",
"@privy-io/public-api": "2.18.5",
Expand Down
26 changes: 21 additions & 5 deletions typescript/agentkit/src/action-providers/cdp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@ This directory contains the **CdpV2ActionProvider** implementation, which provid

```
cdp/
├── cdpApiActionProvider.ts # Provider for CDP API interactions
├── cdpApiActionProvider.test.ts # Tests for CDP API provider
├── schemas.ts # Action schemas for CDP operations
├── index.ts # Main exports
└── README.md # This file
├── cdpApiActionProvider.ts # Provider for CDP API interactions
├── cdpApiActionProvider.test.ts # Tests for CDP API provider
├── cdpEvmWalletActionProvider.ts # Provider for CDP EVM Wallet operations
├── cdpSmartWalletActionProvider.ts # Provider for CDP Smart Wallet operations
├── cdpEvmWalletActionProvider.test.ts # Tests for CDP EVM Wallet provider
├── cdpSmartWalletActionProvider.test.ts # Tests for CDP Smart Wallet provider
├── schemas.ts # Action schemas for CDP operations
├── index.ts # Main exports
└── README.md # This file
```

## Actions
Expand All @@ -21,13 +25,25 @@ cdp/

- Available only on Base Sepolia, Ethereum Sepolia or Solana Devnet

### CDP EVM Wallet Actions

- `list_spend_permissions`: Lists spend permissions that have been granted to the current EVM wallet by a smart account.
- `use_spend_permission`: Uses a spend permission to spend tokens on behalf of a smart account that the current EVM wallet has permission to spend.

### CDP Smart Wallet Actions

- `list_spend_permissions`: Lists spend permissions that have been granted to the current smart wallet by a smart account.
- `use_spend_permission`: Uses a spend permission to spend tokens on behalf of a smart account that the current smart wallet has permission to spend.

## Adding New Actions

To add new CDP actions:

1. Define your action schema in `schemas.ts`
2. Implement the action in the appropriate provider file:
- CDP API actions in `cdpApiActionProvider.ts`
- CDP EVM Wallet actions in `cdpEvmWalletActionProvider.ts`
- CDP Smart Wallet actions in `cdpSmartWalletActionProvider.ts`
3. Add corresponding tests

## Network Support
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { CdpClient, SpendPermissionNetwork } from "@coinbase/cdp-sdk";
import { CdpEvmWalletProvider } from "../../wallet-providers/cdpEvmWalletProvider";
import { CdpEvmWalletActionProvider } from "./cdpEvmWalletActionProvider";
import { ListSpendPermissionsSchema, UseSpendPermissionSchema } from "./schemas";
import * as spendPermissionUtils from "./spendPermissionUtils";

// Mock the CDP SDK and utility functions
jest.mock("@coinbase/cdp-sdk");
jest.mock("./spendPermissionUtils");

describe("CDP EVM Wallet Action Provider", () => {
let actionProvider: CdpEvmWalletActionProvider;
let mockWalletProvider: jest.Mocked<CdpEvmWalletProvider>;
let mockCdpClient: jest.Mocked<CdpClient>;
let mockAccount: any;

beforeEach(() => {
jest.clearAllMocks();

mockAccount = {
useSpendPermission: jest.fn(),
address: "0x1234567890123456789012345678901234567890",
};

mockCdpClient = {
evm: {
listSpendPermissions: jest.fn(),
getAccount: jest.fn(),
},
} as any;

mockWalletProvider = {
getNetwork: jest.fn(),
getAddress: jest.fn(),
getClient: jest.fn(),
} as any;

actionProvider = new CdpEvmWalletActionProvider();
});

describe("listSpendPermissions", () => {
const mockArgs = {
smartAccountAddress: "0xabcd1234567890123456789012345678901234567890",
};

beforeEach(() => {
mockWalletProvider.getNetwork.mockReturnValue({
protocolFamily: "evm",
networkId: "base-sepolia",
} as any);
mockWalletProvider.getAddress.mockReturnValue("0x1234567890123456789012345678901234567890");
mockWalletProvider.getClient.mockReturnValue(mockCdpClient);
});

it("should successfully list spend permissions for EVM wallets", async () => {
const expectedResult =
"Found 2 spend permission(s):\n1. Token: USDC, Allowance: 500, Period: 1800 seconds, Start: 111111, End: 222222\n2. Token: ETH, Allowance: 1000, Period: 3600 seconds, Start: 123456, End: 234567";
(spendPermissionUtils.listSpendPermissionsForSpender as jest.Mock).mockResolvedValue(
expectedResult,
);

const result = await actionProvider.listSpendPermissions(mockWalletProvider, mockArgs);

expect(spendPermissionUtils.listSpendPermissionsForSpender).toHaveBeenCalledWith(
mockCdpClient,
mockArgs.smartAccountAddress,
"0x1234567890123456789012345678901234567890",
);
expect(result).toBe(expectedResult);
});

it("should return error message for non-EVM networks", async () => {
mockWalletProvider.getNetwork.mockReturnValue({
protocolFamily: "svm",
networkId: "solana-devnet",
} as any);

const result = await actionProvider.listSpendPermissions(mockWalletProvider, mockArgs);

expect(result).toBe("Spend permissions are currently only supported on EVM networks.");
expect(spendPermissionUtils.listSpendPermissionsForSpender).not.toHaveBeenCalled();
});

it("should handle utility function errors gracefully", async () => {
(spendPermissionUtils.listSpendPermissionsForSpender as jest.Mock).mockResolvedValue(
"Failed to list spend permissions: Network error",
);

const result = await actionProvider.listSpendPermissions(mockWalletProvider, mockArgs);

expect(result).toBe("Failed to list spend permissions: Network error");
});

it("should validate input schema", () => {
const validInput = { smartAccountAddress: "0xabcd1234567890123456789012345678901234567890" };
const invalidInput = { wrongField: "0xabcd1234567890123456789012345678901234567890" };

expect(() => ListSpendPermissionsSchema.parse(validInput)).not.toThrow();
expect(() => ListSpendPermissionsSchema.parse(invalidInput)).toThrow();
});
});

describe("useSpendPermission", () => {
const mockArgs = {
smartAccountAddress: "0xabcd1234567890123456789012345678901234567890",
value: "2500",
};

beforeEach(() => {
mockWalletProvider.getNetwork.mockReturnValue({
protocolFamily: "evm",
networkId: "base-sepolia",
} as any);
mockWalletProvider.getAddress.mockReturnValue("0x1234567890123456789012345678901234567890");
mockWalletProvider.getClient.mockReturnValue(mockCdpClient);
(mockCdpClient.evm.getAccount as jest.Mock).mockResolvedValue(mockAccount);
});

it("should successfully use spend permission for EVM wallets", async () => {
const mockPermission = {
spender: "0x1234567890123456789012345678901234567890",
token: "USDC",
allowance: "5000",
period: 7200,
start: 111111,
end: 333333,
};

const mockSpendResult = {
status: "completed",
transactionHash: "0xdef456789",
};

(spendPermissionUtils.findLatestSpendPermission as jest.Mock).mockResolvedValue(
mockPermission,
);
mockAccount.useSpendPermission.mockResolvedValue(mockSpendResult);

const result = await actionProvider.useSpendPermission(mockWalletProvider, mockArgs);

expect(spendPermissionUtils.findLatestSpendPermission).toHaveBeenCalledWith(
mockCdpClient,
mockArgs.smartAccountAddress,
"0x1234567890123456789012345678901234567890",
);

expect(mockCdpClient.evm.getAccount).toHaveBeenCalledWith({
address: "0x1234567890123456789012345678901234567890",
});

expect(mockAccount.useSpendPermission).toHaveBeenCalledWith({
spendPermission: mockPermission,
value: BigInt(2500),
network: "base-sepolia" as SpendPermissionNetwork,
});

expect(result).toBe(
"Successfully spent 2500 tokens using spend permission. Transaction hash: 0xdef456789",
);
});

it("should handle different network conversions", async () => {
const testCases = [
{ networkId: "base-sepolia", expected: "base-sepolia" },
{ networkId: "base-mainnet", expected: "base" },
{ networkId: "ethereum-sepolia", expected: "ethereum-sepolia" },
{ networkId: "ethereum-mainnet", expected: "ethereum" },
];

const mockPermission = { spender: "0x1234", token: "ETH" };
const mockSpendResult = { status: "completed" };

(spendPermissionUtils.findLatestSpendPermission as jest.Mock).mockResolvedValue(
mockPermission,
);
mockAccount.useSpendPermission.mockResolvedValue(mockSpendResult);

for (const testCase of testCases) {
jest.clearAllMocks();
mockWalletProvider.getNetwork.mockReturnValue({
protocolFamily: "evm",
networkId: testCase.networkId,
} as any);
mockWalletProvider.getClient.mockReturnValue(mockCdpClient);
(mockCdpClient.evm.getAccount as jest.Mock).mockResolvedValue(mockAccount);

await actionProvider.useSpendPermission(mockWalletProvider, mockArgs);

expect(mockAccount.useSpendPermission).toHaveBeenCalledWith({
spendPermission: mockPermission,
value: BigInt(2500),
network: testCase.expected as SpendPermissionNetwork,
});
}
});

it("should handle unknown networks by passing them as-is", async () => {
mockWalletProvider.getNetwork.mockReturnValue({
protocolFamily: "evm",
networkId: "polygon-mainnet",
} as any);

const mockPermission = { spender: "0x1234", token: "MATIC" };
const mockSpendResult = { status: "completed" };

(spendPermissionUtils.findLatestSpendPermission as jest.Mock).mockResolvedValue(
mockPermission,
);
mockAccount.useSpendPermission.mockResolvedValue(mockSpendResult);

await actionProvider.useSpendPermission(mockWalletProvider, mockArgs);

expect(mockAccount.useSpendPermission).toHaveBeenCalledWith({
spendPermission: mockPermission,
value: BigInt(2500),
network: "polygon-mainnet" as SpendPermissionNetwork,
});
});

it("should return error message for non-EVM networks", async () => {
mockWalletProvider.getNetwork.mockReturnValue({
protocolFamily: "svm",
networkId: "solana-devnet",
} as any);

const result = await actionProvider.useSpendPermission(mockWalletProvider, mockArgs);
expect(result).toBe("Spend permissions are currently only supported on EVM networks.");
});

it("should handle spend permission not found error", async () => {
(spendPermissionUtils.findLatestSpendPermission as jest.Mock).mockRejectedValue(
new Error("No spend permissions found for spender"),
);

const result = await actionProvider.useSpendPermission(mockWalletProvider, mockArgs);
expect(result).toBe(
"Failed to use spend permission: Error: No spend permissions found for spender",
);
});

it("should handle account creation failure", async () => {
(spendPermissionUtils.findLatestSpendPermission as jest.Mock).mockResolvedValue({
spender: "0x1234",
token: "ETH",
});
(mockCdpClient.evm.getAccount as jest.Mock).mockRejectedValue(new Error("Account not found"));

const result = await actionProvider.useSpendPermission(mockWalletProvider, mockArgs);
expect(result).toBe("Failed to use spend permission: Error: Account not found");
});

it("should handle account use permission failure", async () => {
const mockPermission = { spender: "0x1234", token: "ETH" };
(spendPermissionUtils.findLatestSpendPermission as jest.Mock).mockResolvedValue(
mockPermission,
);
mockAccount.useSpendPermission.mockRejectedValue(new Error("Insufficient allowance"));

const result = await actionProvider.useSpendPermission(mockWalletProvider, mockArgs);
expect(result).toBe("Failed to use spend permission: Error: Insufficient allowance");
});

it("should validate input schema", () => {
const validInput = {
smartAccountAddress: "0xabcd1234567890123456789012345678901234567890",
value: "1000",
};
const invalidInput = {
smartAccountAddress: "not-an-address",
value: -100,
};

expect(() => UseSpendPermissionSchema.parse(validInput)).not.toThrow();
expect(() => UseSpendPermissionSchema.parse(invalidInput)).toThrow();
});
});

describe("supportsNetwork", () => {
it("should return true for EVM networks", () => {
const evmNetwork = { protocolFamily: "evm", networkId: "base-sepolia" } as any;
expect(actionProvider.supportsNetwork(evmNetwork)).toBe(true);
});

it("should return false for non-EVM networks", () => {
const svmNetwork = { protocolFamily: "svm", networkId: "solana-devnet" } as any;
expect(actionProvider.supportsNetwork(svmNetwork)).toBe(false);
});
});
});
Loading
Loading