@chorus-one/polygon 1.0.1 → 1.0.4

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/README.md CHANGED
@@ -99,7 +99,9 @@ const { txHash } = await staker.broadcast({ signedTx })
99
99
 
100
100
  ### Stake (Delegate) to Validator
101
101
 
102
- Delegate POL tokens to a validator via their ValidatorShare contract:
102
+ Delegate POL tokens to a validator via their ValidatorShare contract.
103
+
104
+ You must provide exactly one of `slippageBps` or `minSharesToMint` (not both). There is no default — omitting both will throw an error.
103
105
 
104
106
  ```javascript
105
107
  const { tx } = await staker.buildStakeTx({
@@ -121,7 +123,9 @@ const { txHash } = await staker.broadcast({ signedTx })
121
123
 
122
124
  ### Unstake (Unbond) from Validator
123
125
 
124
- Create an unbond request to unstake POL tokens. After the unbonding period (~80 epochs, approximately 3-4 days), call withdraw to claim funds:
126
+ Create an unbond request to unstake POL tokens. After the unbonding period (~80 epochs, approximately 3-4 days), call withdraw to claim funds.
127
+
128
+ You must provide exactly one of `slippageBps` or `maximumSharesToBurn` (not both). There is no default — omitting both will throw an error.
125
129
 
126
130
  ```javascript
