From 8b93d45024824a12affd0f290986064ecc041295 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 06:42:02 +0000 Subject: [PATCH 1/3] feat: add Sapien Vault TypeScript action provider Add an EVM action provider so AgentKit agents can deposit SAPIEN, withdraw or redeem vSAPIEN, and read position state on the Base mainnet ERC-4626 Sapien Vault. Co-authored-by: Chad --- .../sapien-vault-action-provider.md | 5 + typescript/agentkit/README.md | 21 + .../agentkit/src/action-providers/index.ts | 1 + .../action-providers/sapienVault/README.md | 55 +++ .../action-providers/sapienVault/constants.ts | 105 +++++ .../src/action-providers/sapienVault/index.ts | 3 + .../sapienVaultActionProvider.test.ts | 368 +++++++++++++++ .../sapienVault/sapienVaultActionProvider.ts | 425 ++++++++++++++++++ .../action-providers/sapienVault/schemas.ts | 56 +++ .../src/action-providers/sapienVault/utils.ts | 113 +++++ 10 files changed, 1152 insertions(+) create mode 100644 typescript/.changeset/sapien-vault-action-provider.md create mode 100644 typescript/agentkit/src/action-providers/sapienVault/README.md create mode 100644 typescript/agentkit/src/action-providers/sapienVault/constants.ts create mode 100644 typescript/agentkit/src/action-providers/sapienVault/index.ts create mode 100644 typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts create mode 100644 typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts create mode 100644 typescript/agentkit/src/action-providers/sapienVault/schemas.ts create mode 100644 typescript/agentkit/src/action-providers/sapienVault/utils.ts diff --git a/typescript/.changeset/sapien-vault-action-provider.md b/typescript/.changeset/sapien-vault-action-provider.md new file mode 100644 index 000000000..adba2f90b --- /dev/null +++ b/typescript/.changeset/sapien-vault-action-provider.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a Sapien Vault action provider for depositing SAPIEN, withdrawing or redeeming vSAPIEN, and reading position state on the Base mainnet ERC-4626 vault diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 37b14207f..3b6e837e9 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -555,6 +555,27 @@ const agent = createAgent({
+Sapien Vault + + + + + + + + + + + + + + + + + +
depositApproves SAPIEN if needed and deposits into the Sapien Vault (vSAPIEN) on Base mainnet.
withdrawWithdraws SAPIEN from the Sapien Vault by asset amount (ERC-4626 withdraw).
redeemRedeems vSAPIEN shares from the Sapien Vault for SAPIEN (ERC-4626 redeem).
get_positionReads vSAPIEN shares and SAPIEN asset TVL. Excludes RewardsController inventory from user TVL.
+
+
Superfluid diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..09c14f7b5 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -41,3 +41,4 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; +export * from "./sapienVault"; diff --git a/typescript/agentkit/src/action-providers/sapienVault/README.md b/typescript/agentkit/src/action-providers/sapienVault/README.md new file mode 100644 index 000000000..78e469c25 --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/README.md @@ -0,0 +1,55 @@ +# Sapien Vault Action Provider + +This directory contains the **SapienVaultActionProvider**, which lets AgentKit agents deposit SAPIEN, withdraw or redeem vSAPIEN, and read position state on the Sapien Vault — an ERC-4626 vault on Base mainnet. + +App: [https://vault.sapien.io](https://vault.sapien.io) + +## Directory Structure + +``` +sapienVault/ +├── sapienVaultActionProvider.ts # Main provider +├── sapienVaultActionProvider.test.ts # Unit tests +├── constants.ts # Base mainnet addresses and vault ABI +├── schemas.ts # Zod action schemas +├── utils.ts # Network guard and approve-if-needed +├── index.ts # Exports +└── README.md # This file +``` + +## Contracts (Base mainnet, EIP-55) + +| Role | Address | +| --- | --- | +| Vault / vSAPIEN (ERC-4626) | `0x60Bf63729f688287a450299962b36Cef0aFfaa42` | +| Underlying SAPIEN | `0xC729777d0470F30612B1564Fd96E8Dd26f5814E3` | +| RewardsController (exclude from user TVL) | `0x55Ce7717Bc8c8F1b59AdB9e0CE7abc332391BF18` | + +## Actions + +- `deposit`: Approve SAPIEN for the vault if allowance is insufficient, then call ERC-4626 `deposit` +- `withdraw`: Redeem SAPIEN by **asset** amount (`withdraw`) +- `redeem`: Redeem SAPIEN by **share** amount (`redeem`) +- `get_position`: Read vSAPIEN shares, SAPIEN asset value (user TVL), `maxWithdraw`, and `maxRedeem` + +### User TVL + +`get_position` reports user TVL as `convertToAssets(balanceOf(user))` only. The RewardsController holds unstreamed reward inventory; that balance is **not** part of a user's position and is called out separately so agents do not add it to TVL. + +## Network Support + +**Base mainnet only** (`networkId` `base-mainnet` or `chainId` `8453`). + +`supportsNetwork` returns `false` on every other chain. Each action also returns a clear error if the connected wallet is not on Base mainnet (including Base Sepolia). + +## Adding New Actions + +1. Define the schema in `schemas.ts` +2. Implement the action in `sapienVaultActionProvider.ts` +3. Add tests in `sapienVaultActionProvider.test.ts` + +## Notes + +- Fresh deposits may be subject to `minDepositAge` before they can be withdrawn or transferred. Locked validator stake cannot be withdrawn until the engine unlocks it. Actions consult `maxDeposit` / `maxWithdraw` / `maxRedeem`. +- vSAPIEN share decimals are the underlying decimals plus an internal ERC-4626 offset. `redeem` uses the vault's `decimals()`. +- For protocol design, see the [SapienVault docs](https://github.com/Sapien-io/sapien-contracts/blob/main/docs/SapienVault.md). diff --git a/typescript/agentkit/src/action-providers/sapienVault/constants.ts b/typescript/agentkit/src/action-providers/sapienVault/constants.ts new file mode 100644 index 000000000..3eebc7205 --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/constants.ts @@ -0,0 +1,105 @@ +/** + * Sapien Vault contracts on Base mainnet (EIP-55). + * + * @see https://vault.sapien.io + */ +export const SAPIEN_VAULT_ADDRESS = "0x60Bf63729f688287a450299962b36Cef0aFfaa42"; +export const SAPIEN_TOKEN_ADDRESS = "0xC729777d0470F30612B1564Fd96E8Dd26f5814E3"; + +/** + * RewardsController holds the unstreamed reward inventory. + * Its balance must be excluded from user TVL. + */ +export const SAPIEN_REWARDS_CONTROLLER_ADDRESS = "0x55Ce7717Bc8c8F1b59AdB9e0CE7abc332391BF18"; + +export const SAPIEN_VAULT_APP_URL = "https://vault.sapien.io"; + +export const SAPIEN_VAULT_NETWORK_ID = "base-mainnet"; +export const SAPIEN_VAULT_CHAIN_ID = "8453"; + +/** + * ERC-4626 surface used by the Sapien Vault (vSAPIEN). + */ +export const SAPIEN_VAULT_ABI = [ + { + type: "function", + name: "deposit", + inputs: [ + { name: "assets", type: "uint256", internalType: "uint256" }, + { name: "receiver", type: "address", internalType: "address" }, + ], + outputs: [{ name: "shares", type: "uint256", internalType: "uint256" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "withdraw", + inputs: [ + { name: "assets", type: "uint256", internalType: "uint256" }, + { name: "receiver", type: "address", internalType: "address" }, + { name: "owner", type: "address", internalType: "address" }, + ], + outputs: [{ name: "shares", type: "uint256", internalType: "uint256" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "redeem", + inputs: [ + { name: "shares", type: "uint256", internalType: "uint256" }, + { name: "receiver", type: "address", internalType: "address" }, + { name: "owner", type: "address", internalType: "address" }, + ], + outputs: [{ name: "assets", type: "uint256", internalType: "uint256" }], + stateMutability: "nonpayable", + }, + { + type: "function", + name: "balanceOf", + inputs: [{ name: "account", type: "address", internalType: "address" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "convertToAssets", + inputs: [{ name: "shares", type: "uint256", internalType: "uint256" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "decimals", + inputs: [], + outputs: [{ name: "", type: "uint8", internalType: "uint8" }], + stateMutability: "view", + }, + { + type: "function", + name: "maxDeposit", + inputs: [{ name: "receiver", type: "address", internalType: "address" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "maxWithdraw", + inputs: [{ name: "owner", type: "address", internalType: "address" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "maxRedeem", + inputs: [{ name: "owner", type: "address", internalType: "address" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "previewDeposit", + inputs: [{ name: "assets", type: "uint256", internalType: "uint256" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, +] as const; diff --git a/typescript/agentkit/src/action-providers/sapienVault/index.ts b/typescript/agentkit/src/action-providers/sapienVault/index.ts new file mode 100644 index 000000000..6e019bd8a --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/index.ts @@ -0,0 +1,3 @@ +export * from "./constants"; +export * from "./schemas"; +export * from "./sapienVaultActionProvider"; diff --git a/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts new file mode 100644 index 000000000..45acf7642 --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts @@ -0,0 +1,368 @@ +import { encodeFunctionData, parseUnits } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { approve } from "../../utils"; +import { Network } from "../../network"; +import { SapienVaultActionProvider } from "./sapienVaultActionProvider"; +import { + SAPIEN_REWARDS_CONTROLLER_ADDRESS, + SAPIEN_TOKEN_ADDRESS, + SAPIEN_VAULT_ABI, + SAPIEN_VAULT_ADDRESS, +} from "./constants"; +import { + SapienVaultDepositSchema, + SapienVaultGetPositionSchema, + SapienVaultRedeemSchema, + SapienVaultWithdrawSchema, +} from "./schemas"; + +const MOCK_OWNER = "0x9876543210987654321098765432109876543210"; +const MOCK_RECEIVER = "0x1111111111111111111111111111111111111111"; +const MOCK_TX_HASH = "0xabcdef1234567890"; +const MOCK_RECEIPT = { status: 1, blockNumber: 1234567 }; +const MOCK_TOKEN_DECIMALS = 18; +const MOCK_SHARE_DECIMALS = 21; +const MOCK_WHOLE_ASSETS = "1.0"; +const MOCK_WHOLE_SHARES = "1.0"; +const MOCK_USER_SHARES = parseUnits("1.5", MOCK_SHARE_DECIMALS); +const MOCK_USER_ASSETS = parseUnits("1.52", MOCK_TOKEN_DECIMALS); +const MOCK_RC_SHARES = parseUnits("1000", MOCK_SHARE_DECIMALS); +const MOCK_RC_ASSETS = parseUnits("1010", MOCK_TOKEN_DECIMALS); +const MOCK_MAX_DEPOSIT = parseUnits("1000000", MOCK_TOKEN_DECIMALS); +const MOCK_PREVIEW_SHARES = parseUnits("0.99", MOCK_SHARE_DECIMALS); +const MOCK_MAX_WITHDRAW = parseUnits("10", MOCK_TOKEN_DECIMALS); +const MOCK_MAX_REDEEM = parseUnits("10", MOCK_SHARE_DECIMALS); + +jest.mock("../../utils"); +const mockApprove = approve as jest.MockedFunction; + +describe("SapienVault Action Provider", () => { + const actionProvider = new SapienVaultActionProvider(); + let mockWallet: jest.Mocked; + + const mockReadContract = jest.fn(); + + beforeEach(() => { + mockReadContract.mockImplementation(({ address, functionName, args }) => { + if (functionName === "decimals") { + return address === SAPIEN_VAULT_ADDRESS ? MOCK_SHARE_DECIMALS : MOCK_TOKEN_DECIMALS; + } + if (functionName === "allowance") { + return 0n; + } + if (functionName === "maxDeposit") { + return MOCK_MAX_DEPOSIT; + } + if (functionName === "previewDeposit") { + return MOCK_PREVIEW_SHARES; + } + if (functionName === "maxWithdraw") { + return MOCK_MAX_WITHDRAW; + } + if (functionName === "maxRedeem") { + return MOCK_MAX_REDEEM; + } + if (functionName === "balanceOf") { + const account = (args?.[0] as string)?.toLowerCase(); + if (account === SAPIEN_REWARDS_CONTROLLER_ADDRESS.toLowerCase()) { + return MOCK_RC_SHARES; + } + return MOCK_USER_SHARES; + } + if (functionName === "convertToAssets") { + const shares = args?.[0] as bigint; + if (shares === MOCK_RC_SHARES) { + return MOCK_RC_ASSETS; + } + return MOCK_USER_ASSETS; + } + throw new Error(`Unexpected readContract call: ${functionName}`); + }); + + mockWallet = { + getAddress: jest.fn().mockReturnValue(MOCK_OWNER), + getNetwork: jest.fn().mockReturnValue({ + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", + } as Network), + sendTransaction: jest.fn().mockResolvedValue(MOCK_TX_HASH as `0x${string}`), + waitForTransactionReceipt: jest.fn().mockResolvedValue(MOCK_RECEIPT), + readContract: mockReadContract, + } as unknown as jest.Mocked; + + mockApprove.mockResolvedValue("Approval successful"); + }); + + describe("schemas", () => { + it("should parse a valid deposit", () => { + const result = SapienVaultDepositSchema.safeParse({ assets: "1.5" }); + expect(result.success).toBe(true); + }); + + it("should reject a non-numeric deposit", () => { + expect(SapienVaultDepositSchema.safeParse({ assets: "abc" }).success).toBe(false); + }); + + it("should parse a valid withdraw and redeem", () => { + expect(SapienVaultWithdrawSchema.safeParse({ assets: "0.1" }).success).toBe(true); + expect(SapienVaultRedeemSchema.safeParse({ shares: "2" }).success).toBe(true); + }); + + it("should parse an empty get_position payload", () => { + expect(SapienVaultGetPositionSchema.safeParse({}).success).toBe(true); + }); + }); + + describe("deposit", () => { + it("should approve SAPIEN when allowance is insufficient and deposit", async () => { + const atomicAssets = parseUnits(MOCK_WHOLE_ASSETS, MOCK_TOKEN_DECIMALS); + + const response = await actionProvider.deposit(mockWallet, { assets: MOCK_WHOLE_ASSETS }); + + expect(mockApprove).toHaveBeenCalledWith( + mockWallet, + SAPIEN_TOKEN_ADDRESS, + SAPIEN_VAULT_ADDRESS, + atomicAssets, + ); + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: SAPIEN_VAULT_ADDRESS, + data: encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "deposit", + args: [atomicAssets, MOCK_OWNER], + }), + }); + expect(mockWallet.waitForTransactionReceipt).toHaveBeenCalledWith(MOCK_TX_HASH); + expect(response).toContain(`Deposited ${MOCK_WHOLE_ASSETS} SAPIEN`); + expect(response).toContain(MOCK_TX_HASH); + expect(response).toContain(JSON.stringify(MOCK_RECEIPT)); + }); + + it("should skip approve when allowance is already sufficient", async () => { + mockReadContract.mockImplementation(({ address, functionName }) => { + if (functionName === "decimals") { + return address === SAPIEN_VAULT_ADDRESS ? MOCK_SHARE_DECIMALS : MOCK_TOKEN_DECIMALS; + } + if (functionName === "allowance") { + return parseUnits("100", MOCK_TOKEN_DECIMALS); + } + if (functionName === "maxDeposit") { + return MOCK_MAX_DEPOSIT; + } + if (functionName === "previewDeposit") { + return MOCK_PREVIEW_SHARES; + } + throw new Error(`Unexpected readContract call: ${functionName}`); + }); + + const response = await actionProvider.deposit(mockWallet, { + assets: MOCK_WHOLE_ASSETS, + receiver: MOCK_RECEIVER, + }); + + expect(mockApprove).not.toHaveBeenCalled(); + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: SAPIEN_VAULT_ADDRESS, + data: encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "deposit", + args: [parseUnits(MOCK_WHOLE_ASSETS, MOCK_TOKEN_DECIMALS), MOCK_RECEIVER], + }), + }); + expect(response).toContain("Deposited"); + expect(response).toContain(MOCK_RECEIVER); + }); + + it("should reject a zero deposit", async () => { + const response = await actionProvider.deposit(mockWallet, { assets: "0" }); + expect(response).toBe("Error: Assets amount must be greater than 0"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("should reject deposits above maxDeposit", async () => { + mockReadContract.mockImplementation(({ functionName }) => { + if (functionName === "decimals") { + return MOCK_TOKEN_DECIMALS; + } + if (functionName === "maxDeposit") { + return 0n; + } + throw new Error(`Unexpected readContract call: ${functionName}`); + }); + + const response = await actionProvider.deposit(mockWallet, { assets: MOCK_WHOLE_ASSETS }); + expect(response).toContain("exceeds maxDeposit"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("should return a clear error on non-Base networks", async () => { + mockWallet.getNetwork.mockReturnValue({ + protocolFamily: "evm", + networkId: "ethereum-mainnet", + chainId: "1", + } as Network); + + const response = await actionProvider.deposit(mockWallet, { assets: MOCK_WHOLE_ASSETS }); + expect(response).toContain("only supported on Base mainnet"); + expect(response).toContain("ethereum-mainnet"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("should handle deposit transaction errors", async () => { + mockWallet.sendTransaction.mockRejectedValue(new Error("Failed to deposit")); + + const response = await actionProvider.deposit(mockWallet, { assets: MOCK_WHOLE_ASSETS }); + expect(response).toContain("Error depositing to Sapien Vault: Error: Failed to deposit"); + }); + }); + + describe("withdraw", () => { + it("should withdraw SAPIEN by asset amount", async () => { + const atomicAssets = parseUnits(MOCK_WHOLE_ASSETS, MOCK_TOKEN_DECIMALS); + + const response = await actionProvider.withdraw(mockWallet, { assets: MOCK_WHOLE_ASSETS }); + + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: SAPIEN_VAULT_ADDRESS, + data: encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "withdraw", + args: [atomicAssets, MOCK_OWNER, MOCK_OWNER], + }), + }); + expect(response).toContain(`Withdrawn ${MOCK_WHOLE_ASSETS} SAPIEN`); + expect(response).toContain(MOCK_TX_HASH); + }); + + it("should reject withdraws above maxWithdraw", async () => { + mockReadContract.mockImplementation(({ functionName }) => { + if (functionName === "decimals") { + return MOCK_TOKEN_DECIMALS; + } + if (functionName === "maxWithdraw") { + return 0n; + } + throw new Error(`Unexpected readContract call: ${functionName}`); + }); + + const response = await actionProvider.withdraw(mockWallet, { assets: MOCK_WHOLE_ASSETS }); + expect(response).toContain("exceeds maxWithdraw"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("should handle withdraw errors", async () => { + mockWallet.sendTransaction.mockRejectedValue(new Error("Failed to withdraw")); + + const response = await actionProvider.withdraw(mockWallet, { assets: MOCK_WHOLE_ASSETS }); + expect(response).toContain("Error withdrawing from Sapien Vault: Error: Failed to withdraw"); + }); + }); + + describe("redeem", () => { + it("should redeem vSAPIEN shares", async () => { + const atomicShares = parseUnits(MOCK_WHOLE_SHARES, MOCK_SHARE_DECIMALS); + + const response = await actionProvider.redeem(mockWallet, { shares: MOCK_WHOLE_SHARES }); + + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: SAPIEN_VAULT_ADDRESS, + data: encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "redeem", + args: [atomicShares, MOCK_OWNER, MOCK_OWNER], + }), + }); + expect(response).toContain(`Redeemed ${MOCK_WHOLE_SHARES} vSAPIEN`); + expect(response).toContain(MOCK_TX_HASH); + }); + + it("should reject a zero redeem", async () => { + const response = await actionProvider.redeem(mockWallet, { shares: "0" }); + expect(response).toBe("Error: Shares amount must be greater than 0"); + }); + + it("should handle redeem errors", async () => { + mockWallet.sendTransaction.mockRejectedValue(new Error("Failed to redeem")); + + const response = await actionProvider.redeem(mockWallet, { shares: MOCK_WHOLE_SHARES }); + expect(response).toContain("Error redeeming from Sapien Vault: Error: Failed to redeem"); + }); + }); + + describe("get_position", () => { + it("should return shares and assets and exclude RewardsController from user TVL", async () => { + const response = await actionProvider.getPosition(mockWallet, {}); + + expect(response).toContain(MOCK_OWNER); + expect(response).toContain("1.5"); + expect(response).toContain("1.52"); + expect(response).toContain("user TVL"); + expect(response).toContain(SAPIEN_REWARDS_CONTROLLER_ADDRESS); + expect(response).toContain("excludes RewardsController"); + expect(response).toContain("1010"); + expect(response).not.toMatch(/SAPIEN assets \(user TVL\): 1010/); + }); + + it("should read a specified address", async () => { + const response = await actionProvider.getPosition(mockWallet, { address: MOCK_RECEIVER }); + expect(response).toContain(MOCK_RECEIVER); + }); + + it("should handle read errors", async () => { + mockReadContract.mockRejectedValue(new Error("rpc down")); + const response = await actionProvider.getPosition(mockWallet, {}); + expect(response).toContain("Error reading Sapien Vault position: Error: rpc down"); + }); + }); + + describe("supportsNetwork", () => { + it("should return true for Base mainnet", () => { + expect( + actionProvider.supportsNetwork({ + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", + }), + ).toBe(true); + }); + + it("should return true for Base mainnet identified only by chainId", () => { + expect( + actionProvider.supportsNetwork({ + protocolFamily: "evm", + chainId: "8453", + }), + ).toBe(true); + }); + + it("should return false for Base Sepolia", () => { + expect( + actionProvider.supportsNetwork({ + protocolFamily: "evm", + networkId: "base-sepolia", + chainId: "84532", + }), + ).toBe(false); + }); + + it("should return false for other EVM networks", () => { + expect( + actionProvider.supportsNetwork({ + protocolFamily: "evm", + networkId: "ethereum-mainnet", + }), + ).toBe(false); + }); + + it("should return false for non-EVM networks", () => { + expect( + actionProvider.supportsNetwork({ + protocolFamily: "svm", + networkId: "base-mainnet", + }), + ).toBe(false); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts new file mode 100644 index 000000000..f9530f75a --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts @@ -0,0 +1,425 @@ +import { z } from "zod"; +import { Decimal } from "decimal.js"; +import { Address, encodeFunctionData, formatUnits, Hex, parseUnits } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { + SAPIEN_REWARDS_CONTROLLER_ADDRESS, + SAPIEN_TOKEN_ADDRESS, + SAPIEN_VAULT_ABI, + SAPIEN_VAULT_ADDRESS, + SAPIEN_VAULT_APP_URL, +} from "./constants"; +import { + SapienVaultDepositSchema, + SapienVaultGetPositionSchema, + SapienVaultRedeemSchema, + SapienVaultWithdrawSchema, +} from "./schemas"; +import { + approveSapienIfNeeded, + isSapienVaultNetwork, + readSapienShareDecimals, + readSapienTokenDecimals, + sapienVaultNetworkError, +} from "./utils"; + +/** + * SapienVaultActionProvider is an action provider for the Sapien Vault (vSAPIEN) on Base. + */ +export class SapienVaultActionProvider extends ActionProvider { + /** + * Constructor for the SapienVaultActionProvider class. + */ + constructor() { + super("sapienVault", []); + } + + /** + * Deposits SAPIEN into the Sapien Vault, approving the vault only when needed. + * + * @param wallet - The wallet instance to execute the transaction. + * @param args - The input arguments for the action. + * @returns A success message with transaction details or an error message. + */ + @CreateAction({ + name: "deposit", + description: ` +This tool deposits SAPIEN into the Sapien Vault on Base mainnet (ERC-4626) and mints vSAPIEN shares. + +It takes: +- assets: The amount of SAPIEN to deposit in whole units (e.g. "1", "0.5", "100") +- receiver: Optional address to receive vSAPIEN. Defaults to the connected wallet. + +Important notes: +- Only Base mainnet is supported. Do not call this on any other chain. +- Use the exact amount provided. Do not convert units. +- The vault will be approved to spend SAPIEN only if the current allowance is insufficient. +- Newly deposited shares may be subject to a minimum deposit age before they can be withdrawn or transferred. +- App: ${SAPIEN_VAULT_APP_URL} +- Vault (vSAPIEN): ${SAPIEN_VAULT_ADDRESS} +- Underlying SAPIEN: ${SAPIEN_TOKEN_ADDRESS} +`, + schema: SapienVaultDepositSchema, + }) + async deposit( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + const networkError = sapienVaultNetworkError(wallet); + if (networkError) { + return networkError; + } + + const assets = new Decimal(args.assets); + if (assets.comparedTo(new Decimal(0.0)) != 1) { + return "Error: Assets amount must be greater than 0"; + } + + try { + const owner = wallet.getAddress() as Address; + const receiver = (args.receiver ?? owner) as Address; + + const decimals = await readSapienTokenDecimals(wallet); + const atomicAssets = parseUnits(args.assets, decimals); + + const maxDeposit = (await wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "maxDeposit", + args: [receiver], + })) as bigint; + + if (atomicAssets > maxDeposit) { + return ( + `Error: Deposit of ${args.assets} SAPIEN exceeds maxDeposit (${formatUnits(maxDeposit, decimals)} SAPIEN). ` + + `The vault may be paused.` + ); + } + + const previewShares = (await wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "previewDeposit", + args: [atomicAssets], + })) as bigint; + + if (previewShares === 0n) { + return "Error: previewDeposit returned 0 shares. Wait and retry rather than depositing."; + } + + const approvalResult = await approveSapienIfNeeded(wallet, owner, atomicAssets); + if (approvalResult.startsWith("Error")) { + return `Error approving Sapien Vault as spender: ${approvalResult}`; + } + + const data = encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "deposit", + args: [atomicAssets, receiver], + }); + + const txHash = await wallet.sendTransaction({ + to: SAPIEN_VAULT_ADDRESS as Hex, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return ( + `Deposited ${args.assets} SAPIEN to Sapien Vault ${SAPIEN_VAULT_ADDRESS} ` + + `for ${receiver} with transaction hash: ${txHash}\n` + + `Transaction receipt: ${JSON.stringify(receipt)}\n` + + `App: ${SAPIEN_VAULT_APP_URL}` + ); + } catch (error) { + return `Error depositing to Sapien Vault: ${error}`; + } + } + + /** + * Withdraws SAPIEN from the Sapien Vault by asset amount (ERC-4626 withdraw). + * + * @param wallet - The wallet instance to execute the transaction. + * @param args - The input arguments for the action. + * @returns A success message with transaction details or an error message. + */ + @CreateAction({ + name: "withdraw", + description: ` +This tool withdraws SAPIEN from the Sapien Vault on Base mainnet by asset amount (ERC-4626 withdraw). + +It takes: +- assets: The amount of SAPIEN to withdraw in whole units (e.g. "1", "0.5") +- receiver: Optional address to receive SAPIEN. Defaults to the connected wallet. + +Use withdraw when the user specifies an amount of SAPIEN. Use redeem when they specify vSAPIEN shares. + +Important notes: +- Only Base mainnet is supported. +- Withdrawals are limited by maxWithdraw (matured, unlocked shares). Fresh deposits may still be aging, and locked validator stake cannot be withdrawn until the engine unlocks it. +- App: ${SAPIEN_VAULT_APP_URL} +`, + schema: SapienVaultWithdrawSchema, + }) + async withdraw( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + const networkError = sapienVaultNetworkError(wallet); + if (networkError) { + return networkError; + } + + const assets = new Decimal(args.assets); + if (assets.comparedTo(new Decimal(0.0)) != 1) { + return "Error: Assets amount must be greater than 0"; + } + + try { + const owner = wallet.getAddress() as Address; + const receiver = (args.receiver ?? owner) as Address; + const decimals = await readSapienTokenDecimals(wallet); + const atomicAssets = parseUnits(args.assets, decimals); + + const maxWithdraw = (await wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "maxWithdraw", + args: [owner], + })) as bigint; + + if (atomicAssets > maxWithdraw) { + return ( + `Error: Withdraw of ${args.assets} SAPIEN exceeds maxWithdraw ` + + `(${formatUnits(maxWithdraw, decimals)} SAPIEN). ` + + `Shares may still be aging (minDepositAge) or locked as validator stake.` + ); + } + + const data = encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "withdraw", + args: [atomicAssets, receiver, owner], + }); + + const txHash = await wallet.sendTransaction({ + to: SAPIEN_VAULT_ADDRESS as Hex, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return ( + `Withdrawn ${args.assets} SAPIEN from Sapien Vault ${SAPIEN_VAULT_ADDRESS} ` + + `to ${receiver} with transaction hash: ${txHash}\n` + + `Transaction receipt: ${JSON.stringify(receipt)}\n` + + `App: ${SAPIEN_VAULT_APP_URL}` + ); + } catch (error) { + return `Error withdrawing from Sapien Vault: ${error}`; + } + } + + /** + * Redeems vSAPIEN shares from the Sapien Vault (ERC-4626 redeem). + * + * @param wallet - The wallet instance to execute the transaction. + * @param args - The input arguments for the action. + * @returns A success message with transaction details or an error message. + */ + @CreateAction({ + name: "redeem", + description: ` +This tool redeems vSAPIEN shares from the Sapien Vault on Base mainnet (ERC-4626 redeem) for SAPIEN. + +It takes: +- shares: The amount of vSAPIEN to redeem in whole units (e.g. "1", "0.5") +- receiver: Optional address to receive SAPIEN. Defaults to the connected wallet. + +Use redeem when the user specifies an amount of vSAPIEN shares. Use withdraw when they specify SAPIEN assets. + +Important notes: +- Only Base mainnet is supported. +- vSAPIEN uses the vault's share decimals (underlying decimals plus an internal offset). Pass whole share units; do not convert. +- Redemptions are limited by maxRedeem (matured, unlocked shares). +- App: ${SAPIEN_VAULT_APP_URL} +`, + schema: SapienVaultRedeemSchema, + }) + async redeem( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + const networkError = sapienVaultNetworkError(wallet); + if (networkError) { + return networkError; + } + + const shares = new Decimal(args.shares); + if (shares.comparedTo(new Decimal(0.0)) != 1) { + return "Error: Shares amount must be greater than 0"; + } + + try { + const owner = wallet.getAddress() as Address; + const receiver = (args.receiver ?? owner) as Address; + const shareDecimals = await readSapienShareDecimals(wallet); + const atomicShares = parseUnits(args.shares, shareDecimals); + + const maxRedeem = (await wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "maxRedeem", + args: [owner], + })) as bigint; + + if (atomicShares > maxRedeem) { + return ( + `Error: Redeem of ${args.shares} vSAPIEN exceeds maxRedeem ` + + `(${formatUnits(maxRedeem, shareDecimals)} vSAPIEN). ` + + `Shares may still be aging (minDepositAge) or locked as validator stake.` + ); + } + + const data = encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "redeem", + args: [atomicShares, receiver, owner], + }); + + const txHash = await wallet.sendTransaction({ + to: SAPIEN_VAULT_ADDRESS as Hex, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return ( + `Redeemed ${args.shares} vSAPIEN from Sapien Vault ${SAPIEN_VAULT_ADDRESS} ` + + `to ${receiver} with transaction hash: ${txHash}\n` + + `Transaction receipt: ${JSON.stringify(receipt)}\n` + + `App: ${SAPIEN_VAULT_APP_URL}` + ); + } catch (error) { + return `Error redeeming from Sapien Vault: ${error}`; + } + } + + /** + * Reads a user's Sapien Vault shares and asset TVL. + * + * @param wallet - The wallet instance used for contract reads. + * @param args - Optional address to inspect. Defaults to the connected wallet. + * @returns Share and asset balances, or an error message. + */ + @CreateAction({ + name: "get_position", + description: ` +This tool reads a Sapien Vault position on Base mainnet: vSAPIEN shares and the SAPIEN asset value (user TVL). + +It takes: +- address: Optional address to inspect. Defaults to the connected wallet. + +User TVL is convertToAssets(balanceOf(address)) only. Do not include RewardsController (${SAPIEN_REWARDS_CONTROLLER_ADDRESS}) inventory in user TVL — that contract holds the unstreamed reward budget, not a user deposit. + +Also reports maxWithdraw / maxRedeem (matured, unlocked amounts that can exit now). + +Only Base mainnet is supported. App: ${SAPIEN_VAULT_APP_URL} +`, + schema: SapienVaultGetPositionSchema, + }) + async getPosition( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + const networkError = sapienVaultNetworkError(wallet); + if (networkError) { + return networkError; + } + + try { + const owner = (args.address ?? wallet.getAddress()) as Address; + const [tokenDecimals, shareDecimals] = await Promise.all([ + readSapienTokenDecimals(wallet), + readSapienShareDecimals(wallet), + ]); + + const [userShares, maxWithdraw, maxRedeem, rewardsControllerShares] = await Promise.all([ + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "balanceOf", + args: [owner], + }) as Promise, + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "maxWithdraw", + args: [owner], + }) as Promise, + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "maxRedeem", + args: [owner], + }) as Promise, + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "balanceOf", + args: [SAPIEN_REWARDS_CONTROLLER_ADDRESS as Address], + }) as Promise, + ]); + + const [userAssets, rewardsControllerAssets] = await Promise.all([ + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "convertToAssets", + args: [userShares], + }) as Promise, + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "convertToAssets", + args: [rewardsControllerShares], + }) as Promise, + ]); + + return [ + `Sapien Vault position for ${owner} on Base mainnet:`, + `- vSAPIEN shares: ${formatUnits(userShares, shareDecimals)} (${userShares} atomic)`, + `- SAPIEN assets (user TVL): ${formatUnits(userAssets, tokenDecimals)} (${userAssets} atomic)`, + `- maxWithdraw: ${formatUnits(maxWithdraw, tokenDecimals)} SAPIEN`, + `- maxRedeem: ${formatUnits(maxRedeem, shareDecimals)} vSAPIEN`, + ``, + `User TVL is convertToAssets(shares) only and excludes RewardsController ` + + `${SAPIEN_REWARDS_CONTROLLER_ADDRESS} inventory ` + + `(${formatUnits(rewardsControllerAssets, tokenDecimals)} SAPIEN-equivalent; not a user deposit).`, + `Vault: ${SAPIEN_VAULT_ADDRESS}`, + `Underlying SAPIEN: ${SAPIEN_TOKEN_ADDRESS}`, + `App: ${SAPIEN_VAULT_APP_URL}`, + ].join("\n"); + } catch (error) { + return `Error reading Sapien Vault position: ${error}`; + } + } + + /** + * Checks if the Sapien Vault action provider supports the given network. + * + * @param network - The network to check. + * @returns True only for Base mainnet. + */ + supportsNetwork = (network: Network) => isSapienVaultNetwork(network); +} + +/** + * Creates a new SapienVaultActionProvider instance. + * + * @returns A new SapienVaultActionProvider instance. + */ +export const sapienVaultActionProvider = () => new SapienVaultActionProvider(); diff --git a/typescript/agentkit/src/action-providers/sapienVault/schemas.ts b/typescript/agentkit/src/action-providers/sapienVault/schemas.ts new file mode 100644 index 000000000..53a36abfe --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/schemas.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +const EthereumAddressSchema = z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address format"); + +const WholeAmountSchema = z + .string() + .regex(/^\d+(\.\d+)?$/, "Must be a valid integer or decimal value"); + +/** + * Input schema for Sapien Vault deposit action. + */ +export const SapienVaultDepositSchema = z + .object({ + assets: WholeAmountSchema.describe("The quantity of SAPIEN to deposit, in whole units"), + receiver: EthereumAddressSchema.optional().describe( + "The address that will receive vSAPIEN shares. Defaults to the connected wallet.", + ), + }) + .describe("Input schema for Sapien Vault deposit action"); + +/** + * Input schema for Sapien Vault withdraw action (assets in). + */ +export const SapienVaultWithdrawSchema = z + .object({ + assets: WholeAmountSchema.describe("The quantity of SAPIEN to withdraw, in whole units"), + receiver: EthereumAddressSchema.optional().describe( + "The address that will receive SAPIEN. Defaults to the connected wallet.", + ), + }) + .describe("Input schema for Sapien Vault withdraw action"); + +/** + * Input schema for Sapien Vault redeem action (shares in). + */ +export const SapienVaultRedeemSchema = z + .object({ + shares: WholeAmountSchema.describe("The quantity of vSAPIEN shares to redeem, in whole units"), + receiver: EthereumAddressSchema.optional().describe( + "The address that will receive SAPIEN. Defaults to the connected wallet.", + ), + }) + .describe("Input schema for Sapien Vault redeem action"); + +/** + * Input schema for Sapien Vault position read. + */ +export const SapienVaultGetPositionSchema = z + .object({ + address: EthereumAddressSchema.optional().describe( + "The address to read. Defaults to the connected wallet.", + ), + }) + .describe("Input schema for Sapien Vault position read"); diff --git a/typescript/agentkit/src/action-providers/sapienVault/utils.ts b/typescript/agentkit/src/action-providers/sapienVault/utils.ts new file mode 100644 index 000000000..8752f04ca --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/utils.ts @@ -0,0 +1,113 @@ +import { Address, erc20Abi, Hex } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { Network } from "../../network"; +import { approve } from "../../utils"; +import { + SAPIEN_TOKEN_ADDRESS, + SAPIEN_VAULT_ABI, + SAPIEN_VAULT_ADDRESS, + SAPIEN_VAULT_APP_URL, + SAPIEN_VAULT_CHAIN_ID, + SAPIEN_VAULT_NETWORK_ID, +} from "./constants"; + +/** + * Returns true when the wallet is on Base mainnet, the only Sapien Vault deployment. + * + * @param network - The wallet network. + * @returns Whether the network is Base mainnet. + */ +export function isSapienVaultNetwork(network: Network): boolean { + return ( + network.protocolFamily === "evm" && + (network.networkId === SAPIEN_VAULT_NETWORK_ID || network.chainId === SAPIEN_VAULT_CHAIN_ID) + ); +} + +/** + * Builds a clear error when the wallet is not on Base mainnet. + * + * @param network - The unsupported network. + * @returns An error message naming the required and current networks. + */ +export function getUnsupportedNetworkError(network: Network): string { + const current = network.networkId ?? network.chainId ?? "unknown"; + return ( + `Error: Sapien Vault is only supported on Base mainnet ` + + `(networkId "${SAPIEN_VAULT_NETWORK_ID}", chainId ${SAPIEN_VAULT_CHAIN_ID}). ` + + `Current network: ${current}. See ${SAPIEN_VAULT_APP_URL}` + ); +} + +/** + * Returns an error when the wallet is not on Base mainnet. + * + * @param wallet - The wallet whose network should be checked. + * @returns An error message, or undefined when the network is supported. + */ +export function sapienVaultNetworkError(wallet: EvmWalletProvider): string | undefined { + const network = wallet.getNetwork(); + if (!isSapienVaultNetwork(network)) { + return getUnsupportedNetworkError(network); + } + return undefined; +} + +/** + * Approves the Sapien Vault to spend SAPIEN only when allowance is insufficient. + * + * @param wallet - The wallet provider. + * @param owner - The token owner (the connected wallet). + * @param amount - The SAPIEN amount to approve, in atomic units. + * @returns A success message, or an error string starting with "Error". + */ +export async function approveSapienIfNeeded( + wallet: EvmWalletProvider, + owner: Address, + amount: bigint, +): Promise { + const allowance = (await wallet.readContract({ + address: SAPIEN_TOKEN_ADDRESS as Hex, + abi: erc20Abi, + functionName: "allowance", + args: [owner, SAPIEN_VAULT_ADDRESS as Address], + })) as bigint; + + if (allowance >= amount) { + return `Allowance already sufficient for ${SAPIEN_VAULT_ADDRESS}`; + } + + return approve(wallet, SAPIEN_TOKEN_ADDRESS, SAPIEN_VAULT_ADDRESS, amount); +} + +/** + * Reads SAPIEN token decimals. + * + * @param wallet - The wallet provider. + * @returns Token decimals. + */ +export async function readSapienTokenDecimals(wallet: EvmWalletProvider): Promise { + const decimals = await wallet.readContract({ + address: SAPIEN_TOKEN_ADDRESS as Hex, + abi: erc20Abi, + functionName: "decimals", + args: [], + }); + return Number(decimals); +} + +/** + * Reads vSAPIEN share decimals from the vault. + * + * @param wallet - The wallet provider. + * @returns Share decimals. + */ +export async function readSapienShareDecimals(wallet: EvmWalletProvider): Promise { + const decimals = await wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "decimals", + args: [], + }); + return Number(decimals); +} From 5379d785a968889b7a144fb5e148d1de819d4b37 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 06:44:10 +0000 Subject: [PATCH 2/3] test: clear approve mock between Sapien Vault cases Prevent a leftover approve call from the insufficient-allowance deposit test from failing the skip-approve assertion. Co-authored-by: Chad --- .../sapienVault/sapienVaultActionProvider.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts index 45acf7642..0a6203d34 100644 --- a/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts @@ -91,6 +91,7 @@ describe("SapienVault Action Provider", () => { readContract: mockReadContract, } as unknown as jest.Mocked; + mockApprove.mockClear(); mockApprove.mockResolvedValue("Approval successful"); }); From 2f6ca4f983db588960e6bcf5dab6da577f6997fe Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 07:00:40 +0000 Subject: [PATCH 3/3] feat: expand Sapien Vault reads and share transfer Remove RewardsController. Keep SAPIEN approve as an internal deposit helper only. Add get_vault_totals, tranche fields on get_position, and vSAPIEN transfer with matured/available checks. Co-authored-by: Chad --- .../sapien-vault-action-provider.md | 2 +- typescript/agentkit/README.md | 12 +- .../action-providers/sapienVault/README.md | 23 +- .../action-providers/sapienVault/constants.ts | 95 ++++++- .../sapienVaultActionProvider.test.ts | 246 +++++++++++++++-- .../sapienVault/sapienVaultActionProvider.ts | 260 ++++++++++++++++-- .../action-providers/sapienVault/schemas.ts | 21 +- .../src/action-providers/sapienVault/utils.ts | 48 ++++ 8 files changed, 637 insertions(+), 70 deletions(-) diff --git a/typescript/.changeset/sapien-vault-action-provider.md b/typescript/.changeset/sapien-vault-action-provider.md index adba2f90b..531264ede 100644 --- a/typescript/.changeset/sapien-vault-action-provider.md +++ b/typescript/.changeset/sapien-vault-action-provider.md @@ -2,4 +2,4 @@ "@coinbase/agentkit": patch --- -Added a Sapien Vault action provider for depositing SAPIEN, withdrawing or redeeming vSAPIEN, and reading position state on the Base mainnet ERC-4626 vault +Added a Sapien Vault action provider for depositing SAPIEN, withdrawing or redeeming vSAPIEN, transferring shares, and reading vault totals and tranche position state on the Base mainnet ERC-4626 vault diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 3b6e837e9..c1f307e5d 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -559,7 +559,7 @@ const agent = createAgent({
- + @@ -569,9 +569,17 @@ const agent = createAgent({ + + + + - + + + + +
depositApproves SAPIEN if needed and deposits into the Sapien Vault (vSAPIEN) on Base mainnet.Deposits SAPIEN into the Sapien Vault (vSAPIEN) on Base mainnet, approving the vault internally only when needed.
withdrawredeem Redeems vSAPIEN shares from the Sapien Vault for SAPIEN (ERC-4626 redeem).
transferTransfers matured, unlocked vSAPIEN shares to a destination address.
get_positionReads vSAPIEN shares and SAPIEN asset TVL. Excludes RewardsController inventory from user TVL.Reads vSAPIEN shares, SAPIEN assets, matured/pending tranches, available balance, and locked stake.
get_vault_totalsReads vault totalAssets and total shares (totalSupply).
diff --git a/typescript/agentkit/src/action-providers/sapienVault/README.md b/typescript/agentkit/src/action-providers/sapienVault/README.md index 78e469c25..f53b7b65c 100644 --- a/typescript/agentkit/src/action-providers/sapienVault/README.md +++ b/typescript/agentkit/src/action-providers/sapienVault/README.md @@ -1,6 +1,6 @@ # Sapien Vault Action Provider -This directory contains the **SapienVaultActionProvider**, which lets AgentKit agents deposit SAPIEN, withdraw or redeem vSAPIEN, and read position state on the Sapien Vault — an ERC-4626 vault on Base mainnet. +This directory contains the **SapienVaultActionProvider**, which lets AgentKit agents deposit SAPIEN, withdraw or redeem vSAPIEN, transfer shares, and read vault totals and tranche position state on the Sapien Vault — an ERC-4626 vault on Base mainnet. App: [https://vault.sapien.io](https://vault.sapien.io) @@ -12,7 +12,7 @@ sapienVault/ ├── sapienVaultActionProvider.test.ts # Unit tests ├── constants.ts # Base mainnet addresses and vault ABI ├── schemas.ts # Zod action schemas -├── utils.ts # Network guard and approve-if-needed +├── utils.ts # Network guard and internal approve-if-needed ├── index.ts # Exports └── README.md # This file ``` @@ -23,18 +23,17 @@ sapienVault/ | --- | --- | | Vault / vSAPIEN (ERC-4626) | `0x60Bf63729f688287a450299962b36Cef0aFfaa42` | | Underlying SAPIEN | `0xC729777d0470F30612B1564Fd96E8Dd26f5814E3` | -| RewardsController (exclude from user TVL) | `0x55Ce7717Bc8c8F1b59AdB9e0CE7abc332391BF18` | ## Actions -- `deposit`: Approve SAPIEN for the vault if allowance is insufficient, then call ERC-4626 `deposit` -- `withdraw`: Redeem SAPIEN by **asset** amount (`withdraw`) -- `redeem`: Redeem SAPIEN by **share** amount (`redeem`) -- `get_position`: Read vSAPIEN shares, SAPIEN asset value (user TVL), `maxWithdraw`, and `maxRedeem` +- `deposit`: ERC-4626 `deposit`. Approves SAPIEN internally only when allowance is insufficient (not a public action) +- `withdraw`: Exit by **asset** amount (`withdraw`) +- `redeem`: Exit by **share** amount (`redeem`) +- `transfer`: ERC-20 `transfer` of vSAPIEN shares to a destination (matured, unlocked shares only) +- `get_position`: Shares, assets, matured/pending tranches, available balance, locked stake, and `depositAgeStatus` +- `get_vault_totals`: Vault `totalAssets` and total shares (`totalSupply`) -### User TVL - -`get_position` reports user TVL as `convertToAssets(balanceOf(user))` only. The RewardsController holds unstreamed reward inventory; that balance is **not** part of a user's position and is called out separately so agents do not add it to TVL. +There is no standalone `approve` action. ERC-20 approval is an implementation detail of `deposit`. ## Network Support @@ -50,6 +49,6 @@ sapienVault/ ## Notes -- Fresh deposits may be subject to `minDepositAge` before they can be withdrawn or transferred. Locked validator stake cannot be withdrawn until the engine unlocks it. Actions consult `maxDeposit` / `maxWithdraw` / `maxRedeem`. -- vSAPIEN share decimals are the underlying decimals plus an internal ERC-4626 offset. `redeem` uses the vault's `decimals()`. +- Fresh deposits may be subject to `minDepositAge` before they can be withdrawn or transferred. Locked validator stake cannot be withdrawn or transferred until the engine unlocks it. Actions consult `maxDeposit` / `maxWithdraw` / `maxRedeem` / `maturedShares` / `availableBalance`. +- vSAPIEN share decimals are the underlying decimals plus an internal ERC-4626 offset. Share-denominated actions use the vault's `decimals()`. - For protocol design, see the [SapienVault docs](https://github.com/Sapien-io/sapien-contracts/blob/main/docs/SapienVault.md). diff --git a/typescript/agentkit/src/action-providers/sapienVault/constants.ts b/typescript/agentkit/src/action-providers/sapienVault/constants.ts index 3eebc7205..5d2548828 100644 --- a/typescript/agentkit/src/action-providers/sapienVault/constants.ts +++ b/typescript/agentkit/src/action-providers/sapienVault/constants.ts @@ -6,19 +6,15 @@ export const SAPIEN_VAULT_ADDRESS = "0x60Bf63729f688287a450299962b36Cef0aFfaa42"; export const SAPIEN_TOKEN_ADDRESS = "0xC729777d0470F30612B1564Fd96E8Dd26f5814E3"; -/** - * RewardsController holds the unstreamed reward inventory. - * Its balance must be excluded from user TVL. - */ -export const SAPIEN_REWARDS_CONTROLLER_ADDRESS = "0x55Ce7717Bc8c8F1b59AdB9e0CE7abc332391BF18"; - export const SAPIEN_VAULT_APP_URL = "https://vault.sapien.io"; export const SAPIEN_VAULT_NETWORK_ID = "base-mainnet"; export const SAPIEN_VAULT_CHAIN_ID = "8453"; /** - * ERC-4626 surface used by the Sapien Vault (vSAPIEN). + * Sapien Vault ABI: ERC-4626 + tranche / age views + ERC-20 share transfer. + * + * View signatures match ISapienVault / the live vault used by vault.sapien.io. */ export const SAPIEN_VAULT_ABI = [ { @@ -53,6 +49,16 @@ export const SAPIEN_VAULT_ABI = [ outputs: [{ name: "assets", type: "uint256", internalType: "uint256" }], stateMutability: "nonpayable", }, + { + type: "function", + name: "transfer", + inputs: [ + { name: "to", type: "address", internalType: "address" }, + { name: "value", type: "uint256", internalType: "uint256" }, + ], + outputs: [{ name: "", type: "bool", internalType: "bool" }], + stateMutability: "nonpayable", + }, { type: "function", name: "balanceOf", @@ -60,6 +66,20 @@ export const SAPIEN_VAULT_ABI = [ outputs: [{ name: "", type: "uint256", internalType: "uint256" }], stateMutability: "view", }, + { + type: "function", + name: "totalSupply", + inputs: [], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "totalAssets", + inputs: [], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, { type: "function", name: "convertToAssets", @@ -67,6 +87,13 @@ export const SAPIEN_VAULT_ABI = [ outputs: [{ name: "", type: "uint256", internalType: "uint256" }], stateMutability: "view", }, + { + type: "function", + name: "convertToShares", + inputs: [{ name: "assets", type: "uint256", internalType: "uint256" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, { type: "function", name: "decimals", @@ -102,4 +129,58 @@ export const SAPIEN_VAULT_ABI = [ outputs: [{ name: "", type: "uint256", internalType: "uint256" }], stateMutability: "view", }, + { + type: "function", + name: "maturedShares", + inputs: [{ name: "user", type: "address", internalType: "address" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "pendingShares", + inputs: [{ name: "user", type: "address", internalType: "address" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "availableBalance", + inputs: [{ name: "user", type: "address", internalType: "address" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "assetsOf", + inputs: [{ name: "user", type: "address", internalType: "address" }], + outputs: [{ name: "", type: "uint256", internalType: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "depositAgeStatus", + inputs: [{ name: "user", type: "address", internalType: "address" }], + outputs: [ + { name: "matured", type: "uint256", internalType: "uint256" }, + { name: "pending", type: "uint256", internalType: "uint256" }, + { name: "minAge", type: "uint256", internalType: "uint256" }, + { name: "nextMaturityRemaining", type: "uint256", internalType: "uint256" }, + ], + stateMutability: "view", + }, + { + type: "function", + name: "getStakeAccount", + inputs: [{ name: "user", type: "address", internalType: "address" }], + outputs: [ + { + name: "", + type: "tuple", + internalType: "struct StakeAccount", + components: [{ name: "lockedAmount", type: "uint256", internalType: "uint256" }], + }, + ], + stateMutability: "view", + }, ] as const; diff --git a/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts index 0a6203d34..012fc18ab 100644 --- a/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts @@ -3,16 +3,13 @@ import { EvmWalletProvider } from "../../wallet-providers"; import { approve } from "../../utils"; import { Network } from "../../network"; import { SapienVaultActionProvider } from "./sapienVaultActionProvider"; -import { - SAPIEN_REWARDS_CONTROLLER_ADDRESS, - SAPIEN_TOKEN_ADDRESS, - SAPIEN_VAULT_ABI, - SAPIEN_VAULT_ADDRESS, -} from "./constants"; +import { SAPIEN_TOKEN_ADDRESS, SAPIEN_VAULT_ABI, SAPIEN_VAULT_ADDRESS } from "./constants"; import { SapienVaultDepositSchema, SapienVaultGetPositionSchema, + SapienVaultGetVaultTotalsSchema, SapienVaultRedeemSchema, + SapienVaultTransferSchema, SapienVaultWithdrawSchema, } from "./schemas"; @@ -26,8 +23,14 @@ const MOCK_WHOLE_ASSETS = "1.0"; const MOCK_WHOLE_SHARES = "1.0"; const MOCK_USER_SHARES = parseUnits("1.5", MOCK_SHARE_DECIMALS); const MOCK_USER_ASSETS = parseUnits("1.52", MOCK_TOKEN_DECIMALS); -const MOCK_RC_SHARES = parseUnits("1000", MOCK_SHARE_DECIMALS); -const MOCK_RC_ASSETS = parseUnits("1010", MOCK_TOKEN_DECIMALS); +const MOCK_MATURED_SHARES = parseUnits("1.2", MOCK_SHARE_DECIMALS); +const MOCK_PENDING_SHARES = parseUnits("0.3", MOCK_SHARE_DECIMALS); +const MOCK_AVAILABLE_ASSETS = parseUnits("1.2", MOCK_TOKEN_DECIMALS); +const MOCK_LOCKED_ASSETS = parseUnits("0.1", MOCK_TOKEN_DECIMALS); +const MOCK_TOTAL_ASSETS = parseUnits("10000", MOCK_TOKEN_DECIMALS); +const MOCK_TOTAL_SHARES = parseUnits("9900", MOCK_SHARE_DECIMALS); +const MOCK_MIN_AGE = 86400n; +const MOCK_NEXT_MATURITY = 3600n; const MOCK_MAX_DEPOSIT = parseUnits("1000000", MOCK_TOKEN_DECIMALS); const MOCK_PREVIEW_SHARES = parseUnits("0.99", MOCK_SHARE_DECIMALS); const MOCK_MAX_WITHDRAW = parseUnits("10", MOCK_TOKEN_DECIMALS); @@ -43,7 +46,7 @@ describe("SapienVault Action Provider", () => { const mockReadContract = jest.fn(); beforeEach(() => { - mockReadContract.mockImplementation(({ address, functionName, args }) => { + mockReadContract.mockImplementation(({ address, functionName }) => { if (functionName === "decimals") { return address === SAPIEN_VAULT_ADDRESS ? MOCK_SHARE_DECIMALS : MOCK_TOKEN_DECIMALS; } @@ -63,19 +66,35 @@ describe("SapienVault Action Provider", () => { return MOCK_MAX_REDEEM; } if (functionName === "balanceOf") { - const account = (args?.[0] as string)?.toLowerCase(); - if (account === SAPIEN_REWARDS_CONTROLLER_ADDRESS.toLowerCase()) { - return MOCK_RC_SHARES; - } return MOCK_USER_SHARES; } + if (functionName === "assetsOf") { + return MOCK_USER_ASSETS; + } if (functionName === "convertToAssets") { - const shares = args?.[0] as bigint; - if (shares === MOCK_RC_SHARES) { - return MOCK_RC_ASSETS; - } return MOCK_USER_ASSETS; } + if (functionName === "maturedShares") { + return MOCK_MATURED_SHARES; + } + if (functionName === "pendingShares") { + return MOCK_PENDING_SHARES; + } + if (functionName === "availableBalance") { + return MOCK_AVAILABLE_ASSETS; + } + if (functionName === "getStakeAccount") { + return { lockedAmount: MOCK_LOCKED_ASSETS }; + } + if (functionName === "depositAgeStatus") { + return [MOCK_MATURED_SHARES, MOCK_PENDING_SHARES, MOCK_MIN_AGE, MOCK_NEXT_MATURITY]; + } + if (functionName === "totalAssets") { + return MOCK_TOTAL_ASSETS; + } + if (functionName === "totalSupply") { + return MOCK_TOTAL_SHARES; + } throw new Error(`Unexpected readContract call: ${functionName}`); }); @@ -113,6 +132,34 @@ describe("SapienVault Action Provider", () => { it("should parse an empty get_position payload", () => { expect(SapienVaultGetPositionSchema.safeParse({}).success).toBe(true); }); + + it("should parse vault totals and transfer inputs", () => { + expect(SapienVaultGetVaultTotalsSchema.safeParse({}).success).toBe(true); + expect( + SapienVaultTransferSchema.safeParse({ + destination: MOCK_RECEIVER, + shares: "1", + }).success, + ).toBe(true); + expect(SapienVaultTransferSchema.safeParse({ destination: "bad", shares: "1" }).success).toBe( + false, + ); + }); + }); + + describe("exposed actions", () => { + it("should not expose a standalone approve action", () => { + const names = actionProvider.getActions(mockWallet).map(action => action.name); + expect(names).toEqual([ + "SapienVaultActionProvider_deposit", + "SapienVaultActionProvider_withdraw", + "SapienVaultActionProvider_redeem", + "SapienVaultActionProvider_transfer", + "SapienVaultActionProvider_get_position", + "SapienVaultActionProvider_get_vault_totals", + ]); + expect(names.some(name => name.endsWith("_approve") || name === "approve")).toBe(false); + }); }); describe("deposit", () => { @@ -292,18 +339,153 @@ describe("SapienVault Action Provider", () => { }); }); + describe("transfer", () => { + it("should transfer matured vSAPIEN shares", async () => { + const atomicShares = parseUnits("0.5", MOCK_SHARE_DECIMALS); + mockReadContract.mockImplementation(({ address, functionName }) => { + if (functionName === "decimals") { + return address === SAPIEN_VAULT_ADDRESS ? MOCK_SHARE_DECIMALS : MOCK_TOKEN_DECIMALS; + } + if (functionName === "maturedShares") { + return MOCK_MATURED_SHARES; + } + if (functionName === "availableBalance") { + return MOCK_AVAILABLE_ASSETS; + } + if (functionName === "getStakeAccount") { + return { lockedAmount: MOCK_LOCKED_ASSETS }; + } + if (functionName === "convertToAssets") { + return parseUnits("0.5", MOCK_TOKEN_DECIMALS); + } + throw new Error(`Unexpected readContract call: ${functionName}`); + }); + + const response = await actionProvider.transfer(mockWallet, { + destination: MOCK_RECEIVER, + shares: "0.5", + }); + + expect(mockWallet.sendTransaction).toHaveBeenCalledWith({ + to: SAPIEN_VAULT_ADDRESS, + data: encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "transfer", + args: [MOCK_RECEIVER, atomicShares], + }), + }); + expect(response).toContain("Transferred 0.5 vSAPIEN"); + expect(response).toContain(MOCK_RECEIVER); + expect(response).toContain(MOCK_TX_HASH); + }); + + it("should reject transfers above matured shares", async () => { + mockReadContract.mockImplementation(({ address, functionName }) => { + if (functionName === "decimals") { + return address === SAPIEN_VAULT_ADDRESS ? MOCK_SHARE_DECIMALS : MOCK_TOKEN_DECIMALS; + } + if (functionName === "maturedShares") { + return 0n; + } + if (functionName === "availableBalance") { + return 0n; + } + if (functionName === "getStakeAccount") { + return { lockedAmount: 0n }; + } + throw new Error(`Unexpected readContract call: ${functionName}`); + }); + + const response = await actionProvider.transfer(mockWallet, { + destination: MOCK_RECEIVER, + shares: MOCK_WHOLE_SHARES, + }); + expect(response).toContain("exceeds matured shares"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("should reject transfers that exceed available unlocked balance", async () => { + mockReadContract.mockImplementation(({ address, functionName }) => { + if (functionName === "decimals") { + return address === SAPIEN_VAULT_ADDRESS ? MOCK_SHARE_DECIMALS : MOCK_TOKEN_DECIMALS; + } + if (functionName === "maturedShares") { + return MOCK_MATURED_SHARES; + } + if (functionName === "availableBalance") { + return 0n; + } + if (functionName === "getStakeAccount") { + return { lockedAmount: MOCK_LOCKED_ASSETS }; + } + if (functionName === "convertToAssets") { + return parseUnits("0.5", MOCK_TOKEN_DECIMALS); + } + throw new Error(`Unexpected readContract call: ${functionName}`); + }); + + const response = await actionProvider.transfer(mockWallet, { + destination: MOCK_RECEIVER, + shares: "0.5", + }); + expect(response).toContain("exceeds available"); + expect(response).toContain("Locked stake"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("should reject transferring to the vault contract", async () => { + const response = await actionProvider.transfer(mockWallet, { + destination: SAPIEN_VAULT_ADDRESS, + shares: "0.5", + }); + expect(response).toContain("Do not transfer vSAPIEN to the vault contract"); + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + }); + + it("should handle transfer errors", async () => { + mockReadContract.mockImplementation(({ address, functionName }) => { + if (functionName === "decimals") { + return address === SAPIEN_VAULT_ADDRESS ? MOCK_SHARE_DECIMALS : MOCK_TOKEN_DECIMALS; + } + if (functionName === "maturedShares") { + return MOCK_MATURED_SHARES; + } + if (functionName === "availableBalance") { + return MOCK_AVAILABLE_ASSETS; + } + if (functionName === "getStakeAccount") { + return { lockedAmount: MOCK_LOCKED_ASSETS }; + } + if (functionName === "convertToAssets") { + return parseUnits("0.5", MOCK_TOKEN_DECIMALS); + } + throw new Error(`Unexpected readContract call: ${functionName}`); + }); + mockWallet.sendTransaction.mockRejectedValue(new Error("TransferExceedsUnlockedShares")); + + const response = await actionProvider.transfer(mockWallet, { + destination: MOCK_RECEIVER, + shares: "0.5", + }); + expect(response).toContain("Error transferring vSAPIEN"); + expect(response).toContain("TransferExceedsUnlockedShares"); + }); + }); + describe("get_position", () => { - it("should return shares and assets and exclude RewardsController from user TVL", async () => { + it("should return shares, assets, and tranche state", async () => { const response = await actionProvider.getPosition(mockWallet, {}); expect(response).toContain(MOCK_OWNER); expect(response).toContain("1.5"); expect(response).toContain("1.52"); - expect(response).toContain("user TVL"); - expect(response).toContain(SAPIEN_REWARDS_CONTROLLER_ADDRESS); - expect(response).toContain("excludes RewardsController"); - expect(response).toContain("1010"); - expect(response).not.toMatch(/SAPIEN assets \(user TVL\): 1010/); + expect(response).toContain("matured shares"); + expect(response).toContain("pending shares"); + expect(response).toContain("locked stake"); + expect(response).toContain("available balance"); + expect(response).toContain("86400"); + expect(response).toContain("3600"); + expect(response).not.toMatch(/RewardsController/i); }); it("should read a specified address", async () => { @@ -318,6 +500,24 @@ describe("SapienVault Action Provider", () => { }); }); + describe("get_vault_totals", () => { + it("should return totalAssets and total shares", async () => { + const response = await actionProvider.getVaultTotals(mockWallet, {}); + + expect(response).toContain("totalAssets"); + expect(response).toContain("10000"); + expect(response).toContain("total shares (totalSupply)"); + expect(response).toContain("9900"); + expect(response).toContain(SAPIEN_VAULT_ADDRESS); + }); + + it("should handle read errors", async () => { + mockReadContract.mockRejectedValue(new Error("rpc down")); + const response = await actionProvider.getVaultTotals(mockWallet, {}); + expect(response).toContain("Error reading Sapien Vault totals: Error: rpc down"); + }); + }); + describe("supportsNetwork", () => { it("should return true for Base mainnet", () => { expect( diff --git a/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts index f9530f75a..746ad9eb3 100644 --- a/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts +++ b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts @@ -6,7 +6,6 @@ import { EvmWalletProvider } from "../../wallet-providers"; import { CreateAction } from "../actionDecorator"; import { Network } from "../../network"; import { - SAPIEN_REWARDS_CONTROLLER_ADDRESS, SAPIEN_TOKEN_ADDRESS, SAPIEN_VAULT_ABI, SAPIEN_VAULT_ADDRESS, @@ -15,12 +14,16 @@ import { import { SapienVaultDepositSchema, SapienVaultGetPositionSchema, + SapienVaultGetVaultTotalsSchema, SapienVaultRedeemSchema, + SapienVaultTransferSchema, SapienVaultWithdrawSchema, } from "./schemas"; import { approveSapienIfNeeded, isSapienVaultNetwork, + parseDepositAgeStatus, + parseLockedAmount, readSapienShareDecimals, readSapienTokenDecimals, sapienVaultNetworkError, @@ -309,23 +312,143 @@ Important notes: } /** - * Reads a user's Sapien Vault shares and asset TVL. + * Transfers vSAPIEN shares to a destination address. + * + * @param wallet - The wallet instance to execute the transaction. + * @param args - Destination and whole-unit share amount. + * @returns A success message with transaction details or an error message. + */ + @CreateAction({ + name: "transfer", + description: ` +This tool transfers vSAPIEN shares from the connected wallet to a destination address on Base mainnet (ERC-20 transfer on the vault share token). + +It takes: +- destination: The address that will receive the vSAPIEN shares +- shares: The amount of vSAPIEN to transfer in whole units (e.g. "1", "0.5") + +Important notes: +- Only Base mainnet is supported. +- Only matured, unlocked shares can be transferred. Fresh deposits are blocked until minDepositAge elapses, and locked validator stake cannot be transferred. +- Do not use this to deposit or withdraw SAPIEN; use deposit / withdraw / redeem for those. +- App: ${SAPIEN_VAULT_APP_URL} +`, + schema: SapienVaultTransferSchema, + }) + async transfer( + wallet: EvmWalletProvider, + args: z.infer, + ): Promise { + const networkError = sapienVaultNetworkError(wallet); + if (networkError) { + return networkError; + } + + const shares = new Decimal(args.shares); + if (shares.comparedTo(new Decimal(0.0)) != 1) { + return "Error: Shares amount must be greater than 0"; + } + + const destination = args.destination as Address; + if (destination.toLowerCase() === SAPIEN_VAULT_ADDRESS.toLowerCase()) { + return "Error: Do not transfer vSAPIEN to the vault contract. Use deposit / withdraw / redeem."; + } + + try { + const owner = wallet.getAddress() as Address; + const shareDecimals = await readSapienShareDecimals(wallet); + const atomicShares = parseUnits(args.shares, shareDecimals); + + const [matured, available, stake, tokenDecimals] = await Promise.all([ + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "maturedShares", + args: [owner], + }) as Promise, + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "availableBalance", + args: [owner], + }) as Promise, + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "getStakeAccount", + args: [owner], + }), + readSapienTokenDecimals(wallet), + ]); + + if (atomicShares > matured) { + return ( + `Error: Transfer of ${args.shares} vSAPIEN exceeds matured shares ` + + `(${formatUnits(matured, shareDecimals)} vSAPIEN). ` + + `Pending (aging) shares cannot be transferred until minDepositAge elapses.` + ); + } + + const assetsToSend = (await wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "convertToAssets", + args: [atomicShares], + })) as bigint; + + if (assetsToSend > available) { + const lockedAmount = parseLockedAmount(stake); + return ( + `Error: Transfer of ${args.shares} vSAPIEN exceeds available (matured, unlocked) balance ` + + `(${formatUnits(available, tokenDecimals)} SAPIEN). ` + + `Locked stake: ${formatUnits(lockedAmount, tokenDecimals)} SAPIEN.` + ); + } + + const data = encodeFunctionData({ + abi: SAPIEN_VAULT_ABI, + functionName: "transfer", + args: [destination, atomicShares], + }); + + const txHash = await wallet.sendTransaction({ + to: SAPIEN_VAULT_ADDRESS as Hex, + data, + }); + + const receipt = await wallet.waitForTransactionReceipt(txHash); + + return ( + `Transferred ${args.shares} vSAPIEN from Sapien Vault ${SAPIEN_VAULT_ADDRESS} ` + + `to ${destination} with transaction hash: ${txHash}\n` + + `Transaction receipt: ${JSON.stringify(receipt)}\n` + + `App: ${SAPIEN_VAULT_APP_URL}` + ); + } catch (error) { + return `Error transferring vSAPIEN: ${error}`; + } + } + + /** + * Reads a user's Sapien Vault shares, assets, and tranche / age state. * * @param wallet - The wallet instance used for contract reads. * @param args - Optional address to inspect. Defaults to the connected wallet. - * @returns Share and asset balances, or an error message. + * @returns Share, asset, matured/pending/locked balances, or an error message. */ @CreateAction({ name: "get_position", description: ` -This tool reads a Sapien Vault position on Base mainnet: vSAPIEN shares and the SAPIEN asset value (user TVL). +This tool reads a Sapien Vault position on Base mainnet: vSAPIEN shares, SAPIEN asset value, and tranche / age state. It takes: - address: Optional address to inspect. Defaults to the connected wallet. -User TVL is convertToAssets(balanceOf(address)) only. Do not include RewardsController (${SAPIEN_REWARDS_CONTROLLER_ADDRESS}) inventory in user TVL — that contract holds the unstreamed reward budget, not a user deposit. - -Also reports maxWithdraw / maxRedeem (matured, unlocked amounts that can exit now). +Reports: +- total vSAPIEN shares (balanceOf) and SAPIEN assets (convertToAssets / assetsOf) +- matured shares, pending (aging) shares, minDepositAge, and seconds until the next cohort matures (depositAgeStatus) +- availableBalance (matured, unlocked, in SAPIEN) and lockedAmount from getStakeAccount +- maxWithdraw / maxRedeem Only Base mainnet is supported. App: ${SAPIEN_VAULT_APP_URL} `, @@ -347,7 +470,17 @@ Only Base mainnet is supported. App: ${SAPIEN_VAULT_APP_URL} readSapienShareDecimals(wallet), ]); - const [userShares, maxWithdraw, maxRedeem, rewardsControllerShares] = await Promise.all([ + const [ + userShares, + userAssets, + matured, + pending, + available, + stake, + ageStatus, + maxWithdraw, + maxRedeem, + ] = await Promise.all([ wallet.readContract({ address: SAPIEN_VAULT_ADDRESS as Hex, abi: SAPIEN_VAULT_ABI, @@ -357,48 +490,68 @@ Only Base mainnet is supported. App: ${SAPIEN_VAULT_APP_URL} wallet.readContract({ address: SAPIEN_VAULT_ADDRESS as Hex, abi: SAPIEN_VAULT_ABI, - functionName: "maxWithdraw", + functionName: "assetsOf", args: [owner], }) as Promise, wallet.readContract({ address: SAPIEN_VAULT_ADDRESS as Hex, abi: SAPIEN_VAULT_ABI, - functionName: "maxRedeem", + functionName: "maturedShares", args: [owner], }) as Promise, wallet.readContract({ address: SAPIEN_VAULT_ADDRESS as Hex, abi: SAPIEN_VAULT_ABI, - functionName: "balanceOf", - args: [SAPIEN_REWARDS_CONTROLLER_ADDRESS as Address], + functionName: "pendingShares", + args: [owner], }) as Promise, - ]); - - const [userAssets, rewardsControllerAssets] = await Promise.all([ wallet.readContract({ address: SAPIEN_VAULT_ADDRESS as Hex, abi: SAPIEN_VAULT_ABI, - functionName: "convertToAssets", - args: [userShares], + functionName: "availableBalance", + args: [owner], + }) as Promise, + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "getStakeAccount", + args: [owner], + }), + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "depositAgeStatus", + args: [owner], + }), + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "maxWithdraw", + args: [owner], }) as Promise, wallet.readContract({ address: SAPIEN_VAULT_ADDRESS as Hex, abi: SAPIEN_VAULT_ABI, - functionName: "convertToAssets", - args: [rewardsControllerShares], + functionName: "maxRedeem", + args: [owner], }) as Promise, ]); + const lockedAmount = parseLockedAmount(stake); + const { minAge, nextMaturityRemaining } = parseDepositAgeStatus(ageStatus); + return [ `Sapien Vault position for ${owner} on Base mainnet:`, `- vSAPIEN shares: ${formatUnits(userShares, shareDecimals)} (${userShares} atomic)`, - `- SAPIEN assets (user TVL): ${formatUnits(userAssets, tokenDecimals)} (${userAssets} atomic)`, + `- SAPIEN assets: ${formatUnits(userAssets, tokenDecimals)} (${userAssets} atomic)`, + `- matured shares: ${formatUnits(matured, shareDecimals)} vSAPIEN`, + `- pending shares (aging): ${formatUnits(pending, shareDecimals)} vSAPIEN`, + `- available balance: ${formatUnits(available, tokenDecimals)} SAPIEN`, + `- locked stake: ${formatUnits(lockedAmount, tokenDecimals)} SAPIEN`, + `- minDepositAge: ${minAge.toString()} seconds`, + `- next maturity remaining: ${nextMaturityRemaining.toString()} seconds`, `- maxWithdraw: ${formatUnits(maxWithdraw, tokenDecimals)} SAPIEN`, `- maxRedeem: ${formatUnits(maxRedeem, shareDecimals)} vSAPIEN`, - ``, - `User TVL is convertToAssets(shares) only and excludes RewardsController ` + - `${SAPIEN_REWARDS_CONTROLLER_ADDRESS} inventory ` + - `(${formatUnits(rewardsControllerAssets, tokenDecimals)} SAPIEN-equivalent; not a user deposit).`, `Vault: ${SAPIEN_VAULT_ADDRESS}`, `Underlying SAPIEN: ${SAPIEN_TOKEN_ADDRESS}`, `App: ${SAPIEN_VAULT_APP_URL}`, @@ -408,6 +561,65 @@ Only Base mainnet is supported. App: ${SAPIEN_VAULT_APP_URL} } } + /** + * Reads vault-wide totalAssets and total shares (totalSupply). + * + * @param wallet - The wallet instance used for contract reads. + * @param _args - Unused; the schema is empty. + * @returns Vault totals, or an error message. + */ + @CreateAction({ + name: "get_vault_totals", + description: ` +This tool reads Sapien Vault totals on Base mainnet. + +It returns: +- totalAssets: SAPIEN held by the vault (ERC-4626 totalAssets) +- total shares: outstanding vSAPIEN (ERC-20 totalSupply on the vault) + +No inputs are required. Only Base mainnet is supported. App: ${SAPIEN_VAULT_APP_URL} +`, + schema: SapienVaultGetVaultTotalsSchema, + }) + async getVaultTotals( + wallet: EvmWalletProvider, + _args: z.infer, + ): Promise { + const networkError = sapienVaultNetworkError(wallet); + if (networkError) { + return networkError; + } + + try { + const [tokenDecimals, shareDecimals, totalAssets, totalShares] = await Promise.all([ + readSapienTokenDecimals(wallet), + readSapienShareDecimals(wallet), + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "totalAssets", + args: [], + }) as Promise, + wallet.readContract({ + address: SAPIEN_VAULT_ADDRESS as Hex, + abi: SAPIEN_VAULT_ABI, + functionName: "totalSupply", + args: [], + }) as Promise, + ]); + + return [ + `Sapien Vault totals on Base mainnet:`, + `- totalAssets: ${formatUnits(totalAssets, tokenDecimals)} SAPIEN (${totalAssets} atomic)`, + `- total shares (totalSupply): ${formatUnits(totalShares, shareDecimals)} vSAPIEN (${totalShares} atomic)`, + `Vault: ${SAPIEN_VAULT_ADDRESS}`, + `App: ${SAPIEN_VAULT_APP_URL}`, + ].join("\n"); + } catch (error) { + return `Error reading Sapien Vault totals: ${error}`; + } + } + /** * Checks if the Sapien Vault action provider supports the given network. * diff --git a/typescript/agentkit/src/action-providers/sapienVault/schemas.ts b/typescript/agentkit/src/action-providers/sapienVault/schemas.ts index 53a36abfe..f4b18a7ca 100644 --- a/typescript/agentkit/src/action-providers/sapienVault/schemas.ts +++ b/typescript/agentkit/src/action-providers/sapienVault/schemas.ts @@ -45,7 +45,7 @@ export const SapienVaultRedeemSchema = z .describe("Input schema for Sapien Vault redeem action"); /** - * Input schema for Sapien Vault position read. + * Input schema for Sapien Vault position / tranche read. */ export const SapienVaultGetPositionSchema = z .object({ @@ -54,3 +54,22 @@ export const SapienVaultGetPositionSchema = z ), }) .describe("Input schema for Sapien Vault position read"); + +/** + * Input schema for Sapien Vault totals read (no parameters). + */ +export const SapienVaultGetVaultTotalsSchema = z + .object({}) + .describe("Input schema for Sapien Vault totals read"); + +/** + * Input schema for transferring vSAPIEN shares. + */ +export const SapienVaultTransferSchema = z + .object({ + destination: EthereumAddressSchema.describe("The address that will receive vSAPIEN shares"), + shares: WholeAmountSchema.describe( + "The quantity of vSAPIEN shares to transfer, in whole units", + ), + }) + .describe("Input schema for Sapien Vault share transfer"); diff --git a/typescript/agentkit/src/action-providers/sapienVault/utils.ts b/typescript/agentkit/src/action-providers/sapienVault/utils.ts index 8752f04ca..42f88d864 100644 --- a/typescript/agentkit/src/action-providers/sapienVault/utils.ts +++ b/typescript/agentkit/src/action-providers/sapienVault/utils.ts @@ -111,3 +111,51 @@ export async function readSapienShareDecimals(wallet: EvmWalletProvider): Promis }); return Number(decimals); } + +/** + * Normalizes getStakeAccount's return value to a locked-amount bigint. + * + * @param stake - Tuple or struct from getStakeAccount. + * @returns The locked amount in asset terms. + */ +export function parseLockedAmount(stake: unknown): bigint { + if (stake && typeof stake === "object" && !Array.isArray(stake) && "lockedAmount" in stake) { + return (stake as { lockedAmount: bigint }).lockedAmount; + } + if (Array.isArray(stake) && typeof stake[0] === "bigint") { + return stake[0]; + } + throw new Error("Unexpected getStakeAccount return value"); +} + +/** + * Normalizes depositAgeStatus's four return values. + * + * @param status - Named struct or positional tuple from depositAgeStatus. + * @returns Matured/pending shares, minAge, and seconds until next maturity. + */ +export function parseDepositAgeStatus(status: unknown): { + matured: bigint; + pending: bigint; + minAge: bigint; + nextMaturityRemaining: bigint; +} { + if (status && typeof status === "object" && !Array.isArray(status) && "matured" in status) { + const named = status as { + matured: bigint; + pending: bigint; + minAge: bigint; + nextMaturityRemaining: bigint; + }; + return named; + } + if (Array.isArray(status) && status.length >= 4) { + return { + matured: status[0] as bigint, + pending: status[1] as bigint, + minAge: status[2] as bigint, + nextMaturityRemaining: status[3] as bigint, + }; + } + throw new Error("Unexpected depositAgeStatus return value"); +}