Skip to content

Commit ca57888

Browse files
authored
feat: add stake and unstake tools to HyperEVM (#24)
* draft: add staking/unstaking feature * draft: refactor staking/unstaking tool * feat: add stake and unstake tool to HyperEVM * draft: add staking/unstaking feature * draft: refactor staking/unstaking tool * feat: add stake and unstake tool to HyperEVM * fix: minor fix * fix: fix README * fix: format fix
1 parent 17515ec commit ca57888

File tree

8 files changed

+350
-2
lines changed

8 files changed

+350
-2
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ MCP servers offer a schema-driven interface that removes the need for custom int
2626

2727
- High-performance EVM environment with fast finality and low fees
2828
- Familiar EVM tooling (ABIs, RPC, wallets) via `viem`
29+
- Built-in staking capabilities for HYPE tokens
2930

3031
## Architecture
3132

@@ -43,6 +44,8 @@ The server currently exposes the following tools (see `src/tools/tools.ts`):
4344
- send_funds: Send native funds from the configured signer to a receiver
4445
- deploy_contracts: Deploy a contract with ABI, bytecode, constructor args
4546
- get_transaction_receipt: Fetch a transaction receipt by hash
47+
- stake: Stake HYPE tokens on Hyperliquid
48+
- unstake: Unstake HYPE tokens from Hyperliquid
4649

4750
Each tool validates inputs with `zod` and executes using `viem` on the configured Hyperliquid RPC.
4851

@@ -100,6 +103,8 @@ Example tool calls (names only; argument shapes are defined by the server):
100103
- send_funds { receiverAddress, amountToSend }
101104
- deploy_contracts { abi, bytecode, constructorArguments }
102105
- get_transaction_receipt { txHash }
106+
- stake { amountToStake, validatorAddress, isTestnet }
107+
- unstake { amountToUnstake, validatorAddress, isTestnet }
103108

104109
Inspect `src/main.ts` and `src/tools/**` for exact schemas and behaviors.
105110

package-lock.json

Lines changed: 14 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"description": "",
2323
"dependencies": {
2424
"@modelcontextprotocol/sdk": "^1.17.3",
25+
"@nktkas/hyperliquid": "^0.22.1",
2526
"abitype": "^1.0.9",
2627
"dotenv": "^17.2.1",
2728
"ether": "^0.0.9",

src/config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { privateKeyToAccount } from "viem/accounts";
99
import dotenv from "dotenv";
1010
import { privateKeySchema } from "./tools/hyper-evm/sendFunds/schemas.js";
11+
import * as hyper from "@nktkas/hyperliquid";
1112

1213
dotenv.config();
1314

@@ -52,3 +53,17 @@ export const walletClient: WalletClient = createWalletClient({
5253
chain: hyperEvmConfig,
5354
transport: http(),
5455
});
56+
57+
export const transport = new hyper.HttpTransport({
58+
isTestnet: true,
59+
timeout: 30000,
60+
});
61+
62+
export const exchClient = new hyper.ExchangeClient({
63+
wallet: walletClient,
64+
transport: transport,
65+
isTestnet: true,
66+
signatureChainId: process.env.isTestnet ? "0x66eee" : "0x1",
67+
});
68+
69+
export const infoClient = new hyper.InfoClient({ transport });

src/main.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
SEND_FUNDS_TOOL,
1313
GET_TRANSACTION_RECEIPT_TOOL,
1414
GET_TOKEN_BALANCE_TOOL,
15+
STAKE_TOOL,
16+
UNSTAKE_TOOL,
1517
GET_LOGS_TOOL,
1618
} from "./tools/tools.js";
1719
import { getBalance } from "./tools/hyper-evm/getBalance/index.js";
@@ -24,6 +26,14 @@ import { getTransactionReceipt } from "./tools/hyper-evm/getTransactionReceipt/i
2426
import type { getTransactionReceiptInput } from "./tools/hyper-evm/getTransactionReceipt/schemas.js";
2527
import { getTokenBalanceInputSchema } from "./tools/hyper-evm/getTokenBalance/schemas.js";
2628
import { getTokenBalance } from "./tools/hyper-evm/getTokenBalance/index.js";
29+
import {
30+
performStaking,
31+
performUnstaking,
32+
} from "./tools/hyper-evm/handleStake/index.js";
33+
import {
34+
getStakingInputSchema,
35+
getUnstakingInputSchema,
36+
} from "./tools/hyper-evm/handleStake/schemas.js";
2737
import { getLogs } from "./tools/hyper-evm/getLogs/index.js";
2838

2939
async function main() {
@@ -98,6 +108,30 @@ async function main() {
98108
return result;
99109
}
100110

111+
case "stake": {
112+
const input = args as {
113+
amountToStake: string;
114+
validatorAddress: string;
115+
isTestnet: boolean | string;
116+
};
117+
118+
const validatedInput = getStakingInputSchema.parse(input);
119+
const result = await performStaking(validatedInput);
120+
return result;
121+
}
122+
123+
case "unstake": {
124+
const input = args as {
125+
amountToUnstake: string;
126+
validatorAddress: string;
127+
isTestnet: boolean | string;
128+
};
129+
130+
const validatedInput = getUnstakingInputSchema.parse(input);
131+
const result = await performUnstaking(validatedInput);
132+
return result;
133+
}
134+
101135
case "get_logs": {
102136
const { contractAddress, from, to } = args as {
103137
contractAddress: string;
@@ -110,7 +144,7 @@ async function main() {
110144

111145
default: {
112146
throw new Error(
113-
`Tool '${name}' not found. Available tools: get_latest_block, get_balance, deploy_contracts, send_funds, get_transaction_receipt, get_token_balance`
147+
`Tool '${name}' not found. Available tools: get_latest_block, get_balance, deploy_contracts, send_funds, get_transaction_receipt, get_token_balance, stake, unstake`
114148
);
115149
}
116150
}
@@ -138,6 +172,8 @@ async function main() {
138172
SEND_FUNDS_TOOL,
139173
GET_TRANSACTION_RECEIPT_TOOL,
140174
GET_TOKEN_BALANCE_TOOL,
175+
STAKE_TOOL,
176+
UNSTAKE_TOOL,
141177
GET_LOGS_TOOL,
142178
],
143179
};
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { walletClient, exchClient, infoClient } from "../../../config.js";
2+
import type { getStakingInput, getUnstakingInput } from "./schemas.js";
3+
4+
export async function performStaking(stakingDetails: getStakingInput) {
5+
try {
6+
const validators = await infoClient.validatorSummaries();
7+
8+
const validator = validators.find(
9+
v => v.validator === stakingDetails.validatorAddress
10+
);
11+
12+
if (!validator) {
13+
throw new Error(`Validator ${stakingDetails.validatorAddress} not found`);
14+
}
15+
16+
const amountScaled = Number(
17+
(parseFloat(stakingDetails.amountToStake) * 1e8).toFixed(0)
18+
);
19+
20+
const depositResult = await exchClient.cDeposit({
21+
wei: amountScaled,
22+
});
23+
24+
if (depositResult.status !== "ok") {
25+
throw new Error(`Deposit failed: ${JSON.stringify(depositResult)}`);
26+
}
27+
28+
// Wait for deposit to process
29+
await new Promise(resolve => setTimeout(resolve, 3000));
30+
31+
const delegationResult = await exchClient.tokenDelegate({
32+
validator: stakingDetails.validatorAddress,
33+
wei: amountScaled,
34+
isUndelegate: false,
35+
});
36+
37+
if (delegationResult.status !== "ok") {
38+
throw new Error(`Delegation failed: ${JSON.stringify(delegationResult)}`);
39+
}
40+
41+
const userAddress = walletClient.account?.address;
42+
if (!userAddress) {
43+
throw new Error("Failed to load wallet client account");
44+
}
45+
46+
const updatedDelegations = await infoClient.delegations({
47+
user: userAddress,
48+
});
49+
50+
const newDelegation = updatedDelegations.find(
51+
d => d.validator === stakingDetails.validatorAddress
52+
);
53+
54+
return {
55+
content: [
56+
{
57+
type: "text",
58+
text: `Staking successful!\nValidator: ${validator.name}\nAmount Staked: ${stakingDetails.amountToStake} HYPE\nTotal Delegated to Validator: ${newDelegation?.amount || "0"} HYPE\nDeposit TX: ${depositResult.response?.type}\nDelegation TX: ${delegationResult.response?.type}`,
59+
},
60+
],
61+
};
62+
} catch (error) {
63+
console.error("Error performing staking:", error);
64+
throw new Error(
65+
`Failed to perform staking: ${error instanceof Error ? error.message : String(error)}`
66+
);
67+
}
68+
}
69+
70+
export async function performUnstaking(unstakingDetails: getUnstakingInput) {
71+
try {
72+
const userAddress = walletClient.account?.address;
73+
if (!userAddress) {
74+
throw new Error("Failed to load wallet client account");
75+
}
76+
const currentDelegations = await infoClient.delegations({
77+
user: userAddress,
78+
});
79+
80+
if (currentDelegations.length === 0) {
81+
throw new Error("No active delegations found to unstake from");
82+
}
83+
84+
const totalDelegated = currentDelegations.reduce(
85+
(sum, delegation) =>
86+
sum + Number(parseFloat(delegation.amount).toFixed(8)),
87+
0
88+
);
89+
90+
const requestedAmount = parseFloat(unstakingDetails.amountToUnstake);
91+
92+
if (requestedAmount > totalDelegated) {
93+
throw new Error(
94+
`Insufficient staked amount. Available: ${totalDelegated}, Requested: ${requestedAmount}`
95+
);
96+
}
97+
98+
let remainingToUnstake = requestedAmount;
99+
const undelegationResults = [];
100+
101+
for (const delegation of currentDelegations) {
102+
if (remainingToUnstake <= 0) {
103+
break;
104+
}
105+
106+
const delegatedAmount = parseFloat(delegation.amount);
107+
const amountToUndelegateFromThis = Math.min(
108+
remainingToUnstake,
109+
delegatedAmount
110+
);
111+
const newUndelegatedAmountScaled = Number(
112+
(amountToUndelegateFromThis * 1e8).toFixed(0)
113+
);
114+
115+
const undelegateResult = await exchClient.tokenDelegate({
116+
validator: delegation.validator,
117+
wei: newUndelegatedAmountScaled,
118+
isUndelegate: true,
119+
});
120+
121+
if (undelegateResult.status !== "ok") {
122+
throw new Error(`Failed to undelegate from ${delegation.validator}:`);
123+
} else {
124+
undelegationResults.push({
125+
validator: delegation.validator,
126+
undelegatedAmount: amountToUndelegateFromThis,
127+
result: undelegateResult,
128+
});
129+
remainingToUnstake -= amountToUndelegateFromThis;
130+
}
131+
132+
// Small delay between undelegations
133+
await new Promise(resolve => setTimeout(resolve, 1000));
134+
}
135+
136+
if (remainingToUnstake > 0) {
137+
throw new Error(
138+
`Could not undelegate full amount. Remaining: ${remainingToUnstake}`
139+
);
140+
}
141+
142+
// Wait for undelegations to process
143+
await new Promise(resolve => setTimeout(resolve, 5000));
144+
145+
const amountScaledWithdraw = Number(
146+
(parseFloat(unstakingDetails.amountToUnstake) * 1e8).toFixed(0)
147+
);
148+
149+
const withdrawResult = await exchClient.cWithdraw({
150+
wei: amountScaledWithdraw,
151+
});
152+
153+
if (withdrawResult.status !== "ok") {
154+
throw new Error(`Withdrawal failed: ${JSON.stringify(withdrawResult)}`);
155+
}
156+
157+
const finalSummary = await infoClient.delegatorSummary({
158+
user: userAddress,
159+
});
160+
161+
return {
162+
content: [
163+
{
164+
type: "text",
165+
text: `Unstaking successful!\nAmount Unstaked: ${unstakingDetails.amountToUnstake} HYPE\nValidators Affected: ${undelegationResults.length}\nWithdrawal Status: ${withdrawResult.response?.type}\nRemaining Staked: ${finalSummary.delegated || "0"} HYPE\nAvailable in Staking Account: ${finalSummary.totalPendingWithdrawal || "0"} HYPE`,
166+
},
167+
],
168+
};
169+
} catch (error) {
170+
console.error("Error performing unstaking:", error);
171+
throw new Error(
172+
`Failed to perform unstaking: ${error instanceof Error ? error.message : String(error)}`
173+
);
174+
}
175+
}

0 commit comments

Comments
 (0)