127
131
  const { tx } = await staker.buildUnstakeTx({
@@ -212,10 +216,10 @@ console.log(status) // 'success', 'failure', or 'unknown'
212
216
 
213
217
  ## Key Features
214
218
 
215
- - **Ethereum L1 Based**: Polygon PoS staking operates via ValidatorShare contracts deployed on Ethereum mainnet (or Sepolia for testnet)
219
+ - **Ethereum L1 Based**: Polygon PoS staking operates via ValidatorShare contracts deployed on Ethereum mainnet (or Sepolia for testnet). Every transaction includes a `chainId` field so you know exactly which chain to broadcast on (`1` for mainnet, `11155111` for Sepolia).
216
220
  - **POL Token Staking**: Stake the native POL token (formerly MATIC) to validators
217
221
  - **Human-Readable Amounts**: Pass token amounts as strings (e.g., '1.5'), conversion to wei is handled automatically
218
- - **Slippage Protection**: Stake and unstake operations support `slippageBps` (basis points) for automatic slippage calculation, or manual `minSharesToMint`/`maximumSharesToBurn` parameters
222
+ - **Slippage Protection**: Stake and unstake operations require either `slippageBps` (basis points) for automatic slippage calculation, or manual `minSharesToMint`/`maximumSharesToBurn` parameters. Exactly one must be provided — there is no default.
219
223
  - **Query Methods**: Read stake balances, rewards, allowances, unbond status (with POL amount and withdrawability), and epoch information
220
224
  - **Rewards Management**: Claim rewards to wallet or compound them back into your delegation
221
225
 
@@ -227,6 +231,19 @@ console.log(status) // 'success', 'failure', or 'unknown'
227
231
  - **Referrer Tracking**: Transaction builders that support referrer tracking (stake, unstake, claim rewards, compound) append a tracking marker to the transaction calldata. By default, `sdk-chorusone-staking` is used as the referrer. You can provide a custom referrer via the `referrer` parameter.
228
232
  - **Exchange Rate**: The exchange rate between shares and POL may fluctuate. Use `slippageBps` for automatic slippage protection, or specify `minSharesToMint`/`maximumSharesToBurn` directly. Foundation validators (ID < 8) use different precision than others.
229
233
  - **Validator Share Contracts**: Each validator has their own ValidatorShare contract address. You must specify the correct contract address for the validator you want to delegate to.
234
+ - **Testnet Validators**: Testnet validators on Sepolia may be locked or inactive. To list all active ValidatorShare addresses, use [Foundry's](https://book.getfoundry.sh/) `cast`:
235
+
236
+ ```bash
237
+ export ETH_RPC_URL="https://ethereum-sepolia-rpc.publicnode.com"
238
+ SM="0x4AE8f648B1Ec892B6cc68C89cc088583964d08bE"
239
+ count=$(cast call $SM "NFTCounter()(uint256)")
240
+ for id in $(seq 1 $((count - 1))); do
241
+ raw=$(cast call $SM "validators(uint256)(uint256,uint256,uint256,uint256,uint256,address,address,uint8,uint256,uint256,uint256,uint256,uint256)" $id 2>/dev/null)
242
+ contract=$(echo "$raw" | sed -n 7p)
243
+ st=$(echo "$raw" | sed -n 8p)
244
+ [ "$st" = "1" ] && echo $contract
245
+ done
246
+ ```
230
247
 
231
248
  ## License
232
249
 
@@ -184,4 +184,59 @@ export declare const STAKE_MANAGER_ABI: readonly [{
184
184
  readonly internalType: "uint256";
185
185
  }];
186
186
  readonly stateMutability: "view";
187
+ }, {
188
+ readonly type: "function";
189
+ readonly name: "validators";
190
+ readonly inputs: readonly [{
191
+ readonly name: "";
192
+ readonly type: "uint256";
193
+ readonly internalType: "uint256";
194
+ }];
195
+ readonly outputs: readonly [{
196
+ readonly name: "amount";
197
+ readonly type: "uint256";
198
+ }, {
199
+ readonly name: "reward";
200
+ readonly type: "uint256";
201
+ }, {
202
+ readonly name: "activationEpoch";
203
+ readonly type: "uint256";
204
+ }, {
205
+ readonly name: "deactivationEpoch";
206
+ readonly type: "uint256";
207
+ }, {
208
+ readonly name: "jailTime";
209
+ readonly type: "uint256";
210
+ }, {
211
+ readonly name: "signer";
212
+ readonly type: "address";
213
+ }, {
214
+ readonly name: "contractAddress";
215
+ readonly type: "address";
216
+ }, {
217
+ readonly name: "status";
218
+ readonly type: "uint256";
219
+ }, {
220
+ readonly name: "commissionRate";
221
+ readonly type: "uint256";
222
+ }, {
223
+ readonly name: "lastCommissionUpdate";
224
+ readonly type: "uint256";
225
+ }, {
226
+ readonly name: "delegatorsReward";
227
+ readonly type: "uint256";
228
+ }, {
229
+ readonly name: "delegatedAmount";
230
+ readonly type: "uint256";
231
+ }, {
232
+ readonly name: "initialRewardPerStake";
233
+ readonly type: "uint256";
234
+ }];
235
+ readonly stateMutability: "view";
187
236
  }];
237
+ export declare const VALIDATOR_STATUS: {
238
+ readonly 0: "Inactive";
239
+ readonly 1: "Active";
240
+ readonly 2: "Locked";
241
+ readonly 3: "Unstaked";
242
+ };
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.STAKE_MANAGER_ABI = exports.EXCHANGE_RATE_HIGH_PRECISION = exports.EXCHANGE_RATE_PRECISION = exports.VALIDATOR_SHARE_ABI = exports.CHORUS_ONE_POLYGON_VALIDATORS = exports.NETWORK_CONTRACTS = void 0;
3
+ exports.VALIDATOR_STATUS = exports.STAKE_MANAGER_ABI = exports.EXCHANGE_RATE_HIGH_PRECISION = exports.EXCHANGE_RATE_PRECISION = exports.VALIDATOR_SHARE_ABI = exports.CHORUS_ONE_POLYGON_VALIDATORS = exports.NETWORK_CONTRACTS = void 0;
4
4
  /** Contract addresses per network (mainnet = Ethereum L1, testnet = Sepolia L1) */
5
5
  // Reference: https://docs.polygon.technology/pos/reference/rpc-endpoints/
6
6
  exports.NETWORK_CONTRACTS = {
@@ -15,10 +15,12 @@ exports.NETWORK_CONTRACTS = {
15
15
  };
16
16
  /** Chorus One Polygon ValidatorShare contract addresses */
17
17
  // Reference mainnet: https://staking.polygon.technology/validators/106
18
- // Reference testnet (Random Validator): https://staking.polygon.technology/validators/31
18
+ // Reference testnet: https://staking.polygon.technology/validators/31
19
+ // Note: Testnet validators may be locked. To list active validators, query the StakeManager
20
+ // contract on Sepolia: https://sepolia.etherscan.io/address/0x4AE8f648B1Ec892B6cc68C89cc088583964d08bE
19
21
  exports.CHORUS_ONE_POLYGON_VALIDATORS = {
20
22
  mainnet: '0xD9E6987D77bf2c6d0647b8181fd68A259f838C36',
21
- testnet: '0x91344055cb0511b3aa36c561d741ee356b95f1c9'
23
+ testnet: '0xe50f5ad9b885675fd11d8204eb01c83a8a32a91d'
22
24
  };
23
25
  // Reference: https://github.com/0xPolygon/pos-contracts/blob/main/contracts/staking/validatorShare/ValidatorShare.sol
24
26
  exports.VALIDATOR_SHARE_ABI = [
@@ -137,5 +139,32 @@ exports.STAKE_MANAGER_ABI = [
137
139
  inputs: [],
138
140
  outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
139
141
  stateMutability: 'view'
142
+ },
143
+ {
144
+ type: 'function',
145
+ name: 'validators',
146
+ inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
147
+ outputs: [
148
+ { name: 'amount', type: 'uint256' },
149
+ { name: 'reward', type: 'uint256' },
150
+ { name: 'activationEpoch', type: 'uint256' },
151
+ { name: 'deactivationEpoch', type: 'uint256' },
152
+ { name: 'jailTime', type: 'uint256' },
153
+ { name: 'signer', type: 'address' },
154
+ { name: 'contractAddress', type: 'address' },
155
+ { name: 'status', type: 'uint256' },
156
+ { name: 'commissionRate', type: 'uint256' },
157
+ { name: 'lastCommissionUpdate', type: 'uint256' },
158
+ { name: 'delegatorsReward', type: 'uint256' },
159
+ { name: 'delegatedAmount', type: 'uint256' },
160
+ { name: 'initialRewardPerStake', type: 'uint256' }
161
+ ],
162
+ stateMutability: 'view'
140
163
  }
141
164
  ];
165
+ exports.VALIDATOR_STATUS = {
166
+ 0: 'Inactive',
167
+ 1: 'Active',
168
+ 2: 'Locked',
169
+ 3: 'Unstaked'
170
+ };
@@ -54,8 +54,8 @@ export declare class PolygonStaker {
54
54
  * @param params.delegatorAddress - The delegator's Ethereum address
55
55
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
56
56
  * @param params.amount - The amount to stake in POL
57
- * @param params.slippageBps - (Optional) Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint.
58
- * @param params.minSharesToMint - (Optional) Minimum validator shares to receive. Use this OR slippageBps, not both.
57
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
58
+ * @param params.minSharesToMint - Minimum validator shares to receive. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
59
59
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
60
60
  *
61
61
  * @returns Returns a promise that resolves to a Polygon staking transaction
@@ -80,8 +80,8 @@ export declare class PolygonStaker {
80
80
  * @param params.delegatorAddress - The delegator's address
81
81
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
82
82
  * @param params.amount - The amount to unstake in POL (will be converted to wei internally)
83
- * @param params.slippageBps - (Optional) Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn.
84
- * @param params.maximumSharesToBurn - (Optional) Maximum validator shares willing to burn. Use this OR slippageBps, not both.
83
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
84
+ * @param params.maximumSharesToBurn - Maximum validator shares willing to burn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
85
85
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
86
86
  *
87
87
  * @returns Returns a promise that resolves to a Polygon unstaking transaction
@@ -331,5 +331,6 @@ export declare class PolygonStaker {
331
331
  getTxStatus(params: {
332
332
  txHash: Hex;
333
333
  }): Promise<PolygonTxStatus>;
334
+ private assertValidatorActive;
334
335
  private parseAmount;
335
336
  }
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PolygonStaker = void 0;
4
4
  const viem_1 = require("viem");
5
5
  const chains_1 = require("viem/chains");
6
- const secp256k1_js_1 = require("@noble/curves/secp256k1.js");
6
+ const secp256k1_1 = require("@noble/curves/secp256k1");
7
7
  const referrer_1 = require("./referrer");
8
8
  const constants_1 = require("./constants");
9
9
  /**
@@ -46,7 +46,8 @@ class PolygonStaker {
46
46
  * @returns Returns an array containing the derived address.
47
47
  */
48
48
  static getAddressDerivationFn = () => async (publicKey) => {
49
- const pkUncompressed = secp256k1_js_1.secp256k1.Point.fromBytes(publicKey).toBytes(false);
49
+ const point = secp256k1_1.secp256k1.Point.fromHex(publicKey);
50
+ const pkUncompressed = point.toBytes(false);
50
51
  const hash = (0, viem_1.keccak256)(pkUncompressed.subarray(1));
51
52
  const ethAddress = hash.slice(-40);
52
53
  return [ethAddress];
@@ -94,7 +95,8 @@ class PolygonStaker {
94
95
  tx: {
95
96
  to: this.contracts.stakingTokenAddress,
96
97
  data,
97
- value: 0n
98
+ value: 0n,
99
+ chainId: this.chain.id
98
100
  }
99
101
  };
100
102
  }
@@ -108,8 +110,8 @@ class PolygonStaker {
108
110
  * @param params.delegatorAddress - The delegator's Ethereum address
109
111
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
110
112
  * @param params.amount - The amount to stake in POL
111
- * @param params.slippageBps - (Optional) Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint.
112
- * @param params.minSharesToMint - (Optional) Minimum validator shares to receive. Use this OR slippageBps, not both.
113
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
114
+ * @param params.minSharesToMint - Minimum validator shares to receive. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
113
115
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
114
116
  *
115
117
  * @returns Returns a promise that resolves to a Polygon staking transaction
@@ -127,7 +129,10 @@ class PolygonStaker {
127
129
  throw new Error('Cannot specify both slippageBps and minSharesToMint. Use one or the other.');
128
130
  }
129
131
  const amountWei = this.parseAmount(amount);
130
- const allowance = await this.getAllowance(delegatorAddress);
132
+ const [allowance] = await Promise.all([
133
+ this.getAllowance(delegatorAddress),
134
+ this.assertValidatorActive(validatorShareAddress)
135
+ ]);
131
136
  if ((0, viem_1.parseEther)(allowance) < amountWei) {
132
137
  throw new Error(`Insufficient POL allowance. Current: ${allowance}, Required: ${amount}. Call buildApproveTx() first.`);
133
138
  }
@@ -151,7 +156,8 @@ class PolygonStaker {
151
156
  tx: {
152
157
  to: validatorShareAddress,
153
158
  data: (0, referrer_1.appendReferrerTracking)(calldata, referrer),
154
- value: 0n
159
+ value: 0n,
160
+ chainId: this.chain.id
155
161
  }
156
162
  };
157
163
  }
@@ -165,8 +171,8 @@ class PolygonStaker {
165
171
  * @param params.delegatorAddress - The delegator's address
166
172
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
167
173
  * @param params.amount - The amount to unstake in POL (will be converted to wei internally)
168
- * @param params.slippageBps - (Optional) Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn.
169
- * @param params.maximumSharesToBurn - (Optional) Maximum validator shares willing to burn. Use this OR slippageBps, not both.
174
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
175
+ * @param params.maximumSharesToBurn - Maximum validator shares willing to burn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
170
176
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
171
177
  *
172
178
  * @returns Returns a promise that resolves to a Polygon unstaking transaction
@@ -205,7 +211,8 @@ class PolygonStaker {
205
211
  tx: {
206
212
  to: validatorShareAddress,
207
213
  data: (0, referrer_1.appendReferrerTracking)(calldata, referrer),
208
- value: 0n
214
+ value: 0n,
215
+ chainId: this.chain.id
209
216
  }
210
217
  };
211
218
  }
@@ -252,7 +259,8 @@ class PolygonStaker {
252
259
  tx: {
253
260
  to: validatorShareAddress,
254
261
  data,
255
- value: 0n
262
+ value: 0n,
263
+ chainId: this.chain.id
256
264
  }
257
265
  };
258
266
  }
@@ -288,7 +296,8 @@ class PolygonStaker {
288
296
  tx: {
289
297
  to: validatorShareAddress,
290
298
  data: (0, referrer_1.appendReferrerTracking)(calldata, referrer),
291
- value: 0n
299
+ value: 0n,
300
+ chainId: this.chain.id
292
301
  }
293
302
  };
294
303
  }
@@ -312,7 +321,10 @@ class PolygonStaker {
312
321
  if (!(0, viem_1.isAddress)(validatorShareAddress)) {
313
322
  throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
314
323
  }
315
- const rewards = await this.getLiquidRewards({ delegatorAddress, validatorShareAddress });
324
+ const [rewards] = await Promise.all([
325
+ this.getLiquidRewards({ delegatorAddress, validatorShareAddress }),
326
+ this.assertValidatorActive(validatorShareAddress)
327
+ ]);
316
328
  if ((0, viem_1.parseEther)(rewards) === 0n) {
317
329
  throw new Error('No rewards available to compound');
318
330
  }
@@ -324,7 +336,8 @@ class PolygonStaker {
324
336
  tx: {
325
337
  to: validatorShareAddress,
326
338
  data: (0, referrer_1.appendReferrerTracking)(calldata, referrer),
327
- value: 0n
339
+ value: 0n,
340
+ chainId: this.chain.id
328
341
  }
329
342
  };
330
343
  }
@@ -692,6 +705,26 @@ class PolygonStaker {
692
705
  };
693
706
  }
694
707
  }
