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 @@
+
+
+# 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/**"]
+ }
+ }
+}