diff --git a/README.md b/README.md index cb1825c6b..f301a18c2 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ GOAT is free software, MIT licensed. | Orca | Create positions on Orca | [@goat-sdk/plugin-orca](https://www.npmjs.com/package/@goat-sdk/plugin-orca) | | PlunderSwap | Currency exchange on Zilliqa | [@goat-sdk/plugin-plunderswap](https://www.npmjs.com/package/@goat-sdk/plugin-plunderswap) | | Polymarket | Bet on Polymarket | [@goat-sdk/plugin-polymarket](https://www.npmjs.com/package/@goat-sdk/plugin-polymarket) | +| Propy | Get updates on staking positions (more coming soon) | [@goat-sdk/plugin-propy](https://www.npmjs.com/package/@goat-sdk/plugin-propy) | | Pump.fun | Launch a token on Pump.fun | [@goat-sdk/plugin-pump-fun](https://www.npmjs.com/package/@goat-sdk/plugin-pump-fun) | | Renzo | Create a position on Renzo | [@goat-sdk/plugin-renzo](https://www.npmjs.com/package/@goat-sdk/plugin-renzo) | | Rugcheck | Check SPL token validity on Rugcheck | [@goat-sdk/plugin-rugcheck](https://www.npmjs.com/package/@goat-sdk/plugin-rugcheck) | [goat-sdk-plugin-rugcheck](https://github.com/goat-sdk/goat/tree/main/python/src/plugins/rugcheck) | diff --git a/typescript/.changeset/fuzzy-spoons-smell.md b/typescript/.changeset/fuzzy-spoons-smell.md new file mode 100644 index 000000000..509f931a4 --- /dev/null +++ b/typescript/.changeset/fuzzy-spoons-smell.md @@ -0,0 +1,5 @@ +--- +"@goat-sdk/plugin-propy": patch +--- + +Initial foundation for Propy's goat-sdk plugin diff --git a/typescript/examples/by-use-case/propy-evm-agent/tsconfig.json b/typescript/examples/by-use-case/propy-evm-agent/tsconfig.json new file mode 100644 index 000000000..a7fc4a937 --- /dev/null +++ b/typescript/examples/by-use-case/propy-evm-agent/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["index.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/typescript/packages/plugins/propy/README.md b/typescript/packages/plugins/propy/README.md new file mode 100644 index 000000000..4a2c51868 --- /dev/null +++ b/typescript/packages/plugins/propy/README.md @@ -0,0 +1,47 @@ +
+ + +GOAT + +
+ +# Propy GOAT Plugin + +Propy's experimental AI agent to make it easier for new blockchain users to read data from the blockchain. + +## Installation +```bash +npm install @goat-sdk/plugin-propy +yarn add @goat-sdk/plugin-propy +pnpm add @goat-sdk/plugin-propy +``` + +## Usage +```typescript +import { propy } from '@goat-sdk/plugin-propy'; + +const tools = await getOnChainTools({ + wallet: // ... + plugins: [ + propy({ + chainId: sepolia.id, + provider: process.env.RPC_PROVIDER_URL, + }) + ] +}); +``` + +## Tools +* get_staking_power +* get_staking_remaining_lockup_period +* MORE COMING SOON + + diff --git a/typescript/packages/plugins/propy/package.json b/typescript/packages/plugins/propy/package.json new file mode 100644 index 000000000..7a42dde91 --- /dev/null +++ b/typescript/packages/plugins/propy/package.json @@ -0,0 +1,32 @@ +{ + "name": "@goat-sdk/plugin-propy", + "version": "0.1.0", + "files": ["dist/**/*", "README.md", "package.json"], + "scripts": { + "build": "tsup", + "clean": "rm -rf dist", + "test": "vitest run --passWithNoTests" + }, + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "sideEffects": false, + "homepage": "https://ohmygoat.dev", + "repository": { + "type": "git", + "url": "git+https://github.com/goat-sdk/goat.git" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/goat-sdk/goat/issues" + }, + "keywords": ["ai", "agents", "web3"], + "dependencies": { + "@goat-sdk/core": "workspace:*", + "@goat-sdk/wallet-evm": "workspace:*", + "zod": "catalog:" + }, + "peerDependencies": { + "@goat-sdk/core": "workspace:*" + } +} diff --git a/typescript/packages/plugins/propy/src/abi.ts b/typescript/packages/plugins/propy/src/abi.ts new file mode 100644 index 000000000..1cd1b1b52 --- /dev/null +++ b/typescript/packages/plugins/propy/src/abi.ts @@ -0,0 +1,8 @@ +import { parseAbi } from "viem"; + +export const STAKING_V3_ABI = parseAbi([ + "function balanceOf(address account) external view returns (uint256)", + "function totalSupply() external view returns (uint256)", + "function locked(address, bytes32) external view returns (uint256)", + "function lockedAt(address, bytes32) external view returns (uint256)", +]); diff --git a/typescript/packages/plugins/propy/src/index.ts b/typescript/packages/plugins/propy/src/index.ts new file mode 100644 index 000000000..64824d70d --- /dev/null +++ b/typescript/packages/plugins/propy/src/index.ts @@ -0,0 +1,2 @@ +export * from "./propy.plugin"; +export * from "./parameters"; diff --git a/typescript/packages/plugins/propy/src/parameters.ts b/typescript/packages/plugins/propy/src/parameters.ts new file mode 100644 index 000000000..26c8c5e05 --- /dev/null +++ b/typescript/packages/plugins/propy/src/parameters.ts @@ -0,0 +1,50 @@ +import { createToolParameters } from "@goat-sdk/core"; +import { z } from "zod"; + +export class GetStakingPowerParameters extends createToolParameters( + z.object({ + ownWalletAddress: z.string().describe("The users own wallet address"), + specifiedWalletAddress: z + .string() + .describe("A different wallet address to ownWalletAddress, explicitly specified within the user's prompt"), + hasSpecifiedWalletAddress: z + .boolean() + .describe( + "Whether or not the user has explicitly specified a wallet address, should be false if ownWalletAddress and specifiedWalletAddress match each other", + ), + }), +) {} + +export class GetStakingRemainingLockupParameters extends createToolParameters( + z.object({ + ownWalletAddress: z.string().describe("The users own wallet address"), + specifiedWalletAddress: z + .string() + .describe("A different wallet address to ownWalletAddress, explicitly specified within the user's prompt"), + hasSpecifiedWalletAddress: z + .boolean() + .describe( + "Whether or not the user has explicitly specified a wallet address, should be false if ownWalletAddress and specifiedWalletAddress match each other", + ), + selectedStakingModules: z + .array( + z.object({ + moduleName: z.enum(["PRO", "PropyKeys", "UniswapLPNFT"]), + }), + ) + .default([ + { + moduleName: "PRO", + }, + { + moduleName: "PropyKeys", + }, + { + moduleName: "UniswapLPNFT", + }, + ]) + .describe( + "The user-specified modules that they want to check the remaining lockup period of, usually we would check for all modules but if they ask about the lockup associated with a particular asset, we will just check for the lockups associated with the specified assets, multiple modules can be specified", + ), + }), +) {} diff --git a/typescript/packages/plugins/propy/src/propy.plugin.ts b/typescript/packages/plugins/propy/src/propy.plugin.ts new file mode 100644 index 000000000..09f634d44 --- /dev/null +++ b/typescript/packages/plugins/propy/src/propy.plugin.ts @@ -0,0 +1,23 @@ +import { Chain, PluginBase } from "@goat-sdk/core"; +import { PropyService } from "./propy.service"; + +import { arbitrum, base, baseSepolia, mainnet, sepolia } from "viem/chains"; + +const SUPPORTED_CHAINS = [mainnet, arbitrum, base, sepolia, baseSepolia]; + +type PropyPluginOptions = { + provider?: string; + chainId?: number; +}; + +export class PropyPlugin extends PluginBase { + constructor(options: PropyPluginOptions) { + super("propy", [new PropyService(options.provider, options.chainId)]); + } + + supportsChain = (chain: Chain) => chain.type === "evm" && SUPPORTED_CHAINS.some((c) => c.id === chain.id); +} + +export function propy({ provider, chainId }: PropyPluginOptions) { + return new PropyPlugin({ provider, chainId }); +} diff --git a/typescript/packages/plugins/propy/src/propy.service.ts b/typescript/packages/plugins/propy/src/propy.service.ts new file mode 100644 index 000000000..83f801952 --- /dev/null +++ b/typescript/packages/plugins/propy/src/propy.service.ts @@ -0,0 +1,202 @@ +import { Tool } from "@goat-sdk/core"; +import { EVMWalletClient } from "@goat-sdk/wallet-evm"; +import BigNumber from "bignumber.js"; +import { ethers } from "ethers"; +import { STAKING_V3_ABI } from "./abi"; +import { GetStakingPowerParameters, GetStakingRemainingLockupParameters } from "./parameters"; +import { countdownToTimestamp } from "./utils"; + +BigNumber.config({ EXPONENTIAL_AT: [-1e9, 1e9] }); + +const CONTRACT_ADDRESSES: { [key: string]: { [key: string]: string } } = { + PRONFTStakingCore: { + 1: "0x4e2f246042FC67d8173397c01775Fc29508c9aCe", + 11155111: "0xea6fFe0d13eca58CfF3427d65807338982BdC687", + }, + PropyKeyStakingModule: { + 1: "0xBd0969813733df8f506611c204EEF540770CAB72", + 11155111: "0xBd0969813733df8f506611c204EEF540770CAB72", + }, + ERC20StakingModule: { + 1: "0xF46464ad108B1CC7866DF2Cfa87688F7742BA623", + 11155111: "0xF46464ad108B1CC7866DF2Cfa87688F7742BA623", + }, + LPStakingModule: { + 1: "0x8D020131832D8823846232031bD7EEee7A102F2F", + 11155111: "0x8D020131832D8823846232031bD7EEee7A102F2F", + }, +}; + +export class PropyService { + constructor( + private readonly provider: string | undefined, + private readonly chainId: number | undefined, + ) {} + + @Tool({ + name: "get_staking_power", + description: + "Get the current staking power (pSTAKE balance) of the current wallet address, and it's share of incoming rewards", + }) + async getStakingPower(walletClient: EVMWalletClient, parameters: GetStakingPowerParameters) { + let chainId = walletClient.getChain().id; + + if (this.chainId) { + chainId = this.chainId; + } + + const contractAddress = CONTRACT_ADDRESSES?.PRONFTStakingCore?.[chainId]; + + if (!contractAddress) { + throw Error("Failed to fetch balance, unable to detect staking contract address"); + } + + const { ownWalletAddress, specifiedWalletAddress } = parameters; + + let useWalletAddress = ownWalletAddress; + if (specifiedWalletAddress !== ownWalletAddress) { + useWalletAddress = specifiedWalletAddress; + } + + if (!useWalletAddress) { + throw Error("Failed to fetch balance, unable to detect wallet address"); + } + + try { + const rawBalance = await walletClient.read({ + address: contractAddress, + abi: STAKING_V3_ABI, + functionName: "balanceOf", + args: [useWalletAddress], + }); + + if (Number(rawBalance.value) > 0) { + const rawSupply = await walletClient.read({ + address: contractAddress, + abi: STAKING_V3_ABI, + functionName: "totalSupply", + args: [], + }); + + const percentageShare = new BigNumber(`${rawBalance.value}`) + .multipliedBy(100) + .dividedBy(new BigNumber(`${rawSupply.value}`)) + .toFixed(2); + return { + stakingPower: `${Number(ethers.utils.formatUnits(`${rawBalance.value}`, 8)).toFixed(2)} pSTAKE`, + percentageShareOfIncomingRewards: `${percentageShare} %`, + }; + } + return { + stakingPower: `${Number(ethers.utils.formatUnits(`${rawBalance.value}`, 8)).toFixed(2)} pSTAKE`, + percentageShareOfIncomingRewards: "0 %", + }; + } catch (error) { + throw Error(`Failed to fetch balance: ${error}`); + } + } + @Tool({ + name: "get_staking_remaining_lockup_period", + description: + "Get the remaining staking lockup periods of the current wallet address, support the following staking modules: PropyKey Staking, PRO staking & Uniswap Liquidity Provider NFT (LP NFT) staking", + }) + async getStakingRemainingLockupPeriod( + walletClient: EVMWalletClient, + parameters: GetStakingRemainingLockupParameters, + ) { + let chainId = walletClient.getChain().id; + + if (this.chainId) { + chainId = this.chainId; + } + + const { ownWalletAddress, specifiedWalletAddress, selectedStakingModules } = parameters; + + const checkModuleConfigs = []; + for (const moduleType of selectedStakingModules) { + if (moduleType.moduleName === "PRO") { + checkModuleConfigs.push({ + assetType: moduleType.moduleName, + contractAddress: CONTRACT_ADDRESSES?.PRONFTStakingCore?.[chainId], + moduleId: "0x1eacf06e77941a18f9bc3eb0852750ba87d1f812f0c2df2907082d9904d39335", + abi: STAKING_V3_ABI, + }); + } else if (moduleType.moduleName === "PropyKeys") { + checkModuleConfigs.push({ + assetType: moduleType.moduleName, + contractAddress: CONTRACT_ADDRESSES?.PRONFTStakingCore?.[chainId], + moduleId: "0x45078117f79b3fdef93038946c01157f589c320a33e8da6a836521d757382476", + abi: STAKING_V3_ABI, + }); + } else if (moduleType.moduleName === "UniswapLPNFT") { + checkModuleConfigs.push({ + assetType: moduleType.moduleName, + contractAddress: CONTRACT_ADDRESSES?.PRONFTStakingCore?.[chainId], + moduleId: "0xc857a6e7be06cf7940500da1c03716d761f264c09e870f16bef249a1d84f00ac", + abi: STAKING_V3_ABI, + }); + } + } + + if (checkModuleConfigs.length === 0) { + throw Error("Failed to remaining staking time, unable to detect staking module contract addresses"); + } + + let useWalletAddress = ownWalletAddress; + if (specifiedWalletAddress !== ownWalletAddress) { + useWalletAddress = specifiedWalletAddress; + } + + if (!useWalletAddress) { + throw Error("Failed to remaining staking time, unable to detect staker's wallet address"); + } + + try { + const response: { [key: string]: string | Date }[] = []; + + for (const moduleConfig of checkModuleConfigs) { + const lockedAt = await walletClient.read({ + address: moduleConfig.contractAddress, + abi: moduleConfig.abi, + functionName: "lockedAt", + args: [useWalletAddress, moduleConfig.moduleId], + }); + + const lockedUntil = await walletClient.read({ + address: moduleConfig.contractAddress, + abi: moduleConfig.abi, + functionName: "locked", + args: [useWalletAddress, moduleConfig.moduleId], + }); + + if (Number(lockedAt?.value) > 0 && Number(lockedUntil?.value) > 0) { + response.push({ + stakedAssetType: moduleConfig.assetType, + // "unixTimestampLockedAt": new Date(Number(lockedAt?.value) * 1000), + dateLockedUntil: new Date(Number(lockedUntil?.value) * 1000), + // "currentDate": new Date(), + timeRemainingOnLockup: countdownToTimestamp( + Number(lockedUntil?.value), + "Lockup period is complete", + false, + ), + meta: "Include an intuitive amount of time left for the staking (e.g. in days/hours/minutes, use appropriate values), also mention the start and end date of the staking", + }); + } else { + response.push({ + stakedAssetType: moduleConfig.assetType, + unixTimestampLockedAt: `No stake "locked at" time, this indicates that the current wallet address doesn't have an active staking position for ${moduleConfig.assetType}`, + unixTimestampLockedUntil: `No stake "locked until" time, this indicates that the current wallet address doesn't have an active staking position for ${moduleConfig.assetType}`, + }); + } + } + + if (response.length > 0) { + return response; + } + return "Unable to find results"; + } catch (error) { + throw Error(`Failed to fetch balance: ${error}`); + } + } +} diff --git a/typescript/packages/plugins/propy/src/utils.ts b/typescript/packages/plugins/propy/src/utils.ts new file mode 100644 index 000000000..f1a9517f7 --- /dev/null +++ b/typescript/packages/plugins/propy/src/utils.ts @@ -0,0 +1,36 @@ +export const countdownToTimestamp = (unixTimestamp: number, completeText: string, showFullTimer?: boolean): string => { + // Get the current time and calculate the difference + const now = new Date().getTime(); + const diff = unixTimestamp * 1000 - now; + + // Ensure the timestamp is in the future + if (diff <= 0) { + return completeText; + } + + // Calculate days, hours, minutes, and seconds + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + // Format the countdown string + let countdownString = ""; + if (days > 0) { + countdownString += `${days} day${days > 1 ? "s" : ""} ${hours} hour${hours > 1 ? "s" : ""} `; + if (showFullTimer) { + countdownString += `${minutes} minute${minutes > 1 ? "s" : ""} ${seconds} second${seconds > 1 ? "s" : ""}`; + } + } else if (hours > 0) { + countdownString += `${hours} hour${hours > 1 ? "s" : ""} ${minutes} minute${minutes > 1 ? "s" : ""} `; + if (showFullTimer) { + countdownString += `${seconds} second${seconds > 1 ? "s" : ""}`; + } + } else if (minutes > 0) { + countdownString += `${minutes} minute${minutes > 1 ? "s" : ""} ${seconds} second${seconds > 1 ? "s" : ""}`; + } else { + countdownString += `${seconds} second${seconds > 1 ? "s" : ""}`; + } + + return countdownString.trim(); +}; diff --git a/typescript/packages/plugins/propy/tsconfig.json b/typescript/packages/plugins/propy/tsconfig.json new file mode 100644 index 000000000..b4ae67c1f --- /dev/null +++ b/typescript/packages/plugins/propy/tsconfig.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "../../../tsconfig.base.json", + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/typescript/packages/plugins/propy/tsup.config.ts b/typescript/packages/plugins/propy/tsup.config.ts new file mode 100644 index 000000000..2d38789ad --- /dev/null +++ b/typescript/packages/plugins/propy/tsup.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "tsup"; +import { treeShakableConfig } from "../../../tsup.config.base"; + +export default defineConfig({ + ...treeShakableConfig, +}); diff --git a/typescript/packages/plugins/propy/turbo.json b/typescript/packages/plugins/propy/turbo.json new file mode 100644 index 000000000..45f951676 --- /dev/null +++ b/typescript/packages/plugins/propy/turbo.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://turbo.build/schema.json", + "extends": ["//"], + "tasks": { + "build": { + "inputs": ["src/**", "tsup.config.ts", "!./**/*.test.{ts,tsx}", "tsconfig.json"], + "dependsOn": ["^build"], + "outputs": ["dist/**"] + } + } +}