708
+ async assertValidatorActive(validatorShareAddress) {
709
+ const validatorId = await this.publicClient.readContract({
710
+ address: validatorShareAddress,
711
+ abi: constants_1.VALIDATOR_SHARE_ABI,
712
+ functionName: 'validatorId'
713
+ });
714
+ const validator = await this.publicClient.readContract({
715
+ address: this.contracts.stakeManagerAddress,
716
+ abi: constants_1.STAKE_MANAGER_ABI,
717
+ functionName: 'validators',
718
+ args: [validatorId]
719
+ });
720
+ const status = Number(validator[7]);
721
+ if (status !== 1) {
722
+ const statusName = constants_1.VALIDATOR_STATUS[status] ?? 'Unknown';
723
+ throw new Error(`Validator is not active (status: ${statusName}). ` +
724
+ 'The validator may be locked, inactive, or unstaked. ' +
725
+ 'See the @chorus-one/polygon README for how to list active validators on testnet.');
726
+ }
727
+ }
695
728
  parseAmount(amount) {
696
729
  if (typeof amount === 'bigint') {
697
730
  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');
@@ -13,6 +13,14 @@ export interface Transaction {
13
13
  data: Hex;
14
14
  /** The amount of ETH (in wei) to be sent with the transaction */
15
15
  value?: bigint;
16
+ /**
17
+ * The chain ID where the transaction must be broadcast.
18
+ *
19
+ * Polygon staking contracts live on Ethereum L1, not on the Polygon chain itself:
20
+ * - mainnet: `1` (Ethereum Mainnet)
21
+ * - testnet: `11155111` (Sepolia)
22
+ */
23
+ chainId: number;
16
24
  }
17
25
  export interface PolygonTxStatus {
18
26
  /** Status of the transaction */
@@ -184,4 +184,59 @@ export declare const STAKE_MANAGER_ABI: readonly [{
184
184
  readonly internalType: "uint256";
185
185
  }];
186
186
  readonly stateMutability: "view";
187
+ }, {
188
+ readonly type: "function";
189
+ readonly name: "validators";
190
+ readonly inputs: readonly [{
191
+ readonly name: "";
192
+ readonly type: "uint256";
193
+ readonly internalType: "uint256";
194
+ }];
195
+ readonly outputs: readonly [{
196
+ readonly name: "amount";
197
+ readonly type: "uint256";
198
+ }, {
199
+ readonly name: "reward";
200
+ readonly type: "uint256";
201
+ }, {
202
+ readonly name: "activationEpoch";
203
+ readonly type: "uint256";
204
+ }, {
205
+ readonly name: "deactivationEpoch";
206
+ readonly type: "uint256";
207
+ }, {
208
+ readonly name: "jailTime";
209
+ readonly type: "uint256";
210
+ }, {
211
+ readonly name: "signer";
212
+ readonly type: "address";
213
+ }, {
214
+ readonly name: "contractAddress";
215
+ readonly type: "address";
216
+ }, {
217
+ readonly name: "status";
218
+ readonly type: "uint256";
219
+ }, {
220
+ readonly name: "commissionRate";
221
+ readonly type: "uint256";
222
+ }, {
223
+ readonly name: "lastCommissionUpdate";
224
+ readonly type: "uint256";
225
+ }, {
226
+ readonly name: "delegatorsReward";
227
+ readonly type: "uint256";
228
+ }, {
229
+ readonly name: "delegatedAmount";
230
+ readonly type: "uint256";
231
+ }, {
232
+ readonly name: "initialRewardPerStake";
233
+ readonly type: "uint256";
234
+ }];
235
+ readonly stateMutability: "view";
187
236
  }];
237
+ export declare const VALIDATOR_STATUS: {
238
+ readonly 0: "Inactive";
239
+ readonly 1: "Active";
240
+ readonly 2: "Locked";
241
+ readonly 3: "Unstaked";
242
+ };
@@ -12,10 +12,12 @@ export const NETWORK_CONTRACTS = {
12
12
  };
13
13
  /** Chorus One Polygon ValidatorShare contract addresses */
14
14
  // Reference mainnet: https://staking.polygon.technology/validators/106
15
- // Reference testnet (Random Validator): https://staking.polygon.technology/validators/31
15
+ // Reference testnet: https://staking.polygon.technology/validators/31
16
+ // Note: Testnet validators may be locked. To list active validators, query the StakeManager
17
+ // contract on Sepolia: https://sepolia.etherscan.io/address/0x4AE8f648B1Ec892B6cc68C89cc088583964d08bE
16
18
  export const CHORUS_ONE_POLYGON_VALIDATORS = {
17
19
  mainnet: '0xD9E6987D77bf2c6d0647b8181fd68A259f838C36',
18
- testnet: '0x91344055cb0511b3aa36c561d741ee356b95f1c9'
20
+ testnet: '0xe50f5ad9b885675fd11d8204eb01c83a8a32a91d'
19
21
  };
20
22
  // Reference: https://github.com/0xPolygon/pos-contracts/blob/main/contracts/staking/validatorShare/ValidatorShare.sol
21
23
  export const VALIDATOR_SHARE_ABI = [
@@ -134,5 +136,32 @@ export const STAKE_MANAGER_ABI = [
134
136
  inputs: [],
135
137
  outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
136
138
  stateMutability: 'view'
139
+ },
140
+ {
141
+ type: 'function',
142
+ name: 'validators',
143
+ inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
144
+ outputs: [
145
+ { name: 'amount', type: 'uint256' },
146
+ { name: 'reward', type: 'uint256' },
147
+ { name: 'activationEpoch', type: 'uint256' },
148
+ { name: 'deactivationEpoch', type: 'uint256' },
149
+ { name: 'jailTime', type: 'uint256' },
150
+ { name: 'signer', type: 'address' },
151
+ { name: 'contractAddress', type: 'address' },
152
+ { name: 'status', type: 'uint256' },
153
+ { name: 'commissionRate', type: 'uint256' },
154
+ { name: 'lastCommissionUpdate', type: 'uint256' },
155
+ { name: 'delegatorsReward', type: 'uint256' },
156
+ { name: 'delegatedAmount', type: 'uint256' },
157
+ { name: 'initialRewardPerStake', type: 'uint256' }
158
+ ],
159
+ stateMutability: 'view'
137
160
  }
138
161
  ];
162
+ export const VALIDATOR_STATUS = {
163
+ 0: 'Inactive',
164
+ 1: 'Active',
165
+ 2: 'Locked',
166
+ 3: 'Unstaked'
167
+ };
@@ -54,8 +54,8 @@ export declare class PolygonStaker {
54
54
  * @param params.delegatorAddress - The delegator's Ethereum address
55
55
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
56
56
  * @param params.amount - The amount to stake in POL
57
- * @param params.slippageBps - (Optional) Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint.
58
- * @param params.minSharesToMint - (Optional) Minimum validator shares to receive. Use this OR slippageBps, not both.
57
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
58
+ * @param params.minSharesToMint - Minimum validator shares to receive. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
59
59
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
60
60
  *
61
61
  * @returns Returns a promise that resolves to a Polygon staking transaction
@@ -80,8 +80,8 @@ export declare class PolygonStaker {
80
80
  * @param params.delegatorAddress - The delegator's address
81
81
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
82
82
  * @param params.amount - The amount to unstake in POL (will be converted to wei internally)
83
- * @param params.slippageBps - (Optional) Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn.
84
- * @param params.maximumSharesToBurn - (Optional) Maximum validator shares willing to burn. Use this OR slippageBps, not both.
83
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
84
+ * @param params.maximumSharesToBurn - Maximum validator shares willing to burn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
85
85
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
86
86
  *
87
87
  * @returns Returns a promise that resolves to a Polygon unstaking transaction
@@ -331,5 +331,6 @@ export declare class PolygonStaker {
331
331
  getTxStatus(params: {
332
332
  txHash: Hex;
333
333
  }): Promise<PolygonTxStatus>;
334
+ private assertValidatorActive;
334
335
  private parseAmount;
335
336
  }
@@ -1,8 +1,8 @@
1
1
  import { createPublicClient, http, encodeFunctionData, parseEther, formatEther, isAddress, keccak256, serializeTransaction, createWalletClient, maxUint256, erc20Abi } from 'viem';
2
2
  import { mainnet, sepolia } from 'viem/chains';
3
- import { secp256k1 } from '@noble/curves/secp256k1.js';
3
+ import { secp256k1 } from '@noble/curves/secp256k1';
4
4
  import { appendReferrerTracking } from './referrer';
5
- import { VALIDATOR_SHARE_ABI, STAKE_MANAGER_ABI, NETWORK_CONTRACTS, EXCHANGE_RATE_PRECISION, EXCHANGE_RATE_HIGH_PRECISION } from './constants';
5
+ import { VALIDATOR_SHARE_ABI, STAKE_MANAGER_ABI, NETWORK_CONTRACTS, EXCHANGE_RATE_PRECISION, EXCHANGE_RATE_HIGH_PRECISION, VALIDATOR_STATUS } from './constants';
6
6
  /**
7
7
  * PolygonStaker - TypeScript SDK for Polygon PoS staking operations
8
8
  *
@@ -43,7 +43,8 @@ export class PolygonStaker {
43
43
  * @returns Returns an array containing the derived address.
44
44
  */
45
45
  static getAddressDerivationFn = () => async (publicKey) => {
46
- const pkUncompressed = secp256k1.Point.fromBytes(publicKey).toBytes(false);
46
+ const point = secp256k1.Point.fromHex(publicKey);
47
+ const pkUncompressed = point.toBytes(false);
47
48
  const hash = keccak256(pkUncompressed.subarray(1));
48
49
  const ethAddress = hash.slice(-40);
49
50
  return [ethAddress];
@@ -91,7 +92,8 @@ export class PolygonStaker {
91
92
  tx: {
92
93
  to: this.contracts.stakingTokenAddress,
93
94
  data,
94
- value: 0n
95
+ value: 0n,
96
+ chainId: this.chain.id
95
97
  }
96
98
  };
97
99
  }
@@ -105,8 +107,8 @@ export class PolygonStaker {
105
107
  * @param params.delegatorAddress - The delegator's Ethereum address
106
108
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
107
109
  * @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.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
111
+ * @param params.minSharesToMint - Minimum validator shares to receive. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
110
112
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
111
113
  *
112
114
  * @returns Returns a promise that resolves to a Polygon staking transaction
@@ -124,7 +126,10 @@ export class PolygonStaker {
124
126
  throw new Error('Cannot specify both slippageBps and minSharesToMint. Use one or the other.');
125
127
  }
126
128
  const amountWei = this.parseAmount(amount);
127
- const allowance = await this.getAllowance(delegatorAddress);
129
+ const [allowance] = await Promise.all([
130
+ this.getAllowance(delegatorAddress),
131
+ this.assertValidatorActive(validatorShareAddress)
132
+ ]);
128
133
  if (parseEther(allowance) < amountWei) {
129
134
  throw new Error(`Insufficient POL allowance. Current: ${allowance}, Required: ${amount}. Call buildApproveTx() first.`);
130
135
  }
@@ -148,7 +153,8 @@ export class PolygonStaker {
148
153
  tx: {
149
154
  to: validatorShareAddress,
150
155
  data: appendReferrerTracking(calldata, referrer),
151
- value: 0n
156
+ value: 0n,
157
+ chainId: this.chain.id
152
158
  }
153
159
  };
154
160
  }
@@ -162,8 +168,8 @@ export class PolygonStaker {
162
168
  * @param params.delegatorAddress - The delegator's address
163
169
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
164
170
  * @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.
171
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
172
+ * @param params.maximumSharesToBurn - Maximum validator shares willing to burn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
167
173
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
168
174
  *
169
175
  * @returns Returns a promise that resolves to a Polygon unstaking transaction
@@ -202,7 +208,8 @@ export class PolygonStaker {
202
208
  tx: {
203
209
  to: validatorShareAddress,
204
210
  data: appendReferrerTracking(calldata, referrer),
205
- value: 0n
211
+ value: 0n,
212
+ chainId: this.chain.id
206
213
  }
207
214
  };
208
215
  }
@@ -249,7 +256,8 @@ export class PolygonStaker {
249
256
  tx: {
250
257
  to: validatorShareAddress,
251
258
  data,
252
- value: 0n
259
+ value: 0n,
260
+ chainId: this.chain.id
253
261
  }
254
262
  };
255
263
  }
@@ -285,7 +293,8 @@ export class PolygonStaker {
285
293
  tx: {
286
294
  to: validatorShareAddress,
287
295
  data: appendReferrerTracking(calldata, referrer),
288
- value: 0n
296
+ value: 0n,
297
+ chainId: this.chain.id
289
298
  }
290
299
  };
291
300
  }
@@ -309,7 +318,10 @@ export class PolygonStaker {
309
318
  if (!isAddress(validatorShareAddress)) {
310
319
  throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
311
320
  }
312
- const rewards = await this.getLiquidRewards({ delegatorAddress, validatorShareAddress });
321
+ const [rewards] = await Promise.all([
322
+ this.getLiquidRewards({ delegatorAddress, validatorShareAddress }),
323
+ this.assertValidatorActive(validatorShareAddress)
324
+ ]);
313
325
  if (parseEther(rewards) === 0n) {
314
326
  throw new Error('No rewards available to compound');
315
327
  }
@@ -321,7 +333,8 @@ export class PolygonStaker {
321
333
  tx: {
322
334
  to: validatorShareAddress,
323
335
  data: appendReferrerTracking(calldata, referrer),
324
- value: 0n
336
+ value: 0n,
337
+ chainId: this.chain.id
325
338
  }
326
339
  };
327
340
  }
@@ -689,6 +702,26 @@ export class PolygonStaker {
689
702
  };
690
703
  }
