diff --git a/typescript/.changeset/sapien-vault-action-provider.md b/typescript/.changeset/sapien-vault-action-provider.md new file mode 100644 index 000000000..531264ede --- /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, 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 37b14207f..c1f307e5d 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -555,6 +555,35 @@ const agent = createAgent({
+Sapien Vault + + + + + + + + + + + + + + + + + + + + + + + + + +
depositDeposits SAPIEN into the Sapien Vault (vSAPIEN) on Base mainnet, approving the vault internally only when needed.
withdrawWithdraws SAPIEN from the Sapien Vault by asset amount (ERC-4626 withdraw).
redeemRedeems vSAPIEN shares from the Sapien Vault for SAPIEN (ERC-4626 redeem).
transferTransfers matured, unlocked vSAPIEN shares to a destination address.
get_positionReads vSAPIEN shares, SAPIEN assets, matured/pending tranches, available balance, and locked stake.
get_vault_totalsReads vault totalAssets and total shares (totalSupply).
+
+
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..f53b7b65c --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/README.md @@ -0,0 +1,54 @@ +# Sapien Vault Action Provider + +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) + +## 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 internal 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` | + +## Actions + +- `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`) + +There is no standalone `approve` action. ERC-20 approval is an implementation detail of `deposit`. + +## 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 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 new file mode 100644 index 000000000..5d2548828 --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/constants.ts @@ -0,0 +1,186 @@ +/** + * 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"; + +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"; + +/** + * 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 = [ + { + 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: "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", + inputs: [{ name: "account", type: "address", internalType: "address" }], + 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", + inputs: [{ name: "shares", type: "uint256", internalType: "uint256" }], + 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", + 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", + }, + { + 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/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..012fc18ab --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.test.ts @@ -0,0 +1,569 @@ +import { encodeFunctionData, parseUnits } from "viem"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { approve } from "../../utils"; +import { Network } from "../../network"; +import { SapienVaultActionProvider } from "./sapienVaultActionProvider"; +import { SAPIEN_TOKEN_ADDRESS, SAPIEN_VAULT_ABI, SAPIEN_VAULT_ADDRESS } from "./constants"; +import { + SapienVaultDepositSchema, + SapienVaultGetPositionSchema, + SapienVaultGetVaultTotalsSchema, + SapienVaultRedeemSchema, + SapienVaultTransferSchema, + 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_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); +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 }) => { + 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") { + return MOCK_USER_SHARES; + } + if (functionName === "assetsOf") { + return MOCK_USER_ASSETS; + } + if (functionName === "convertToAssets") { + 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}`); + }); + + 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.mockClear(); + 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); + }); + + 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", () => { + 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("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, 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("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 () => { + 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("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( + 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..746ad9eb3 --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/sapienVaultActionProvider.ts @@ -0,0 +1,637 @@ +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_TOKEN_ADDRESS, + SAPIEN_VAULT_ABI, + SAPIEN_VAULT_ADDRESS, + SAPIEN_VAULT_APP_URL, +} from "./constants"; +import { + SapienVaultDepositSchema, + SapienVaultGetPositionSchema, + SapienVaultGetVaultTotalsSchema, + SapienVaultRedeemSchema, + SapienVaultTransferSchema, + SapienVaultWithdrawSchema, +} from "./schemas"; +import { + approveSapienIfNeeded, + isSapienVaultNetwork, + parseDepositAgeStatus, + parseLockedAmount, + 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}`; + } + } + + /** + * 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, 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, SAPIEN asset value, and tranche / age state. + +It takes: +- address: Optional address to inspect. Defaults to the connected wallet. + +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} +`, + 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, + userAssets, + matured, + pending, + available, + stake, + ageStatus, + maxWithdraw, + maxRedeem, + ] = 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: "assetsOf", + args: [owner], + }) as Promise, + 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: "pendingShares", + 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], + }), + 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: "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: ${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`, + `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}`; + } + } + + /** + * 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. + * + * @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..f4b18a7ca --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/schemas.ts @@ -0,0 +1,75 @@ +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 / tranche 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"); + +/** + * 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 new file mode 100644 index 000000000..42f88d864 --- /dev/null +++ b/typescript/agentkit/src/action-providers/sapienVault/utils.ts @@ -0,0 +1,161 @@ +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); +} + +/** + * 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"); +}