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