691
704
  }
705
+ async assertValidatorActive(validatorShareAddress) {
706
+ const validatorId = await this.publicClient.readContract({
707
+ address: validatorShareAddress,
708
+ abi: VALIDATOR_SHARE_ABI,
709
+ functionName: 'validatorId'
710
+ });
711
+ const validator = await this.publicClient.readContract({
712
+ address: this.contracts.stakeManagerAddress,
713
+ abi: STAKE_MANAGER_ABI,
714
+ functionName: 'validators',
715
+ args: [validatorId]
716
+ });
717
+ const status = Number(validator[7]);
718
+ if (status !== 1) {
719
+ const statusName = VALIDATOR_STATUS[status] ?? 'Unknown';
720
+ throw new Error(`Validator is not active (status: ${statusName}). ` +
721
+ 'The validator may be locked, inactive, or unstaked. ' +
722
+ 'See the @chorus-one/polygon README for how to list active validators on testnet.');
723
+ }
724
+ }
692
725
  parseAmount(amount) {
693
726
  if (typeof amount === 'bigint') {
694
727
  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');
@@ -13,6 +13,14 @@ export interface Transaction {
13
13
  data: Hex;
14
14
  /** The amount of ETH (in wei) to be sent with the transaction */
15
15
  value?: bigint;
16
+ /**
17
+ * The chain ID where the transaction must be broadcast.
18
+ *
19
+ * Polygon staking contracts live on Ethereum L1, not on the Polygon chain itself:
20
+ * - mainnet: `1` (Ethereum Mainnet)
21
+ * - testnet: `11155111` (Sepolia)
22
+ */
23
+ chainId: number;
16
24
  }
17
25
  export interface PolygonTxStatus {
18
26
  /** Status of the transaction */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chorus-one/polygon",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "All-in-one toolkit for building staking dApps on Polygon network",
5
5
  "scripts": {
6
6
  "build": "rm -fr dist/* && tsc -p tsconfig.mjs.json --outDir dist/mjs && tsc -p tsconfig.cjs.json --outDir dist/cjs && bash ../../scripts/fix-package-json",
@@ -38,7 +38,7 @@
38
38
  "type": "module",
39
39
  "dependencies": {
40
40
  "@chorus-one/signer": "^1.0.0",
41
- "@noble/curves": "^2.0.1",
41
+ "@noble/curves": "^1.9.2",
42
42
  "viem": "^2.28.0"
43
43
  },
44
44
  "devDependencies": {
package/src/constants.ts CHANGED
@@ -22,10 +22,12 @@ export const NETWORK_CONTRACTS: Record<PolygonNetworks, NetworkContracts> = {
22
22
 
23
23
  /** Chorus One Polygon ValidatorShare contract addresses */
24
24
  // Reference mainnet: https://staking.polygon.technology/validators/106
25
- // Reference testnet (Random Validator): https://staking.polygon.technology/validators/31
25
+ // Reference testnet: https://staking.polygon.technology/validators/31
26
+ // Note: Testnet validators may be locked. To list active validators, query the StakeManager
27
+ // contract on Sepolia: https://sepolia.etherscan.io/address/0x4AE8f648B1Ec892B6cc68C89cc088583964d08bE
26
28
  export const CHORUS_ONE_POLYGON_VALIDATORS = {
27
29
  mainnet: '0xD9E6987D77bf2c6d0647b8181fd68A259f838C36' as Address,
28
- testnet: '0x91344055cb0511b3aa36c561d741ee356b95f1c9' as Address
30
+ testnet: '0xe50f5ad9b885675fd11d8204eb01c83a8a32a91d' as Address
29
31
  } as const
30
32
 
31
33
  // Reference: https://github.com/0xPolygon/pos-contracts/blob/main/contracts/staking/validatorShare/ValidatorShare.sol
@@ -147,5 +149,33 @@ export const STAKE_MANAGER_ABI = [
147
149
  inputs: [],
148
150
  outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
149
151
  stateMutability: 'view'
152
+ },
153
+ {
154
+ type: 'function',
155
+ name: 'validators',
156
+ inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
157
+ outputs: [
158
+ { name: 'amount', type: 'uint256' },
159
+ { name: 'reward', type: 'uint256' },
160
+ { name: 'activationEpoch', type: 'uint256' },
161
+ { name: 'deactivationEpoch', type: 'uint256' },
162
+ { name: 'jailTime', type: 'uint256' },
163
+ { name: 'signer', type: 'address' },
164
+ { name: 'contractAddress', type: 'address' },
165
+ { name: 'status', type: 'uint256' },
166
+ { name: 'commissionRate', type: 'uint256' },
167
+ { name: 'lastCommissionUpdate', type: 'uint256' },
168
+ { name: 'delegatorsReward', type: 'uint256' },
169
+ { name: 'delegatedAmount', type: 'uint256' },
170
+ { name: 'initialRewardPerStake', type: 'uint256' }
171
+ ],
172
+ stateMutability: 'view'
150
173
  }
151
174
  ] as const
175
+
176
+ export const VALIDATOR_STATUS = {
177
+ 0: 'Inactive',
178
+ 1: 'Active',
179
+ 2: 'Locked',
180
+ 3: 'Unstaked'
181
+ } as const
package/src/staker.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  type Chain
17
17
  } from 'viem'
18
18
  import { mainnet, sepolia } from 'viem/chains'
19
- import { secp256k1 } from '@noble/curves/secp256k1.js'
19
+ import { secp256k1 } from '@noble/curves/secp256k1'
20
20
  import type { Signer } from '@chorus-one/signer'
21
21
  import type { Transaction, PolygonNetworkConfig, PolygonTxStatus, StakeInfo, UnbondInfo } from './types'
22
22
  import { appendReferrerTracking } from './referrer'
@@ -26,6 +26,7 @@ import {
26
26
  NETWORK_CONTRACTS,
27
27
  EXCHANGE_RATE_PRECISION,
28
28
  EXCHANGE_RATE_HIGH_PRECISION,
29
+ VALIDATOR_STATUS,
29
30
  type PolygonNetworks,
30
31
  type NetworkContracts
31
32
  } from './constants'
@@ -75,7 +76,8 @@ export class PolygonStaker {
75
76
  static getAddressDerivationFn =
76
77
  () =>
77
78
  async (publicKey: Uint8Array): Promise<Array<string>> => {
78
- const pkUncompressed = secp256k1.Point.fromBytes(publicKey).toBytes(false)
79
+ const point = secp256k1.Point.fromHex(publicKey)
80
+ const pkUncompressed = point.toBytes(false)
79
81
  const hash = keccak256(pkUncompressed.subarray(1))
80
82
  const ethAddress = hash.slice(-40)
81
83
  return [ethAddress]
@@ -129,7 +131,8 @@ export class PolygonStaker {
129
131
  tx: {
130
132
  to: this.contracts.stakingTokenAddress,
131
133
  data,
132
- value: 0n
134
+ value: 0n,
135
+ chainId: this.chain.id
133
136
  }
134
137
  }
135
138
  }
@@ -144,8 +147,8 @@ export class PolygonStaker {
144
147
  * @param params.delegatorAddress - The delegator's Ethereum address
145
148
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
146
149
  * @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.
150
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate minSharesToMint. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
151
+ * @param params.minSharesToMint - Minimum validator shares to receive. Exactly one of `slippageBps` or `minSharesToMint` must be provided (not both, no default).
149
152
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
150
153
  *
151
154
  * @returns Returns a promise that resolves to a Polygon staking transaction
@@ -173,7 +176,10 @@ export class PolygonStaker {
173
176
 
174
177
  const amountWei = this.parseAmount(amount)
175
178
 
176
- const allowance = await this.getAllowance(delegatorAddress)
179
+ const [allowance] = await Promise.all([
180
+ this.getAllowance(delegatorAddress),
181
+ this.assertValidatorActive(validatorShareAddress)
182
+ ])
177
183
  if (parseEther(allowance) < amountWei) {
178
184
  throw new Error(
179
185
  `Insufficient POL allowance. Current: ${allowance}, Required: ${amount}. Call buildApproveTx() first.`
@@ -203,7 +209,8 @@ export class PolygonStaker {
203
209
  tx: {
204
210
  to: validatorShareAddress,
205
211
  data: appendReferrerTracking(calldata, referrer),
206
- value: 0n
212
+ value: 0n,
213
+ chainId: this.chain.id
207
214
  }
208
215
  }
209
216
  }
@@ -218,8 +225,8 @@ export class PolygonStaker {
218
225
  * @param params.delegatorAddress - The delegator's address
219
226
  * @param params.validatorShareAddress - The validator's ValidatorShare contract address
220
227
  * @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.
228
+ * @param params.slippageBps - Slippage tolerance in basis points (e.g., 50 = 0.5%). Used to calculate maximumSharesToBurn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
229
+ * @param params.maximumSharesToBurn - Maximum validator shares willing to burn. Exactly one of `slippageBps` or `maximumSharesToBurn` must be provided (not both, no default).
223
230
  * @param params.referrer - (Optional) Custom referrer string for tracking. If not provided, uses 'sdk-chorusone-staking'.
224
231
  *
225
232
  * @returns Returns a promise that resolves to a Polygon unstaking transaction
@@ -272,7 +279,8 @@ export class PolygonStaker {
272
279
  tx: {
273
280
  to: validatorShareAddress,
274
281
  data: appendReferrerTracking(calldata, referrer),
275
- value: 0n
282
+ value: 0n,
283
+ chainId: this.chain.id
276
284
  }
277
285
  }
278
286
  }
@@ -330,7 +338,8 @@ export class PolygonStaker {
330
338
  tx: {
331
339
  to: validatorShareAddress,
332
340
  data,
333
- value: 0n
341
+ value: 0n,
342
+ chainId: this.chain.id
334
343
  }
335
344
  }
336
345
  }
@@ -375,7 +384,8 @@ export class PolygonStaker {
375
384
  tx: {
376
385
  to: validatorShareAddress,
377
386
  data: appendReferrerTracking(calldata, referrer),
378
- value: 0n
387
+ value: 0n,
388
+ chainId: this.chain.id
379
389
  }
380
390
  }
381
391
  }
@@ -406,7 +416,10 @@ export class PolygonStaker {
406
416
  throw new Error(`Invalid validator share address: ${validatorShareAddress}`)
407
417
  }
408
418
 
409
- const rewards = await this.getLiquidRewards({ delegatorAddress, validatorShareAddress })
419
+ const [rewards] = await Promise.all([
420
+ this.getLiquidRewards({ delegatorAddress, validatorShareAddress }),
421
+ this.assertValidatorActive(validatorShareAddress)
422
+ ])
410
423
  if (parseEther(rewards) === 0n) {
411
424
  throw new Error('No rewards available to compound')
412
425
  }
@@ -420,7 +433,8 @@ export class PolygonStaker {
420
433
  tx: {
421
434
  to: validatorShareAddress,
422
435
  data: appendReferrerTracking(calldata, referrer),
423
- value: 0n
436
+ value: 0n,
437
+ chainId: this.chain.id
424
438
  }
425
439
  }
426
440
  }
@@ -854,6 +868,31 @@ export class PolygonStaker {
854
868
  }
855
869
  }
856
870
 
871
+ private async assertValidatorActive (validatorShareAddress: Address): Promise<void> {
872
+ const validatorId = await this.publicClient.readContract({
873
+ address: validatorShareAddress,
874
+ abi: VALIDATOR_SHARE_ABI,
875
+ functionName: 'validatorId'
876
+ })
877
+
878
+ const validator = await this.publicClient.readContract({
879
+ address: this.contracts.stakeManagerAddress,
880
+ abi: STAKE_MANAGER_ABI,
881
+ functionName: 'validators',
882
+ args: [validatorId]
883
+ })
884
+
885
+ const status = Number(validator[7])
886
+ if (status !== 1) {
887
+ const statusName = VALIDATOR_STATUS[status as keyof typeof VALIDATOR_STATUS] ?? 'Unknown'
888
+ throw new Error(
889
+ `Validator is not active (status: ${statusName}). ` +
890
+ 'The validator may be locked, inactive, or unstaked. ' +
891
+ 'See the @chorus-one/polygon README for how to list active validators on testnet.'
892
+ )
893
+ }
894
+ }
895
+
857
896
  private parseAmount (amount: string): bigint {
858
897
  if (typeof amount === 'bigint') {
859
898
  throw new Error(
package/src/types.ts CHANGED
@@ -15,6 +15,14 @@ export interface Transaction {
15
15
  data: Hex
16
16
  /** The amount of ETH (in wei) to be sent with the transaction */
17
17
  value?: bigint
18
+ /**
19
+ * The chain ID where the transaction must be broadcast.
20
+ *
21
+ * Polygon staking contracts live on Ethereum L1, not on the Polygon chain itself:
22
+ * - mainnet: `1` (Ethereum Mainnet)
23
+ * - testnet: `11155111` (Sepolia)
24
+ */
25
+ chainId: number
18
26
  }
19
27
 
20
28
  export interface PolygonTxStatus {
@@ -12,6 +12,7 @@ export const EXPECTED_APPROVE_TX = {
12
12
  to: stakingTokenAddress as Address,
13
13
  // approve(address spender, uint256 amount) with spender = stakeManagerAddress (0x5e3e...), amount = 100e18
14
14
  data: '0x095ea7b30000000000000000000000005e3ef299fddf15eaa0432e6e66473ace8c13d9080000000000000000000000000000000000000000000000056bc75e2d63100000' as Hex,
15
- value: 0n
15
+ value: 0n,
16
+ chainId: 1
16
17
  }
17
18
  }
@@ -93,6 +93,34 @@ describe('PolygonStaker', () => {
93
93
  const precision = await staker.getExchangeRatePrecision(validatorShareAddress)
94
94
  assert.equal(precision, EXCHANGE_RATE_HIGH_PRECISION)
95
95
  })
96
+
97
+ it('rejects staking to an unstaked validator', async () => {
98
+ const unstakedValidator = '0xd245710936382e5ED099d4F8D9AAE64e67e30EF3' as Address
99
+ await expect(
100
+ staker.buildStakeTx({
101
+ delegatorAddress: WHALE_DELEGATOR,
102
+ validatorShareAddress: unstakedValidator,
103
+ amount: '100',
104
+ minSharesToMint: 0n
105
+ })
106
+ ).to.be.rejectedWith('Validator is not active (status: Unstaked)')
107
+ })
108
+
109
+ it('allows staking to an active validator', async () => {
110
+ // Should not throw — the default validatorShareAddress is active
111
+ const allowance = await staker.getAllowance(WHALE_DELEGATOR)
112
+ // Will fail on allowance, not on validator status
113
+ if (parseEther(allowance) === 0n) {
114
+ await expect(
115
+ staker.buildStakeTx({
116
+ delegatorAddress: WHALE_DELEGATOR,
117
+ validatorShareAddress,
118
+ amount: '100',
119
+ minSharesToMint: 0n
120
+ })
121
+ ).to.be.rejectedWith('Insufficient POL allowance')
122
+ }
123
+ })
96
124
  })
97
125
 
98
126
  describe('staking lifecycle', () => {
@@ -2,7 +2,7 @@
2
2
  "networks": {
3
3
  "ethereum": {
4
4
  "name": "ethereum",
5
- "url": "https://ethereum-rpc.publicnode.com"
5
+ "url": "https://eth.drpc.org"
6
6
  }
7
7
  },
8
8
  "accounts": [
@@ -27,6 +27,7 @@ describe('PolygonStaker', () => {
27
27
  assert.equal(tx.to, EXPECTED_APPROVE_TX.expected.to)
28
28
  assert.equal(tx.data, EXPECTED_APPROVE_TX.expected.data)
29
29
  assert.equal(tx.value, EXPECTED_APPROVE_TX.expected.value)
30
+ assert.equal(tx.chainId, EXPECTED_APPROVE_TX.expected.chainId)
30
31
  })
31
32
 
32
33
  it('should generate correct unsigned approve tx for max (unlimited) amount', async () => {
@@ -50,6 +51,22 @@ describe('PolygonStaker', () => {
50
51
  })
51
52
  })
52
53
 
54
+ describe('chainId', () => {
55
+ it('should return mainnet chainId (1) for mainnet network', async () => {
56
+ const { tx } = await staker.buildApproveTx({ amount: '100' })
57
+ assert.equal(tx.chainId, 1)
58
+ })
59
+
60
+ it('should return sepolia chainId (11155111) for testnet network', async () => {
61
+ const testnetStaker = new PolygonStaker({
62
+ network: 'testnet',
63
+ rpcUrl: 'https://ethereum-sepolia-rpc.publicnode.com'
64
+ })
65
+ const { tx } = await testnetStaker.buildApproveTx({ amount: '100' })
66
+ assert.equal(tx.chainId, 11155111)
67
+ })
68
+ })
69
+
53
70
  describe('address validation', () => {
54
71
  it('should reject invalid delegator address in buildStakeTx', async () => {
55
72
  await expect(