@chorus-one/polygon 1.0.3 → 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
@@ -216,7 +216,7 @@ console.log(status) // 'success', 'failure', or 'unknown'
216
216
 
217
217
  ## Key Features
218
218
 
219
- - **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).
220
220
  - **POL Token Staking**: Stake the native POL token (formerly MATIC) to validators
221
221
  - **Human-Readable Amounts**: Pass token amounts as strings (e.g., '1.5'), conversion to wei is handled automatically
222
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.
@@ -231,6 +231,19 @@ console.log(status) // 'success', 'failure', or 'unknown'
231
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.
232
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.
233
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
+ ```
234
247
 
235
248
  ## License
236
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
+ };
@@ -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
  }
@@ -95,7 +95,8 @@ class PolygonStaker {
95
95
  tx: {
96
96
  to: this.contracts.stakingTokenAddress,
97
97
  data,
98
- value: 0n
98
+ value: 0n,
99
+ chainId: this.chain.id
99
100
  }
100
101
  };
101
102
  }
@@ -128,7 +129,10 @@ class PolygonStaker {
128
129
  throw new Error('Cannot specify both slippageBps and minSharesToMint. Use one or the other.');
129
130
  }
130
131
  const amountWei = this.parseAmount(amount);
131
- const allowance = await this.getAllowance(delegatorAddress);
132
+ const [allowance] = await Promise.all([
133
+ this.getAllowance(delegatorAddress),
134
+ this.assertValidatorActive(validatorShareAddress)
135
+ ]);
132
136
  if ((0, viem_1.parseEther)(allowance) < amountWei) {
133
137
  throw new Error(`Insufficient POL allowance. Current: ${allowance}, Required: ${amount}. Call buildApproveTx() first.`);
134
138
  }
@@ -152,7 +156,8 @@ class PolygonStaker {
152
156
  tx: {
153
157
  to: validatorShareAddress,
154
158
  data: (0, referrer_1.appendReferrerTracking)(calldata, referrer),
155
- value: 0n
159
+ value: 0n,
160
+ chainId: this.chain.id
156
161
  }
157
162
  };
158
163
  }
@@ -206,7 +211,8 @@ class PolygonStaker {
206
211
  tx: {
207
212
  to: validatorShareAddress,
208
213
  data: (0, referrer_1.appendReferrerTracking)(calldata, referrer),
209
- value: 0n
214
+ value: 0n,
215
+ chainId: this.chain.id
210
216
  }
211
217
  };
212
218
  }
@@ -253,7 +259,8 @@ class PolygonStaker {
253
259
  tx: {
254
260
  to: validatorShareAddress,
255
261
  data,
256
- value: 0n
262
+ value: 0n,
263
+ chainId: this.chain.id
257
264
  }
258
265
  };
259
266
  }
@@ -289,7 +296,8 @@ class PolygonStaker {
289
296
  tx: {
290
297
  to: validatorShareAddress,
291
298
  data: (0, referrer_1.appendReferrerTracking)(calldata, referrer),
292
- value: 0n
299
+ value: 0n,
300
+ chainId: this.chain.id
293
301
  }
294
302
  };
295
303
  }
@@ -313,7 +321,10 @@ class PolygonStaker {
313
321
  if (!(0, viem_1.isAddress)(validatorShareAddress)) {
314
322
  throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
315
323
  }
316
- const rewards = await this.getLiquidRewards({ delegatorAddress, validatorShareAddress });
324
+ const [rewards] = await Promise.all([
325
+ this.getLiquidRewards({ delegatorAddress, validatorShareAddress }),
326
+ this.assertValidatorActive(validatorShareAddress)
327
+ ]);
317
328
  if ((0, viem_1.parseEther)(rewards) === 0n) {
318
329
  throw new Error('No rewards available to compound');
319
330
  }
@@ -325,7 +336,8 @@ class PolygonStaker {
325
336
  tx: {
326
337
  to: validatorShareAddress,
327
338
  data: (0, referrer_1.appendReferrerTracking)(calldata, referrer),
328
- value: 0n
339
+ value: 0n,
340
+ chainId: this.chain.id
329
341
  }
330
342
  };
331
343
  }
@@ -693,6 +705,26 @@ class PolygonStaker {
693
705
  };
694
706
  }
695
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
+ }
696
728
  parseAmount(amount) {
697
729
  if (typeof amount === 'bigint') {
698
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
+ };
@@ -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
  }
@@ -2,7 +2,7 @@ import { createPublicClient, http, encodeFunctionData, parseEther, formatEther,
2
2
  import { mainnet, sepolia } from 'viem/chains';
3
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
  *
@@ -92,7 +92,8 @@ export class PolygonStaker {
92
92
  tx: {
93
93
  to: this.contracts.stakingTokenAddress,
94
94
  data,
95
- value: 0n
95
+ value: 0n,
96
+ chainId: this.chain.id
96
97
  }
97
98
  };
98
99
  }
@@ -125,7 +126,10 @@ export class PolygonStaker {
125
126
  throw new Error('Cannot specify both slippageBps and minSharesToMint. Use one or the other.');
126
127
  }
127
128
  const amountWei = this.parseAmount(amount);
128
- const allowance = await this.getAllowance(delegatorAddress);
129
+ const [allowance] = await Promise.all([
130
+ this.getAllowance(delegatorAddress),
131
+ this.assertValidatorActive(validatorShareAddress)
132
+ ]);
129
133
  if (parseEther(allowance) < amountWei) {
130
134
  throw new Error(`Insufficient POL allowance. Current: ${allowance}, Required: ${amount}. Call buildApproveTx() first.`);
131
135
  }
@@ -149,7 +153,8 @@ export class PolygonStaker {
149
153
  tx: {
150
154
  to: validatorShareAddress,
151
155
  data: appendReferrerTracking(calldata, referrer),
152
- value: 0n
156
+ value: 0n,
157
+ chainId: this.chain.id
153
158
  }
154
159
  };
155
160
  }
@@ -203,7 +208,8 @@ export class PolygonStaker {
203
208
  tx: {
204
209
  to: validatorShareAddress,
205
210
  data: appendReferrerTracking(calldata, referrer),
206
- value: 0n
211
+ value: 0n,
212
+ chainId: this.chain.id
207
213
  }
208
214
  };
209
215
  }
@@ -250,7 +256,8 @@ export class PolygonStaker {
250
256
  tx: {
251
257
  to: validatorShareAddress,
252
258
  data,
253
- value: 0n
259
+ value: 0n,
260
+ chainId: this.chain.id
254
261
  }
255
262
  };
256
263
  }
@@ -286,7 +293,8 @@ export class PolygonStaker {
286
293
  tx: {
287
294
  to: validatorShareAddress,
288
295
  data: appendReferrerTracking(calldata, referrer),
289
- value: 0n
296
+ value: 0n,
297
+ chainId: this.chain.id
290
298
  }
291
299
  };
292
300
  }
@@ -310,7 +318,10 @@ export class PolygonStaker {
310
318
  if (!isAddress(validatorShareAddress)) {
311
319
  throw new Error(`Invalid validator share address: ${validatorShareAddress}`);
312
320
  }
313
- const rewards = await this.getLiquidRewards({ delegatorAddress, validatorShareAddress });
321
+ const [rewards] = await Promise.all([
322
+ this.getLiquidRewards({ delegatorAddress, validatorShareAddress }),
323
+ this.assertValidatorActive(validatorShareAddress)
324
+ ]);
314
325
  if (parseEther(rewards) === 0n) {
315
326
  throw new Error('No rewards available to compound');
316
327
  }
@@ -322,7 +333,8 @@ export class PolygonStaker {
322
333
  tx: {
323
334
  to: validatorShareAddress,
324
335
  data: appendReferrerTracking(calldata, referrer),
325
- value: 0n
336
+ value: 0n,
337
+ chainId: this.chain.id
326
338
  }
327
339
  };
328
340
  }
@@ -690,6 +702,26 @@ export class PolygonStaker {
690
702
  };
691
703
  }
692
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
+ }
693
725
  parseAmount(amount) {
694
726
  if (typeof amount === 'bigint') {
695
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.3",
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",
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
@@ -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'
@@ -130,7 +131,8 @@ export class PolygonStaker {
130
131
  tx: {
131
132
  to: this.contracts.stakingTokenAddress,
132
133
  data,
133
- value: 0n
134
+ value: 0n,
135
+ chainId: this.chain.id
134
136
  }
135
137
  }
136
138
  }
@@ -174,7 +176,10 @@ export class PolygonStaker {
174
176
 
175
177
  const amountWei = this.parseAmount(amount)
176
178
 
177
- const allowance = await this.getAllowance(delegatorAddress)
179
+ const [allowance] = await Promise.all([
180
+ this.getAllowance(delegatorAddress),
181
+ this.assertValidatorActive(validatorShareAddress)
182
+ ])
178
183
  if (parseEther(allowance) < amountWei) {
179
184
  throw new Error(
180
185
  `Insufficient POL allowance. Current: ${allowance}, Required: ${amount}. Call buildApproveTx() first.`
@@ -204,7 +209,8 @@ export class PolygonStaker {
204
209
  tx: {
205
210
  to: validatorShareAddress,
206
211
  data: appendReferrerTracking(calldata, referrer),
207
- value: 0n
212
+ value: 0n,
213
+ chainId: this.chain.id
208
214
  }
209
215
  }
210
216
  }
@@ -273,7 +279,8 @@ export class PolygonStaker {
273
279
  tx: {
274
280
  to: validatorShareAddress,
275
281
  data: appendReferrerTracking(calldata, referrer),
276
- value: 0n
282
+ value: 0n,
283
+ chainId: this.chain.id
277
284
  }
278
285
  }
279
286
  }
@@ -331,7 +338,8 @@ export class PolygonStaker {
331
338
  tx: {
332
339
  to: validatorShareAddress,
333
340
  data,
334
- value: 0n
341
+ value: 0n,
342
+ chainId: this.chain.id
335
343
  }
336
344
  }
337
345
  }
@@ -376,7 +384,8 @@ export class PolygonStaker {
376
384
  tx: {
377
385
  to: validatorShareAddress,
378
386
  data: appendReferrerTracking(calldata, referrer),
379
- value: 0n
387
+ value: 0n,
388
+ chainId: this.chain.id
380
389
  }
381
390
  }
382
391
  }
@@ -407,7 +416,10 @@ export class PolygonStaker {
407
416
  throw new Error(`Invalid validator share address: ${validatorShareAddress}`)
408
417
  }
409
418
 
410
- const rewards = await this.getLiquidRewards({ delegatorAddress, validatorShareAddress })
419
+ const [rewards] = await Promise.all([
420
+ this.getLiquidRewards({ delegatorAddress, validatorShareAddress }),
421
+ this.assertValidatorActive(validatorShareAddress)
422
+ ])
411
423
  if (parseEther(rewards) === 0n) {
412
424
  throw new Error('No rewards available to compound')
413
425
  }
@@ -421,7 +433,8 @@ export class PolygonStaker {
421
433
  tx: {
422
434
  to: validatorShareAddress,
423
435
  data: appendReferrerTracking(calldata, referrer),
424
- value: 0n
436
+ value: 0n,
437
+ chainId: this.chain.id
425
438
  }
426
439
  }
427
440
  }
@@ -855,6 +868,31 @@ export class PolygonStaker {
855
868
  }
856
869
  }
857
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
+
858
896
  private parseAmount (amount: string): bigint {
859
897
  if (typeof amount === 'bigint') {
860
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', () => {
@@ -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(