@chorus-one/polygon 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.json +6 -0
- package/LICENSE +13 -0
- package/README.md +233 -0
- package/dist/cjs/constants.d.ts +187 -0
- package/dist/cjs/constants.js +141 -0
- package/dist/cjs/index.d.ts +4 -0
- package/dist/cjs/index.js +12 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/referrer.d.ts +2 -0
- package/dist/cjs/referrer.js +15 -0
- package/dist/cjs/staker.d.ts +335 -0
- package/dist/cjs/staker.js +716 -0
- package/dist/cjs/types.d.ts +40 -0
- package/dist/cjs/types.js +2 -0
- package/dist/mjs/constants.d.ts +187 -0
- package/dist/mjs/constants.js +138 -0
- package/dist/mjs/index.d.ts +4 -0
- package/dist/mjs/index.js +2 -0
- package/dist/mjs/package.json +3 -0
- package/dist/mjs/referrer.d.ts +2 -0
- package/dist/mjs/referrer.js +11 -0
- package/dist/mjs/staker.d.ts +335 -0
- package/dist/mjs/staker.js +712 -0
- package/dist/mjs/types.d.ts +40 -0
- package/dist/mjs/types.js +1 -0
- package/hardhat.config.ts +27 -0
- package/package.json +50 -0
- package/src/constants.ts +151 -0
- package/src/index.ts +14 -0
- package/src/referrer.ts +15 -0
- package/src/staker.ts +878 -0
- package/src/types.ts +45 -0
- package/test/fixtures/expected-data.ts +17 -0
- package/test/integration/localSigner.spec.ts +128 -0
- package/test/integration/setup.ts +41 -0
- package/test/integration/staker.spec.ts +587 -0
- package/test/integration/testStaker.ts +130 -0
- package/test/integration/utils.ts +263 -0
- package/test/lib/networks.json +14 -0
- package/test/staker.spec.ts +154 -0
- package/tsconfig.cjs.json +9 -0
- package/tsconfig.json +13 -0
- package/tsconfig.mjs.json +9 -0
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { createPublicClient, http, encodeFunctionData, parseEther, formatEther, isAddress, keccak256, serializeTransaction, createWalletClient, maxUint256, erc20Abi } from 'viem';
|
|
2
|
+
import { mainnet, sepolia } from 'viem/chains';
|
|
3
|
+
import { secp256k1 } from '@noble/curves/secp256k1.js';
|
|
4
|
+
import { appendReferrerTracking } from './referrer';
|
|
5
|
+
import { VALIDATOR_SHARE_ABI, STAKE_MANAGER_ABI, NETWORK_CONTRACTS, EXCHANGE_RATE_PRECISION, EXCHANGE_RATE_HIGH_PRECISION } from './constants';
|
|
6
|
+
/**
|
|
7
|
+
* PolygonStaker - TypeScript SDK for Polygon PoS staking operations
|
|
8
|
+
*
|
|
9
|
+
* This class provides the functionality to stake (delegate), unstake, withdraw,
|
|
10
|
+
* claim rewards, and compound rewards on Polygon PoS via ValidatorShare contracts
|
|
11
|
+
* deployed on Ethereum L1.
|
|
12
|
+
*
|
|
13
|
+
* Built with viem for type-safety and modern patterns.
|
|
14
|
+
*
|
|
15
|
+
* ---
|
|
16
|
+
*
|
|
17
|
+
* **Referrer Tracking**
|
|
18
|
+
*
|
|
19
|
+
* Transaction builders that support referrer tracking (stake, unstake, claim rewards, compound)
|
|
20
|
+
* append a tracking marker to the transaction calldata. The marker format is `c1c1` followed by
|
|
21
|
+
* the first 3 bytes of the keccak256 hash of the referrer string. By default, `sdk-chorusone-staking`
|
|
22
|
+
* is used as the referrer.
|
|
23
|
+
*
|
|
24
|
+
* To extract the referrer from on-chain transactions, look for the `c1c1` prefix in the trailing
|
|
25
|
+
* bytes after the function calldata.
|
|
26
|
+
*/
|
|
27
|
+
const NETWORK_CHAINS = {
|
|
28
|
+
mainnet,
|
|
29
|
+
testnet: sepolia
|
|
30
|
+
};
|
|
31
|
+
export class PolygonStaker {
|
|
32
|
+
rpcUrl;
|
|
33
|
+
contracts;
|
|
34
|
+
publicClient;
|
|
35
|
+
chain;
|
|
36
|
+
withdrawalDelayCache = null;
|
|
37
|
+
validatorPrecisionCache = new Map();
|
|
38
|
+
/**
|
|
39
|
+
* This **static** method is used to derive an address from a public key.
|
|
40
|
+
*
|
|
41
|
+
* It can be used for signer initialization, e.g. `FireblocksSigner` or `LocalSigner`.
|
|
42
|
+
*
|
|
43
|
+
* @returns Returns an array containing the derived address.
|
|
44
|
+
*/
|
|
45
|
+
static getAddressDerivationFn = () => async (publicKey) => {
|
|
46
|
+
const pkUncompressed = secp256k1.Point.fromBytes(publicKey).toBytes(false);
|
|
47
|
+
const hash = keccak256(pkUncompressed.subarray(1));
|
|
48
|
+
const ethAddress = hash.slice(-40);
|
|
49
|
+
return [ethAddress];
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Creates a PolygonStaker instance
|
|
53
|
+
*
|
|
54
|
+
* @param params - Initialization configuration
|
|
55
|
+
* @param params.network - Network to use: 'mainnet' (Ethereum L1) or 'testnet' (Sepolia L1)
|
|
56
|
+
* @param params.rpcUrl - Optional RPC endpoint URL override. If not provided, uses viem's default for the network.
|
|
57
|
+
*
|
|
58
|
+
* @returns An instance of PolygonStaker
|
|
59
|
+
*/
|
|
60
|
+
constructor(params) {
|
|
61
|
+
this.rpcUrl = params.rpcUrl;
|
|
62
|
+
this.contracts = NETWORK_CONTRACTS[params.network];
|
|
63
|
+
this.chain = NETWORK_CHAINS[params.network];
|
|
64
|
+
this.publicClient = createPublicClient({
|
|
65
|
+
chain: this.chain,
|
|
66
|
+
transport: http(this.rpcUrl)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/** @deprecated No longer required. Kept for backward compatibility. */
|
|
70
|
+
async init() { }
|
|
71
|
+
/**
|
|
72
|
+
* Builds a token approval transaction
|
|
73
|
+
*
|
|
74
|
+
* Approves the StakeManager contract to spend POL tokens on behalf of the delegator.
|
|
75
|
+
* This must be called before staking if the current allowance is insufficient.
|
|
76
|
+
*
|
|
77
|
+
* @param params - Parameters for building the transaction
|
|
78
|
+
* @param params.amount - The amount to approve in POL (will be converted to wei internally). Pass "max" for unlimited approval.
|
|
79
|
+
*
|
|
80
|
+
* @returns Returns a promise that resolves to an approval transaction
|
|
81
|
+
*/
|
|
82
|
+
async buildApproveTx(params) {
|
|
83
|
+
const { amount } = params;
|
|
84
|
+
const amountWei = amount === 'max' ? maxUint256 : this.parseAmount(amount);
|
|
85
|
+
const data = encodeFunctionData({
|
|
86
|
+
abi: erc20Abi,
|
|
87
|
+
functionName: 'approve',
|
|
88
|
+
args: [this.contracts.stakeManagerAddress, amountWei]
|
|
89
|
+
});
|
|
90
|
+
return {
|
|
91
|
+
tx: {
|
|
92
|
+
to: this.contracts.stakingTokenAddress,
|
|
93
|
+
data,
|
|
94
|
+
value: 0n
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Builds a staking (delegation) transaction
|
|
100
|
+
*
|
|
101
|
+
* Delegates POL tokens to a validator via their ValidatorShare contract.
|
|
102
|
+
* Requires prior token approval to the StakeManager contract.
|
|
103
|
+
*
|
|
104
|
+
* @param params - Parameters for building the transaction
|
|
105
|
+
* @param params.delegatorAddress - The delegator's Ethereum address
|
|
106
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
107
|
+
* @param params.amount - The amount to stake in POL
|
|
108
|
+
* @param params.slippageBps - (Optional) Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint.
|
|
109
|
+
* @param params.minSharesToMint - (Optional) Minimum validator shares to receive. Use this OR slippageBps, not both.
|
|
110
|
+
* @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
|
|
111
|
+
*
|
|
112
|
+
* @returns Returns a promise that resolves to a Polygon staking transaction
|
|
113
|
+
*/
|
|
114
|
+
async buildStakeTx(params) {
|
|
115
|
+
const { delegatorAddress, validatorShareAddress, amount, slippageBps, referrer } = params;
|
|
116
|
+
let { minSharesToMint } = params;
|
|
117
|
+
if (!isAddress(delegatorAddress)) {
|
|
118
|
+
throw new Error(`Invalid delegator address: ${delegatorAddress}`);
|
|
119
|
+
}
|
|
120
|
+
if (!isAddress(validatorShareAddress)) {
|
|
121
|
+
throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
|
|
122
|
+
}
|
|
123
|
+
if (slippageBps !== undefined && minSharesToMint !== undefined) {
|
|
124
|
+
throw new Error('Cannot specify both slippageBps and minSharesToMint. Use one or the other.');
|
|
125
|
+
}
|
|
126
|
+
const amountWei = this.parseAmount(amount);
|
|
127
|
+
const allowance = await this.getAllowance(delegatorAddress);
|
|
128
|
+
if (parseEther(allowance) < amountWei) {
|
|
129
|
+
throw new Error(`Insufficient POL allowance. Current: ${allowance}, Required: ${amount}. Call buildApproveTx() first.`);
|
|
130
|
+
}
|
|
131
|
+
if (slippageBps !== undefined) {
|
|
132
|
+
const [exchangeRate, precision] = await Promise.all([
|
|
133
|
+
this.getExchangeRate(validatorShareAddress),
|
|
134
|
+
this.getExchangeRatePrecision(validatorShareAddress)
|
|
135
|
+
]);
|
|
136
|
+
const expectedShares = (amountWei * precision) / exchangeRate;
|
|
137
|
+
minSharesToMint = expectedShares - (expectedShares * BigInt(slippageBps)) / 10000n;
|
|
138
|
+
}
|
|
139
|
+
if (minSharesToMint === undefined) {
|
|
140
|
+
throw new Error('Either slippageBps or minSharesToMint must be provided');
|
|
141
|
+
}
|
|
142
|
+
const calldata = encodeFunctionData({
|
|
143
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
144
|
+
functionName: 'buyVoucherPOL',
|
|
145
|
+
args: [amountWei, minSharesToMint]
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
tx: {
|
|
149
|
+
to: validatorShareAddress,
|
|
150
|
+
data: appendReferrerTracking(calldata, referrer),
|
|
151
|
+
value: 0n
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Builds an unstaking transaction
|
|
157
|
+
*
|
|
158
|
+
* Creates an unbond request to unstake POL tokens from a validator.
|
|
159
|
+
* After the unbonding period (~80 checkpoints, approximately 3-4 days), call buildWithdrawTx() to claim funds.
|
|
160
|
+
*
|
|
161
|
+
* @param params - Parameters for building the transaction
|
|
162
|
+
* @param params.delegatorAddress - The delegator's address
|
|
163
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
164
|
+
* @param params.amount - The amount to unstake in POL (will be converted to wei internally)
|
|
165
|
+
* @param params.slippageBps - (Optional) Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn.
|
|
166
|
+
* @param params.maximumSharesToBurn - (Optional) Maximum validator shares willing to burn. Use this OR slippageBps, not both.
|
|
167
|
+
* @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
|
|
168
|
+
*
|
|
169
|
+
* @returns Returns a promise that resolves to a Polygon unstaking transaction
|
|
170
|
+
*/
|
|
171
|
+
async buildUnstakeTx(params) {
|
|
172
|
+
const { delegatorAddress, validatorShareAddress, amount, slippageBps, referrer } = params;
|
|
173
|
+
let { maximumSharesToBurn } = params;
|
|
174
|
+
if (!isAddress(delegatorAddress)) {
|
|
175
|
+
throw new Error(`Invalid delegator address: ${delegatorAddress}`);
|
|
176
|
+
}
|
|
177
|
+
if (!isAddress(validatorShareAddress)) {
|
|
178
|
+
throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
|
|
179
|
+
}
|
|
180
|
+
if (slippageBps !== undefined && maximumSharesToBurn !== undefined) {
|
|
181
|
+
throw new Error('Cannot specify both slippageBps and maximumSharesToBurn. Use one or the other.');
|
|
182
|
+
}
|
|
183
|
+
const amountWei = this.parseAmount(amount);
|
|
184
|
+
const stake = await this.getStake({ delegatorAddress, validatorShareAddress });
|
|
185
|
+
if (parseEther(stake.balance) < amountWei) {
|
|
186
|
+
throw new Error(`Insufficient stake. Current: ${stake.balance} POL, Requested: ${amount} POL`);
|
|
187
|
+
}
|
|
188
|
+
if (slippageBps !== undefined) {
|
|
189
|
+
const precision = await this.getExchangeRatePrecision(validatorShareAddress);
|
|
190
|
+
const expectedShares = (amountWei * precision) / stake.exchangeRate;
|
|
191
|
+
maximumSharesToBurn = expectedShares + (expectedShares * BigInt(slippageBps)) / 10000n;
|
|
192
|
+
}
|
|
193
|
+
if (maximumSharesToBurn === undefined) {
|
|
194
|
+
throw new Error('Either slippageBps or maximumSharesToBurn must be provided');
|
|
195
|
+
}
|
|
196
|
+
const calldata = encodeFunctionData({
|
|
197
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
198
|
+
functionName: 'sellVoucher_newPOL',
|
|
199
|
+
args: [amountWei, maximumSharesToBurn]
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
tx: {
|
|
203
|
+
to: validatorShareAddress,
|
|
204
|
+
data: appendReferrerTracking(calldata, referrer),
|
|
205
|
+
value: 0n
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Builds a withdraw transaction
|
|
211
|
+
*
|
|
212
|
+
* Claims unstaked POL tokens after the unbonding period has elapsed.
|
|
213
|
+
* Use getUnbond() to check if the unbonding period is complete.
|
|
214
|
+
*
|
|
215
|
+
* Note: Each unstake creates a separate unbond with its own nonce (1, 2, 3, etc.).
|
|
216
|
+
* Withdrawals must be done per-nonce. To withdraw all pending unbonds, iterate
|
|
217
|
+
* through nonces from 1 to getUnbondNonce() and withdraw each eligible one.
|
|
218
|
+
*
|
|
219
|
+
* @param params - Parameters for building the transaction
|
|
220
|
+
* @param params.delegatorAddress - The delegator's address that will receive the funds
|
|
221
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
222
|
+
* @param params.unbondNonce - The specific unbond nonce to withdraw
|
|
223
|
+
*
|
|
224
|
+
* @returns Returns a promise that resolves to a Polygon withdrawal transaction
|
|
225
|
+
*/
|
|
226
|
+
async buildWithdrawTx(params) {
|
|
227
|
+
const { delegatorAddress, validatorShareAddress, unbondNonce } = params;
|
|
228
|
+
if (!isAddress(delegatorAddress)) {
|
|
229
|
+
throw new Error(`Invalid delegator address: ${delegatorAddress}`);
|
|
230
|
+
}
|
|
231
|
+
if (!isAddress(validatorShareAddress)) {
|
|
232
|
+
throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
|
|
233
|
+
}
|
|
234
|
+
const unbond = await this.getUnbond({ delegatorAddress, validatorShareAddress, unbondNonce });
|
|
235
|
+
if (unbond.shares === 0n) {
|
|
236
|
+
throw new Error(`No unbond request found for nonce ${unbondNonce}`);
|
|
237
|
+
}
|
|
238
|
+
const [currentEpoch, withdrawalDelay] = await Promise.all([this.getEpoch(), this.getWithdrawalDelay()]);
|
|
239
|
+
const requiredEpoch = unbond.withdrawEpoch + withdrawalDelay;
|
|
240
|
+
if (currentEpoch < requiredEpoch) {
|
|
241
|
+
throw new Error(`Unbonding not complete. Current epoch: ${currentEpoch}, Required epoch: ${requiredEpoch}`);
|
|
242
|
+
}
|
|
243
|
+
const data = encodeFunctionData({
|
|
244
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
245
|
+
functionName: 'unstakeClaimTokens_newPOL',
|
|
246
|
+
args: [unbondNonce]
|
|
247
|
+
});
|
|
248
|
+
return {
|
|
249
|
+
tx: {
|
|
250
|
+
to: validatorShareAddress,
|
|
251
|
+
data,
|
|
252
|
+
value: 0n
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Builds a claim rewards transaction
|
|
258
|
+
*
|
|
259
|
+
* Claims accumulated delegation rewards and sends them to the delegator's wallet.
|
|
260
|
+
*
|
|
261
|
+
* @param params - Parameters for building the transaction
|
|
262
|
+
* @param params.delegatorAddress - The delegator's address that will receive the rewards
|
|
263
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
264
|
+
* @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
|
|
265
|
+
*
|
|
266
|
+
* @returns Returns a promise that resolves to a Polygon claim rewards transaction
|
|
267
|
+
*/
|
|
268
|
+
async buildClaimRewardsTx(params) {
|
|
269
|
+
const { delegatorAddress, validatorShareAddress, referrer } = params;
|
|
270
|
+
if (!isAddress(delegatorAddress)) {
|
|
271
|
+
throw new Error(`Invalid delegator address: ${delegatorAddress}`);
|
|
272
|
+
}
|
|
273
|
+
if (!isAddress(validatorShareAddress)) {
|
|
274
|
+
throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
|
|
275
|
+
}
|
|
276
|
+
const rewards = await this.getLiquidRewards({ delegatorAddress, validatorShareAddress });
|
|
277
|
+
if (parseEther(rewards) === 0n) {
|
|
278
|
+
throw new Error('No rewards available to claim');
|
|
279
|
+
}
|
|
280
|
+
const calldata = encodeFunctionData({
|
|
281
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
282
|
+
functionName: 'withdrawRewardsPOL'
|
|
283
|
+
});
|
|
284
|
+
return {
|
|
285
|
+
tx: {
|
|
286
|
+
to: validatorShareAddress,
|
|
287
|
+
data: appendReferrerTracking(calldata, referrer),
|
|
288
|
+
value: 0n
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Builds a compound (restake) rewards transaction
|
|
294
|
+
*
|
|
295
|
+
* Restakes accumulated rewards back into the validator, increasing delegation without new tokens.
|
|
296
|
+
*
|
|
297
|
+
* @param params - Parameters for building the transaction
|
|
298
|
+
* @param params.delegatorAddress - The delegator's address
|
|
299
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
300
|
+
* @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
|
|
301
|
+
*
|
|
302
|
+
* @returns Returns a promise that resolves to a Polygon compound transaction
|
|
303
|
+
*/
|
|
304
|
+
async buildCompoundTx(params) {
|
|
305
|
+
const { delegatorAddress, validatorShareAddress, referrer } = params;
|
|
306
|
+
if (!isAddress(delegatorAddress)) {
|
|
307
|
+
throw new Error(`Invalid delegator address: ${delegatorAddress}`);
|
|
308
|
+
}
|
|
309
|
+
if (!isAddress(validatorShareAddress)) {
|
|
310
|
+
throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
|
|
311
|
+
}
|
|
312
|
+
const rewards = await this.getLiquidRewards({ delegatorAddress, validatorShareAddress });
|
|
313
|
+
if (parseEther(rewards) === 0n) {
|
|
314
|
+
throw new Error('No rewards available to compound');
|
|
315
|
+
}
|
|
316
|
+
const calldata = encodeFunctionData({
|
|
317
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
318
|
+
functionName: 'restakePOL'
|
|
319
|
+
});
|
|
320
|
+
return {
|
|
321
|
+
tx: {
|
|
322
|
+
to: validatorShareAddress,
|
|
323
|
+
data: appendReferrerTracking(calldata, referrer),
|
|
324
|
+
value: 0n
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
// ========== QUERY METHODS ==========
|
|
329
|
+
/**
|
|
330
|
+
* Retrieves the delegator's staking information for a specific validator
|
|
331
|
+
*
|
|
332
|
+
* @param params - Parameters for the query
|
|
333
|
+
* @param params.delegatorAddress - Ethereum address of the delegator
|
|
334
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
335
|
+
*
|
|
336
|
+
* @returns Promise resolving to stake information:
|
|
337
|
+
* - balance: Total staked amount formatted in POL
|
|
338
|
+
* - shares: Total shares held by the delegator
|
|
339
|
+
* - exchangeRate: Current exchange rate between shares and POL
|
|
340
|
+
*/
|
|
341
|
+
async getStake(params) {
|
|
342
|
+
const { delegatorAddress, validatorShareAddress } = params;
|
|
343
|
+
const [balance, exchangeRate] = await this.publicClient.readContract({
|
|
344
|
+
address: validatorShareAddress,
|
|
345
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
346
|
+
functionName: 'getTotalStake',
|
|
347
|
+
args: [delegatorAddress]
|
|
348
|
+
});
|
|
349
|
+
const shares = await this.publicClient.readContract({
|
|
350
|
+
address: validatorShareAddress,
|
|
351
|
+
abi: erc20Abi,
|
|
352
|
+
functionName: 'balanceOf',
|
|
353
|
+
args: [delegatorAddress]
|
|
354
|
+
});
|
|
355
|
+
return { balance: formatEther(balance), shares, exchangeRate };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Retrieves the latest unbond nonce for a delegator
|
|
359
|
+
*
|
|
360
|
+
* Each unstake operation creates a new unbond request with an incrementing nonce.
|
|
361
|
+
* Nonces start at 1 and increment with each unstake.
|
|
362
|
+
* Note: a nonce having existed does not mean it is still pending —
|
|
363
|
+
* claimed unbonds are deleted, but the counter is never decremented.
|
|
364
|
+
*
|
|
365
|
+
* @param params - Parameters for the query
|
|
366
|
+
* @param params.delegatorAddress - Ethereum address of the delegator
|
|
367
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
368
|
+
*
|
|
369
|
+
* @returns Promise resolving to the latest unbond nonce (0n if no unstakes performed)
|
|
370
|
+
*/
|
|
371
|
+
async getUnbondNonce(params) {
|
|
372
|
+
return this.publicClient.readContract({
|
|
373
|
+
address: params.validatorShareAddress,
|
|
374
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
375
|
+
functionName: 'unbondNonces',
|
|
376
|
+
args: [params.delegatorAddress]
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Retrieves unbond request information for a specific nonce
|
|
381
|
+
*
|
|
382
|
+
* Use this to check the status of individual unbond requests.
|
|
383
|
+
* For fetching multiple unbonds efficiently, use getUnbonds() instead.
|
|
384
|
+
*
|
|
385
|
+
* @param params - Parameters for the query
|
|
386
|
+
* @param params.delegatorAddress - Ethereum address of the delegator
|
|
387
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
388
|
+
* @param params.unbondNonce - The specific unbond nonce to query (1, 2, 3, etc.)
|
|
389
|
+
*
|
|
390
|
+
* @returns Promise resolving to unbond information:
|
|
391
|
+
* - amount: Amount pending unbonding in POL
|
|
392
|
+
* - isWithdrawable: Whether the unbond can be withdrawn now
|
|
393
|
+
* - shares: Shares amount pending unbonding (0n if already withdrawn or doesn't exist)
|
|
394
|
+
* - withdrawEpoch: Epoch number when the unbond started
|
|
395
|
+
*/
|
|
396
|
+
async getUnbond(params) {
|
|
397
|
+
const { delegatorAddress, validatorShareAddress, unbondNonce } = params;
|
|
398
|
+
const [multicallResults, withdrawalDelay, precision] = await Promise.all([
|
|
399
|
+
this.publicClient.multicall({
|
|
400
|
+
contracts: [
|
|
401
|
+
{
|
|
402
|
+
address: validatorShareAddress,
|
|
403
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
404
|
+
functionName: 'unbonds_new',
|
|
405
|
+
args: [delegatorAddress, unbondNonce]
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
address: this.contracts.stakeManagerAddress,
|
|
409
|
+
abi: STAKE_MANAGER_ABI,
|
|
410
|
+
functionName: 'epoch'
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
address: validatorShareAddress,
|
|
414
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
415
|
+
functionName: 'withdrawExchangeRate'
|
|
416
|
+
}
|
|
417
|
+
]
|
|
418
|
+
}),
|
|
419
|
+
this.getWithdrawalDelay(),
|
|
420
|
+
this.getExchangeRatePrecision(validatorShareAddress)
|
|
421
|
+
]);
|
|
422
|
+
const [unbondResult, epochResult, withdrawRateResult] = multicallResults;
|
|
423
|
+
if (unbondResult.status === 'failure' ||
|
|
424
|
+
epochResult.status === 'failure' ||
|
|
425
|
+
withdrawRateResult.status === 'failure') {
|
|
426
|
+
throw new Error('Failed to fetch unbond information');
|
|
427
|
+
}
|
|
428
|
+
const [shares, withdrawEpoch] = unbondResult.result;
|
|
429
|
+
const currentEpoch = epochResult.result;
|
|
430
|
+
const withdrawExchangeRate = withdrawRateResult.result;
|
|
431
|
+
const amountWei = (shares * withdrawExchangeRate) / precision;
|
|
432
|
+
const amount = formatEther(amountWei);
|
|
433
|
+
const isWithdrawable = shares > 0n && currentEpoch >= withdrawEpoch + withdrawalDelay;
|
|
434
|
+
return { amount, isWithdrawable, shares, withdrawEpoch };
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Retrieves unbond request information for multiple nonces efficiently
|
|
438
|
+
*
|
|
439
|
+
* This method batches all contract reads into a single RPC call, making it
|
|
440
|
+
* much more efficient than calling getUnbond() multiple times.
|
|
441
|
+
*
|
|
442
|
+
* @param params - Parameters for the query
|
|
443
|
+
* @param params.delegatorAddress - Ethereum address of the delegator
|
|
444
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
445
|
+
* @param params.unbondNonces - Array of unbond nonces to query (1, 2, 3, etc.)
|
|
446
|
+
*
|
|
447
|
+
* @returns Promise resolving to array of unbond information (same order as input nonces)
|
|
448
|
+
*/
|
|
449
|
+
async getUnbonds(params) {
|
|
450
|
+
const { delegatorAddress, validatorShareAddress, unbondNonces } = params;
|
|
451
|
+
if (unbondNonces.length === 0) {
|
|
452
|
+
return [];
|
|
453
|
+
}
|
|
454
|
+
const unbondContracts = unbondNonces.map((nonce) => ({
|
|
455
|
+
address: validatorShareAddress,
|
|
456
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
457
|
+
functionName: 'unbonds_new',
|
|
458
|
+
args: [delegatorAddress, nonce]
|
|
459
|
+
}));
|
|
460
|
+
const [multicallResults, withdrawalDelay, precision] = await Promise.all([
|
|
461
|
+
this.publicClient.multicall({
|
|
462
|
+
contracts: [
|
|
463
|
+
...unbondContracts,
|
|
464
|
+
{
|
|
465
|
+
address: this.contracts.stakeManagerAddress,
|
|
466
|
+
abi: STAKE_MANAGER_ABI,
|
|
467
|
+
functionName: 'epoch'
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
address: validatorShareAddress,
|
|
471
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
472
|
+
functionName: 'withdrawExchangeRate'
|
|
473
|
+
}
|
|
474
|
+
]
|
|
475
|
+
}),
|
|
476
|
+
this.getWithdrawalDelay(),
|
|
477
|
+
this.getExchangeRatePrecision(validatorShareAddress)
|
|
478
|
+
]);
|
|
479
|
+
const epochResult = multicallResults[unbondNonces.length];
|
|
480
|
+
const withdrawRateResult = multicallResults[unbondNonces.length + 1];
|
|
481
|
+
if (epochResult.status === 'failure' || withdrawRateResult.status === 'failure') {
|
|
482
|
+
throw new Error('Failed to fetch epoch or exchange rate');
|
|
483
|
+
}
|
|
484
|
+
const currentEpoch = epochResult.result;
|
|
485
|
+
const withdrawExchangeRate = withdrawRateResult.result;
|
|
486
|
+
return unbondNonces.map((nonce, index) => {
|
|
487
|
+
const unbondResult = multicallResults[index];
|
|
488
|
+
if (unbondResult.status === 'failure') {
|
|
489
|
+
throw new Error(`Failed to fetch unbond for nonce ${nonce}`);
|
|
490
|
+
}
|
|
491
|
+
const [shares, withdrawEpoch] = unbondResult.result;
|
|
492
|
+
const amountWei = (shares * withdrawExchangeRate) / precision;
|
|
493
|
+
const amount = formatEther(amountWei);
|
|
494
|
+
const isWithdrawable = shares > 0n && currentEpoch >= withdrawEpoch + withdrawalDelay;
|
|
495
|
+
return { amount, isWithdrawable, shares, withdrawEpoch };
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* Retrieves pending liquid rewards for a delegator
|
|
500
|
+
*
|
|
501
|
+
* @param params - Parameters for the query
|
|
502
|
+
* @param params.delegatorAddress - Ethereum address of the delegator
|
|
503
|
+
* @param params.validatorShareAddress - The validator's ValidatorShare contract address
|
|
504
|
+
*
|
|
505
|
+
* @returns Promise resolving to the pending rewards in POL
|
|
506
|
+
*/
|
|
507
|
+
async getLiquidRewards(params) {
|
|
508
|
+
const rewards = await this.publicClient.readContract({
|
|
509
|
+
address: params.validatorShareAddress,
|
|
510
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
511
|
+
functionName: 'getLiquidRewards',
|
|
512
|
+
args: [params.delegatorAddress]
|
|
513
|
+
});
|
|
514
|
+
return formatEther(rewards);
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Retrieves the current POL allowance for the StakeManager contract
|
|
518
|
+
*
|
|
519
|
+
* @param ownerAddress - The token owner's address
|
|
520
|
+
*
|
|
521
|
+
* @returns Promise resolving to the current allowance in POL
|
|
522
|
+
*/
|
|
523
|
+
async getAllowance(ownerAddress) {
|
|
524
|
+
const allowance = await this.publicClient.readContract({
|
|
525
|
+
address: this.contracts.stakingTokenAddress,
|
|
526
|
+
abi: erc20Abi,
|
|
527
|
+
functionName: 'allowance',
|
|
528
|
+
args: [ownerAddress, this.contracts.stakeManagerAddress]
|
|
529
|
+
});
|
|
530
|
+
return formatEther(allowance);
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Retrieves the current checkpoint epoch from the StakeManager
|
|
534
|
+
*
|
|
535
|
+
* @returns Promise resolving to the current epoch number
|
|
536
|
+
*/
|
|
537
|
+
async getEpoch() {
|
|
538
|
+
return this.publicClient.readContract({
|
|
539
|
+
address: this.contracts.stakeManagerAddress,
|
|
540
|
+
abi: STAKE_MANAGER_ABI,
|
|
541
|
+
functionName: 'epoch'
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Retrieves the withdrawal delay from the StakeManager
|
|
546
|
+
*
|
|
547
|
+
* The withdrawal delay is the number of epochs that must pass after an unbond
|
|
548
|
+
* request before the funds can be withdrawn (~80 checkpoints, approximately 3-4 days).
|
|
549
|
+
*
|
|
550
|
+
* @returns Promise resolving to the withdrawal delay in epochs
|
|
551
|
+
*/
|
|
552
|
+
async getWithdrawalDelay() {
|
|
553
|
+
if (this.withdrawalDelayCache !== null) {
|
|
554
|
+
return this.withdrawalDelayCache;
|
|
555
|
+
}
|
|
556
|
+
const delay = await this.publicClient.readContract({
|
|
557
|
+
address: this.contracts.stakeManagerAddress,
|
|
558
|
+
abi: STAKE_MANAGER_ABI,
|
|
559
|
+
functionName: 'withdrawalDelay'
|
|
560
|
+
});
|
|
561
|
+
this.withdrawalDelayCache = delay;
|
|
562
|
+
return delay;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Retrieves the exchange rate precision for a validator
|
|
566
|
+
*
|
|
567
|
+
* Foundation validators (ID < 8) use precision of 100, others use 10^29.
|
|
568
|
+
*
|
|
569
|
+
* @param validatorShareAddress - The validator's ValidatorShare contract address
|
|
570
|
+
*
|
|
571
|
+
* @returns Promise resolving to the precision constant
|
|
572
|
+
*/
|
|
573
|
+
async getExchangeRatePrecision(validatorShareAddress) {
|
|
574
|
+
const cached = this.validatorPrecisionCache.get(validatorShareAddress);
|
|
575
|
+
if (cached !== undefined) {
|
|
576
|
+
return cached;
|
|
577
|
+
}
|
|
578
|
+
const validatorId = await this.publicClient.readContract({
|
|
579
|
+
address: validatorShareAddress,
|
|
580
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
581
|
+
functionName: 'validatorId'
|
|
582
|
+
});
|
|
583
|
+
const precision = validatorId < 8n ? EXCHANGE_RATE_PRECISION : EXCHANGE_RATE_HIGH_PRECISION;
|
|
584
|
+
this.validatorPrecisionCache.set(validatorShareAddress, precision);
|
|
585
|
+
return precision;
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Retrieves the current exchange rate for a validator
|
|
589
|
+
*
|
|
590
|
+
* @param validatorShareAddress - The validator's ValidatorShare contract address
|
|
591
|
+
*
|
|
592
|
+
* @returns Promise resolving to the exchange rate
|
|
593
|
+
*/
|
|
594
|
+
async getExchangeRate(validatorShareAddress) {
|
|
595
|
+
const [, exchangeRate] = await this.publicClient.readContract({
|
|
596
|
+
address: validatorShareAddress,
|
|
597
|
+
abi: VALIDATOR_SHARE_ABI,
|
|
598
|
+
functionName: 'getTotalStake',
|
|
599
|
+
args: [validatorShareAddress]
|
|
600
|
+
});
|
|
601
|
+
return exchangeRate;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Signs a transaction using the provided signer.
|
|
605
|
+
*
|
|
606
|
+
* @param params - Parameters for the signing process
|
|
607
|
+
* @param params.signer - A signer instance
|
|
608
|
+
* @param params.signerAddress - The address of the signer
|
|
609
|
+
* @param params.tx - The transaction to sign
|
|
610
|
+
* @param params.baseFeeMultiplier - (Optional) The multiplier for fees, which is used to manage fee fluctuations, is applied to the base fee per gas from the latest block to determine the final `maxFeePerGas`. The default value is 1.2
|
|
611
|
+
* @param params.defaultPriorityFee - (Optional) This overrides the `maxPriorityFeePerGas` estimated by the RPC
|
|
612
|
+
*
|
|
613
|
+
* @returns A promise that resolves to an object containing the signed transaction
|
|
614
|
+
*/
|
|
615
|
+
async sign(params) {
|
|
616
|
+
const { signer, signerAddress, tx, baseFeeMultiplier, defaultPriorityFee } = params;
|
|
617
|
+
const baseChain = this.chain;
|
|
618
|
+
const baseFees = baseChain.fees ?? {};
|
|
619
|
+
const fees = {
|
|
620
|
+
...baseFees,
|
|
621
|
+
baseFeeMultiplier: baseFeeMultiplier ?? baseFees.baseFeeMultiplier,
|
|
622
|
+
defaultPriorityFee: defaultPriorityFee === undefined ? baseFees.maxPriorityFeePerGas : parseEther(defaultPriorityFee)
|
|
623
|
+
};
|
|
624
|
+
const chain = {
|
|
625
|
+
...baseChain,
|
|
626
|
+
fees
|
|
627
|
+
};
|
|
628
|
+
const client = createWalletClient({
|
|
629
|
+
chain,
|
|
630
|
+
transport: http(this.rpcUrl),
|
|
631
|
+
account: signerAddress
|
|
632
|
+
});
|
|
633
|
+
const request = await client.prepareTransactionRequest({
|
|
634
|
+
chain: undefined,
|
|
635
|
+
account: signerAddress,
|
|
636
|
+
to: tx.to,
|
|
637
|
+
value: tx.value,
|
|
638
|
+
data: tx.data,
|
|
639
|
+
type: 'eip1559'
|
|
640
|
+
});
|
|
641
|
+
const message = keccak256(serializeTransaction(request)).slice(2);
|
|
642
|
+
const data = { tx };
|
|
643
|
+
const { sig } = await signer.sign(signerAddress.toLowerCase().slice(2), { message, data }, {});
|
|
644
|
+
const signature = {
|
|
645
|
+
r: `0x${sig.r}`,
|
|
646
|
+
s: `0x${sig.s}`,
|
|
647
|
+
v: sig.v ? 28n : 27n,
|
|
648
|
+
yParity: sig.v
|
|
649
|
+
};
|
|
650
|
+
const signedTx = serializeTransaction(request, signature);
|
|
651
|
+
return { signedTx };
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Broadcasts a signed transaction to the network.
|
|
655
|
+
*
|
|
656
|
+
* @param params - Parameters for the broadcast process
|
|
657
|
+
* @param params.signedTx - The signed transaction to broadcast
|
|
658
|
+
*
|
|
659
|
+
* @returns A promise that resolves to the transaction hash
|
|
660
|
+
*/
|
|
661
|
+
async broadcast(params) {
|
|
662
|
+
const { signedTx } = params;
|
|
663
|
+
const hash = await this.publicClient.sendRawTransaction({ serializedTransaction: signedTx });
|
|
664
|
+
return { txHash: hash };
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Retrieves the status of a transaction using the transaction hash.
|
|
668
|
+
*
|
|
669
|
+
* @param params - Parameters for the transaction status request
|
|
670
|
+
* @param params.txHash - The transaction hash to query
|
|
671
|
+
*
|
|
672
|
+
* @returns A promise that resolves to an object containing the transaction status
|
|
673
|
+
*/
|
|
674
|
+
async getTxStatus(params) {
|
|
675
|
+
const { txHash } = params;
|
|
676
|
+
try {
|
|
677
|
+
const tx = await this.publicClient.getTransactionReceipt({
|
|
678
|
+
hash: txHash
|
|
679
|
+
});
|
|
680
|
+
if (tx.status === 'reverted') {
|
|
681
|
+
return { status: 'failure', receipt: tx };
|
|
682
|
+
}
|
|
683
|
+
return { status: 'success', receipt: tx };
|
|
684
|
+
}
|
|
685
|
+
catch (e) {
|
|
686
|
+
return {
|
|
687
|
+
status: 'unknown',
|
|
688
|
+
receipt: null
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
parseAmount(amount) {
|
|
693
|
+
if (typeof amount === 'bigint') {
|
|
694
|
+
throw new Error('Amount must be a string, denominated in POL. e.g. "1.5" - 1.5 POL. You can use `formatEther` to convert a `bigint` to a string');
|
|
695
|
+
}
|
|
696
|
+
if (typeof amount !== 'string') {
|
|
697
|
+
throw new Error('Amount must be a string, denominated in POL. e.g. "1.5" - 1.5 POL.');
|
|
698
|
+
}
|
|
699
|
+
if (amount === '')
|
|
700
|
+
throw new Error('Amount cannot be empty');
|
|
701
|
+
let result;
|
|
702
|
+
try {
|
|
703
|
+
result = parseEther(amount);
|
|
704
|
+
}
|
|
705
|
+
catch (e) {
|
|
706
|
+
throw new Error('Amount must be a valid number denominated in POL. e.g. "1.5" - 1.5 POL');
|
|
707
|
+
}
|
|
708
|
+
if (result <= 0n)
|
|
709
|
+
throw new Error('Amount must be greater than 0');
|
|
710
|
+
return result;
|
|
711
|
+
}
|
|
712
|
+
}
|