From 4fa52b497ec7c595cb5aeb99a835d01ea4347ac1 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 11 Aug 2026 12:08:33 +0100 Subject: [PATCH 1/3] Reapply "fix: support proof-bearing validator joins (#206)" (#207) This reverts commit 7666f0b73987aa75b7fcd1d62c2759364ce36824. --- README.md | 8 +- docs/api-references/index.md | 8 +- docs/api-references/staking.md | 2 +- .../types.Interface.ValidatorJoinOptions.md | 13 ++- src/abi/staking.ts | 12 +- src/abi/vesting.ts | 12 +- src/index.ts | 15 ++- src/staking/actions.ts | 68 ++++++++--- src/types/staking.ts | 4 +- src/types/vesting.ts | 23 +++- src/vesting/actions.ts | 40 ++++++- src/vesting/operatorRegistration.ts | 110 ++++++++++++++++++ src/vesting/validator.ts | 12 ++ tests/operator-registration.test.ts | 73 ++++++++++++ tests/staking-actions.test.ts | 45 ++++++- tests/vesting-actions.test.ts | 30 ++++- 16 files changed, 426 insertions(+), 49 deletions(-) create mode 100644 src/vesting/operatorRegistration.ts create mode 100644 tests/operator-registration.test.ts diff --git a/README.md b/README.md index 39934b9..fd9aa57 100644 --- a/README.md +++ b/README.md @@ -437,8 +437,12 @@ const isValidator = await client.isValidator("0x..."); // Get validator info const validatorInfo = await client.getValidatorInfo("0x..."); -// Join as validator (requires account with funds) -const result = await client.validatorJoin({ amount: "42000gen" }); +// Join as validator (requires an owner account with funds and the operator key) +const registration = await createOperatorRegistration({ + privateKey: operatorPrivateKey, + ...(await client.getValidatorRegistrationContext()), +}); +const result = await client.validatorJoin({ amount: "42000gen", registration }); // Join as delegator const delegateResult = await client.delegatorJoin({ diff --git a/docs/api-references/index.md b/docs/api-references/index.md index 232f6bc..c08ef14 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -204,8 +204,12 @@ const isValidator = await client.isValidator("0x..."); // Get validator info const validatorInfo = await client.getValidatorInfo("0x..."); -// Join as validator (requires account with funds) -const result = await client.validatorJoin({ amount: "42000gen" }); +// Join as validator (requires an owner account with funds and the operator key) +const registration = await createOperatorRegistration({ + privateKey: operatorPrivateKey, + ...(await client.getValidatorRegistrationContext()), +}); +const result = await client.validatorJoin({ amount: "42000gen", registration }); // Join as delegator const delegateResult = await client.delegatorJoin({ diff --git a/docs/api-references/staking.md b/docs/api-references/staking.md index 17f05a8..da70e2c 100644 --- a/docs/api-references/staking.md +++ b/docs/api-references/staking.md @@ -9,7 +9,7 @@ Joins as a validator with the specified stake amount. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | amount | `bigint \| string` | yes | | -| operator | `Address` | no | | +| registration | `OperatorRegistrationProof` | yes | | **Returns:** `ValidatorJoinResult` diff --git a/docs/api-references/types.Interface.ValidatorJoinOptions.md b/docs/api-references/types.Interface.ValidatorJoinOptions.md index 3406f19..20518ac 100644 --- a/docs/api-references/types.Interface.ValidatorJoinOptions.md +++ b/docs/api-references/types.Interface.ValidatorJoinOptions.md @@ -1,6 +1,6 @@ # Interface: ValidatorJoinOptions -Defined in: [types/staking.ts:152](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L152) +Defined in: `src/types/staking.ts` ## Properties @@ -8,12 +8,15 @@ Defined in: [types/staking.ts:152](https://github.com/genlayerlabs/genlayer-js/b > **amount**: `string` \| `bigint` -Defined in: [types/staking.ts:153](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L153) +Defined in: `src/types/staking.ts` *** -### operator? +### registration -> `optional` **operator?**: `` `0x${string}` `` +> **registration**: `OperatorRegistrationProof` -Defined in: [types/staking.ts:154](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/staking.ts#L154) +Proof-of-possession package bound to this chain, the validator wallet factory, +and the joining owner address. + +Defined in: `src/types/staking.ts` diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 4d25966..ad6e6a7 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -1249,14 +1249,10 @@ export const STAKING_ABI = [ name: "validatorJoin", type: "function", stateMutability: "payable", - inputs: [{name: "_operator", type: "address"}], - outputs: [{name: "", type: "address"}], - }, - { - name: "validatorJoin", - type: "function", - stateMutability: "payable", - inputs: [], + inputs: [ + {name: "_operatorPubKey", type: "uint256[2]"}, + {name: "_possessionProof", type: "bytes"}, + ], outputs: [{name: "", type: "address"}], }, { diff --git a/src/abi/vesting.ts b/src/abi/vesting.ts index 0dc7adc..f874813 100644 --- a/src/abi/vesting.ts +++ b/src/abi/vesting.ts @@ -135,7 +135,17 @@ export const VESTING_ABI = [ {name: "vestingDelegatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingDelegatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, {name: "vestingDelegatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "validator", type: "address"}], outputs: []}, - {name: "vestingValidatorJoin", type: "function", stateMutability: "nonpayable", inputs: [{name: "operator", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, + { + name: "vestingValidatorJoin", + type: "function", + stateMutability: "nonpayable", + inputs: [ + {name: "operatorPubKey", type: "uint256[2]"}, + {name: "possessionProof", type: "bytes"}, + {name: "amount", type: "uint256"}, + ], + outputs: [], + }, {name: "vestingValidatorDeposit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "amount", type: "uint256"}], outputs: []}, {name: "vestingValidatorExit", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}, {name: "shares", type: "uint256"}], outputs: []}, {name: "vestingValidatorClaim", type: "function", stateMutability: "nonpayable", inputs: [{name: "wallet", type: "address"}], outputs: []}, diff --git a/src/index.ts b/src/index.ts index fe3a8f9..7e0b13d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,5 +12,18 @@ export * as abi from "./abi"; export * from "./transactions/fees"; export {isSuccessful} from "./transactions/actions"; export {parseStakingAmount, formatStakingAmount} from "./staking"; -export {vestingActions} from "./vesting"; +export { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorAddressFromPublicKey, + operatorPossessionMessage, + verifyOperatorRegistration, + vestingActions, +} from "./vesting"; +export type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "./vesting"; export {buildGenVmPositionalArgs} from "./contracts/schema"; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index a96f86c..d6eea76 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,7 +1,9 @@ -import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError} from "viem"; +import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError, zeroAddress} from "viem"; import {GenLayerClient, GenLayerChain, Address} from "@/types"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; +import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; +import {operatorAddressFromPublicKey, verifyOperatorRegistration} from "@/vesting/operatorRegistration"; import { ValidatorInfo, ValidatorIdentity, @@ -32,6 +34,7 @@ type WalletClientWithAccount = Client; const FALLBACK_GAS = 1000000n; const GAS_BUFFER_MULTIPLIER = 2n; +const VALIDATOR_WALLET_FACTORY_KEY = "ValidatorWalletFactory"; // Combined ABI for error decoding (both staking and validator wallet errors) const COMBINED_ERROR_ABI = [...STAKING_ABI, ...VALIDATOR_WALLET_ABI]; @@ -236,22 +239,59 @@ export const stakingActions = ( }); }; + const getValidatorRegistrationContext = async () => { + if (!client.account) { + throw new Error("Account is required to resolve validator registration context."); + } + + const consensusMain = client.chain.consensusMainContract; + if (!consensusMain?.address || consensusMain.address === zeroAddress) { + throw new Error("Cannot resolve ValidatorWalletFactory without a consensus main contract."); + } + + const [addressManager, chainId] = await Promise.all([ + publicClient.readContract({ + address: consensusMain.address as ViemAddress, + abi: CONSENSUS_ADDRESS_MANAGER_ABI, + functionName: "getAddressManager", + }) as Promise
, + publicClient.getChainId(), + ]); + const registrar = await publicClient.readContract({ + address: addressManager as ViemAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getAddress", + args: [VALIDATOR_WALLET_FACTORY_KEY], + }) as Address; + + if (!registrar || registrar === zeroAddress) { + throw new Error( + `ValidatorWalletFactory is not registered in AddressManager under key ${VALIDATOR_WALLET_FACTORY_KEY}.`, + ); + } + + return { + registrar, + owner: client.account.address as Address, + chainId: BigInt(chainId), + }; + }; + return { /** Joins as a validator with the specified stake amount. */ validatorJoin: async (options: ValidatorJoinOptions): Promise => { const amount = parseStakingAmount(options.amount); const stakingAddress = getStakingAddress(); - - const data = options.operator - ? encodeFunctionData({ - abi: STAKING_ABI, - functionName: "validatorJoin", - args: [options.operator as ViemAddress], - }) - : encodeFunctionData({ - abi: STAKING_ABI, - functionName: "validatorJoin", - }); + const context = await getValidatorRegistrationContext(); + if (!await verifyOperatorRegistration(options.registration, context)) { + throw new Error("Operator registration proof does not match the owner, registrar, chain, or public key."); + } + const operator = operatorAddressFromPublicKey(options.registration.operatorPubKey); + const data = encodeFunctionData({ + abi: STAKING_ABI, + functionName: "validatorJoin", + args: [options.registration.operatorPubKey, options.registration.possessionProof], + }); const result = await executeWrite({to: stakingAddress, data, value: amount}); const receipt = await publicClient.getTransactionReceipt({hash: result.transactionHash}); @@ -283,11 +323,13 @@ export const stakingActions = ( blockNumber: receipt.blockNumber, gasUsed: receipt.gasUsed, validatorWallet: validatorWallet!, - operator: options.operator || (client.account!.address as Address), + operator, amount: formatStakingAmount(amount), amountRaw: amount, }; }, + /** Resolves the registrar, owner, and chain binding required to create an operator proof. */ + getValidatorRegistrationContext, /** * Adds additional self-stake to an active validator position. The diff --git a/src/types/staking.ts b/src/types/staking.ts index 9d9c657..237ae4a 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -1,6 +1,7 @@ import {Address} from "./accounts"; import {GetContractReturnType, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress} from "viem"; import {STAKING_ABI} from "@/abi/staking"; +import type {OperatorRegistrationContext, OperatorRegistrationProof} from "./vesting"; type WalletClientWithAccount = Client; @@ -155,7 +156,7 @@ export interface DelegatorJoinResult extends StakingTransactionResult { export interface ValidatorJoinOptions { amount: bigint | string; - operator?: Address; + registration: OperatorRegistrationProof; } export interface ValidatorDepositOptions { @@ -211,6 +212,7 @@ export interface DelegatorClaimOptions { export interface StakingActions { validatorJoin: (options: ValidatorJoinOptions) => Promise; + getValidatorRegistrationContext: () => Promise; validatorDeposit: (options: ValidatorDepositOptions) => Promise; validatorExit: (options: ValidatorExitOptions) => Promise; validatorClaim: (options?: ValidatorClaimOptions) => Promise; diff --git a/src/types/vesting.ts b/src/types/vesting.ts index 1c3d3ae..5e32ee2 100644 --- a/src/types/vesting.ts +++ b/src/types/vesting.ts @@ -1,4 +1,4 @@ -import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, PublicClient, Transport} from "viem"; +import {Account, Address as ViemAddress, Chain, Client, GetContractReturnType, Hex, PublicClient, Transport} from "viem"; import {Address} from "./accounts"; import {VESTING_ABI, VESTING_FACTORY_ABI} from "@/abi/vesting"; @@ -14,6 +14,24 @@ export type VestingFactoryContract = GetContractReturnType Promise
; vestingFactory: (vesting: Address) => Promise
; vestingAddressManager: (vesting: Address) => Promise
; + getVestingValidatorRegistrationContext: (vesting: Address) => Promise; vestingTotalAmount: (vesting: Address) => Promise; vestingStartDate: (vesting: Address) => Promise; vestingCliffDuration: (vesting: Address) => Promise; diff --git a/src/vesting/actions.ts b/src/vesting/actions.ts index 5e2cef1..86020f6 100644 --- a/src/vesting/actions.ts +++ b/src/vesting/actions.ts @@ -41,12 +41,17 @@ import { VestingWithdrawResult, } from "@/types/vesting"; import {formatStakingAmount, parseStakingAmount} from "@/staking/utils"; +import { + operatorAddressFromPublicKey, + verifyOperatorRegistration, +} from "./operatorRegistration"; type WalletClientWithAccount = Client; const FALLBACK_GAS = 1000000n; const GAS_BUFFER_MULTIPLIER = 2n; const VESTING_FACTORY_KEY = "VestingFactory"; +const VALIDATOR_WALLET_FACTORY_KEY = "ValidatorWalletFactory"; const COMBINED_ERROR_ABI = [...VESTING_ABI, ...VESTING_FACTORY_ABI, ...ADDRESS_MANAGER_ABI, ...STAKING_ABI] as const; function extractRevertReason(err: unknown): string { @@ -268,6 +273,31 @@ export const vestingActions = ( return factory; }; + const getVestingValidatorRegistrationContext = async (vesting: Address) => { + const [addressManager, chainId] = await Promise.all([ + readVesting
(vesting, "addressManager"), + publicClient.getChainId(), + ]); + const registrar = await publicClient.readContract({ + address: addressManager as ViemAddress, + abi: ADDRESS_MANAGER_ABI, + functionName: "getAddress", + args: [VALIDATOR_WALLET_FACTORY_KEY], + }) as Address; + + if (!registrar || registrar === zeroAddress) { + throw new Error( + `ValidatorWalletFactory is not registered in AddressManager under key ${VALIDATOR_WALLET_FACTORY_KEY}.`, + ); + } + + return { + registrar, + owner: vesting, + chainId: BigInt(chainId), + }; + }; + const getVestingContract = (vesting: Address): VestingContract => { return getContract({ address: vesting as ViemAddress, @@ -329,17 +359,22 @@ export const vestingActions = ( /** Creates a validator wallet and self-stakes vesting-held tokens. Must be called by the vesting beneficiary. */ vestingValidatorJoin: async (options: VestingValidatorJoinOptions): Promise => { const amount = parseStakingAmount(options.amount); + const context = await getVestingValidatorRegistrationContext(options.vesting); + if (!await verifyOperatorRegistration(options.registration, context)) { + throw new Error("Operator registration proof does not match the vesting, registrar, chain, or public key."); + } + const operator = operatorAddressFromPublicKey(options.registration.operatorPubKey); const data = encodeFunctionData({ abi: VESTING_ABI, functionName: "vestingValidatorJoin", - args: [options.operator as ViemAddress, amount], + args: [options.registration.operatorPubKey, options.registration.possessionProof, amount], }); const result = await executeWrite({to: options.vesting as ViemAddress, data}); return { ...result, vesting: options.vesting, - operator: options.operator, + operator, beneficiary: client.account!.address as Address, amount: formatStakingAmount(amount), amountRaw: amount, @@ -610,6 +645,7 @@ export const vestingActions = ( vestingRevoker: (vesting: Address): Promise
=> readVesting
(vesting, "revoker"), vestingFactory: (vesting: Address): Promise
=> readVesting
(vesting, "factory"), vestingAddressManager: (vesting: Address): Promise
=> readVesting
(vesting, "addressManager"), + getVestingValidatorRegistrationContext, vestingTotalAmount: (vesting: Address): Promise => readVesting(vesting, "totalAmount"), vestingStartDate: (vesting: Address): Promise => readVesting(vesting, "startDate"), vestingCliffDuration: (vesting: Address): Promise => readVesting(vesting, "cliffDuration"), diff --git a/src/vesting/operatorRegistration.ts b/src/vesting/operatorRegistration.ts new file mode 100644 index 0000000..d7b4442 --- /dev/null +++ b/src/vesting/operatorRegistration.ts @@ -0,0 +1,110 @@ +import { + concatHex, + encodeAbiParameters, + getAddress, + hexToBigInt, + keccak256, + recoverMessageAddress, + sliceHex, + stringToHex, + toHex, + type Address, + type Hex, +} from "viem"; +import {privateKeyToAccount, publicKeyToAddress} from "viem/accounts"; +import type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "@/types/vesting"; + +export type { + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, +} from "@/types/vesting"; + +export const OPERATOR_REGISTRATION_DOMAIN = keccak256( + stringToHex("GenLayer/operatorPubKey/proof-of-possession/v1"), +); + +export function operatorAddressFromPublicKey(operatorPubKey: OperatorPublicKey): Address { + const publicKey = concatHex([ + "0x04", + toHex(operatorPubKey[0], {size: 32}), + toHex(operatorPubKey[1], {size: 32}), + ]); + return getAddress(publicKeyToAddress(publicKey)); +} + +export function operatorPossessionMessage( + operatorPubKey: OperatorPublicKey, + context: OperatorRegistrationContext, +): Hex { + return keccak256( + encodeAbiParameters( + [ + {type: "bytes32"}, + {type: "uint256"}, + {type: "address"}, + {type: "address"}, + {type: "uint256"}, + {type: "uint256"}, + ], + [ + OPERATOR_REGISTRATION_DOMAIN, + context.chainId, + context.registrar, + context.owner, + operatorPubKey[0], + operatorPubKey[1], + ], + ), + ); +} + +/** + * Builds the proof package consumed by proof-bearing validator registration. + * The private key is used only in memory and is never included in the result. + */ +export async function createOperatorRegistration( + options: CreateOperatorRegistrationOptions, +): Promise { + const account = privateKeyToAccount(options.privateKey); + const operatorPubKey: OperatorPublicKey = [ + hexToBigInt(sliceHex(account.publicKey, 1, 33)), + hexToBigInt(sliceHex(account.publicKey, 33, 65)), + ]; + const operator = operatorAddressFromPublicKey(operatorPubKey); + + if (operator !== getAddress(account.address)) { + throw new Error("Operator private key and public key derive different identities."); + } + + const possessionProof = await account.signMessage({ + message: {raw: operatorPossessionMessage(operatorPubKey, options)}, + }); + + return {operator, operatorPubKey, possessionProof}; +} + +/** Validates the key identity and the exact registrar/owner/chain-bound proof. */ +export async function verifyOperatorRegistration( + registration: OperatorRegistrationProof, + context: OperatorRegistrationContext, +): Promise { + try { + const operator = operatorAddressFromPublicKey(registration.operatorPubKey); + if (operator !== getAddress(registration.operator)) return false; + + const recovered = await recoverMessageAddress({ + message: {raw: operatorPossessionMessage(registration.operatorPubKey, context)}, + signature: registration.possessionProof, + }); + return getAddress(recovered) === operator; + } catch { + return false; + } +} diff --git a/src/vesting/validator.ts b/src/vesting/validator.ts index fa01a00..df40501 100644 --- a/src/vesting/validator.ts +++ b/src/vesting/validator.ts @@ -7,4 +7,16 @@ export type { VestingValidatorJoinResult, VestingValidatorSetIdentityOptions, VestingValidatorWalletOptions, + CreateOperatorRegistrationOptions, + OperatorPublicKey, + OperatorRegistrationContext, + OperatorRegistrationProof, } from "@/types/vesting"; + +export { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorAddressFromPublicKey, + operatorPossessionMessage, + verifyOperatorRegistration, +} from "./operatorRegistration"; diff --git a/tests/operator-registration.test.ts b/tests/operator-registration.test.ts new file mode 100644 index 0000000..4a616a0 --- /dev/null +++ b/tests/operator-registration.test.ts @@ -0,0 +1,73 @@ +import {describe, expect, it} from "vitest"; +import {getAddress} from "viem"; +import {privateKeyToAccount} from "viem/accounts"; +import { + OPERATOR_REGISTRATION_DOMAIN, + createOperatorRegistration, + operatorPossessionMessage, + verifyOperatorRegistration, + type OperatorRegistrationContext, +} from "../src/vesting/operatorRegistration"; + +const OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; +const OTHER_OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000003"; +const CONTEXT: OperatorRegistrationContext = { + registrar: "0x1111111111111111111111111111111111111111", + owner: "0x2222222222222222222222222222222222222222", + chainId: 61999n, +}; + +describe("operator registration", () => { + it("matches the consensus proof-of-possession vector", async () => { + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + ...CONTEXT, + }); + + expect(OPERATOR_REGISTRATION_DOMAIN).toBe( + "0x56a1f863be2956668ca2fd6b4010d6fde7a54f2b5a02d6c624a2bad7e5fd5ada", + ); + expect(registration.operator).toBe(getAddress("0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF")); + expect(registration.operatorPubKey).toEqual([ + 89565891926547004231252920425935692360644145829622209833684329913297188986597n, + 12158399299693830322967808612713398636155367887041628176798871954788371653930n, + ]); + expect(operatorPossessionMessage(registration.operatorPubKey, CONTEXT)).toBe( + "0x7823e1bdaf3a8cea679a7bafaf8ddc39c379ac690f35696328650c3a712f36e0", + ); + expect(registration.possessionProof).toBe( + "0x30cedc70f8ab478fbc1a13a3f36e7f6a10eed631f59db4c451e38fe6d94dc640586d7a3202471043dbad68a3850655d39114aaca647df5734b171f8db7e88f161c", + ); + await expect(verifyOperatorRegistration(registration, CONTEXT)).resolves.toBe(true); + }); + + it("rejects wrong-key and cross-domain proofs", async () => { + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + ...CONTEXT, + }); + const wrongKey = privateKeyToAccount(OTHER_OPERATOR_KEY); + const wrongKeyProof = await wrongKey.signMessage({ + message: {raw: operatorPossessionMessage(registration.operatorPubKey, CONTEXT)}, + }); + + await expect( + verifyOperatorRegistration({...registration, possessionProof: wrongKeyProof}, CONTEXT), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, { + ...CONTEXT, + registrar: "0x3333333333333333333333333333333333333333", + }), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, { + ...CONTEXT, + owner: "0x4444444444444444444444444444444444444444", + }), + ).resolves.toBe(false); + await expect( + verifyOperatorRegistration(registration, {...CONTEXT, chainId: CONTEXT.chainId + 1n}), + ).resolves.toBe(false); + }); +}); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts index f58ce33..6465852 100644 --- a/tests/staking-actions.test.ts +++ b/tests/staking-actions.test.ts @@ -2,11 +2,16 @@ import {describe, expect, it, vi} from "vitest"; import {decodeFunctionData, encodeAbiParameters, encodeEventTopics, getAbiItem, parseEther} from "viem"; import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "../src/abi/staking"; import {stakingActions} from "../src/staking/actions"; +import {createOperatorRegistration} from "../src/vesting/operatorRegistration"; const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; const STAKING_ADDRESS = "0x0000000000000000000000000000000000000044"; const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; -const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; +const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000066"; +const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000077"; +const VALIDATOR_WALLET_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000088"; +const OPERATOR_PRIVATE_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; +const OPERATOR_ADDRESS = "0x2B5AD5c4795c026514f8317c7a215E218DcCD6cF"; const GAS_PRICE_HEX = "0x3b9aca00"; const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; @@ -35,8 +40,22 @@ const baseChain = { rpcUrls: {default: {http: ["http://127.0.0.1"]}}, isStudio: false, stakingContract: {address: STAKING_ADDRESS}, + consensusMainContract: {address: CONSENSUS_MAIN_ADDRESS}, }; +const makeRegistration = () => createOperatorRegistration({ + privateKey: OPERATOR_PRIVATE_KEY, + registrar: VALIDATOR_WALLET_FACTORY_ADDRESS, + owner: ACCOUNT_ADDRESS, + chainId: BigInt(baseChain.id), +}); + +const readRegistrationContract = vi.fn().mockImplementation(async ({functionName}: any) => { + if (functionName === "getAddressManager") return ADDRESS_MANAGER_ADDRESS; + if (functionName === "getAddress") return VALIDATOR_WALLET_FACTORY_ADDRESS; + throw new Error(`Unexpected read: ${functionName}`); +}); + // Local-key harness (byte-for-byte regression anchor for the sign+sendRaw lane). const makeLocalHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -52,7 +71,8 @@ const makeLocalHarness = () => { sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - readContract: vi.fn(), + readContract: readRegistrationContract, + getChainId: vi.fn().mockResolvedValue(baseChain.id), }; return {actions: stakingActions(client as any, publicClient as any), client, publicClient, signTransaction}; }; @@ -79,7 +99,8 @@ const makeProviderHarness = () => { sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), getTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), - readContract: vi.fn(), + readContract: readRegistrationContract, + getChainId: vi.fn().mockResolvedValue(baseChain.id), }; return {actions: stakingActions(client as any, publicClient as any), client, publicClient, request, signTransaction}; }; @@ -93,14 +114,15 @@ describe("stakingActions local lane", () => { it("validatorJoin encodes the call, decodes the ValidatorJoin event, and returns the full shape", async () => { const {actions, publicClient, signTransaction} = makeLocalHarness(); - const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); + const registration = await makeRegistration(); + const result = await actions.validatorJoin({amount: "2gen", registration}); // Encoding routed to the staking contract with msg.value = stake amount. expect(publicClient.call.mock.calls[0][0].to).toBe(STAKING_ADDRESS); expect(publicClient.call.mock.calls[0][0].value).toBe(parseEther("2")); expect(decodeFunctionData({abi: STAKING_ABI, data: publicClient.call.mock.calls[0][0].data})).toEqual({ functionName: "validatorJoin", - args: [OPERATOR_ADDRESS], + args: [registration.operatorPubKey, registration.possessionProof], }); // Local sign+sendRaw path. @@ -117,6 +139,17 @@ describe("stakingActions local lane", () => { amountRaw: parseEther("2"), }); }); + + it("rejects a registration proof that is not bound to the joining owner and registrar", async () => { + const {actions, publicClient} = makeLocalHarness(); + const registration = await makeRegistration(); + + await expect(actions.validatorJoin({ + amount: "2gen", + registration: {...registration, possessionProof: "0x1234"}, + })).rejects.toThrow(/registration proof does not match/i); + expect(publicClient.call).not.toHaveBeenCalled(); + }); }); describe("stakingActions provider lane (Address-only)", () => { @@ -168,7 +201,7 @@ describe("stakingActions provider lane (Address-only)", () => { it("validatorJoin decodes the ValidatorJoin event off the provider-returned hash", async () => { const {actions, request, signTransaction} = makeProviderHarness(); - const result = await actions.validatorJoin({amount: "2gen", operator: OPERATOR_ADDRESS}); + const result = await actions.validatorJoin({amount: "2gen", registration: await makeRegistration()}); // Sent via the provider lane, not signed locally. expect(sentTxParams(request).to).toBe(STAKING_ADDRESS); diff --git a/tests/vesting-actions.test.ts b/tests/vesting-actions.test.ts index ec60a1f..05f9f9a 100644 --- a/tests/vesting-actions.test.ts +++ b/tests/vesting-actions.test.ts @@ -2,17 +2,19 @@ import {describe, expect, it, vi} from "vitest"; import {decodeFunctionData, parseEther, toHex, zeroAddress} from "viem"; import {VESTING_ABI} from "../src/abi/vesting"; import {vestingActions} from "../src/vesting/actions"; +import {createOperatorRegistration} from "../src/vesting/operatorRegistration"; const ACCOUNT_ADDRESS = "0x0000000000000000000000000000000000000011"; const BENEFICIARY_ADDRESS = ACCOUNT_ADDRESS; const VESTING_ADDRESS = "0x0000000000000000000000000000000000000022"; const VALIDATOR_ADDRESS = "0x0000000000000000000000000000000000000033"; const VALIDATOR_WALLET_ADDRESS = "0x0000000000000000000000000000000000000099"; -const OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000AA"; const NEW_OPERATOR_ADDRESS = "0x00000000000000000000000000000000000000bb"; const CONSENSUS_MAIN_ADDRESS = "0x0000000000000000000000000000000000000044"; const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000055"; const FACTORY_ADDRESS = "0x0000000000000000000000000000000000000066"; +const VALIDATOR_WALLET_FACTORY_ADDRESS = "0x0000000000000000000000000000000000000077"; +const OPERATOR_KEY = "0x0000000000000000000000000000000000000000000000000000000000000002"; const MOCK_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; const makeReceipt = () => ({ @@ -54,6 +56,7 @@ const makeHarness = () => { prepareTransactionRequest: vi.fn().mockImplementation(async request => request), sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + getChainId: vi.fn().mockResolvedValue(1), readContract: vi.fn(), }; @@ -131,23 +134,39 @@ describe("vestingActions", () => { }); it("encodes vesting validator join and deposit without caller value", async () => { - const {actions, publicClient} = makeHarness(); + const {actions, client, publicClient} = makeHarness(); + client.chain.id = 999; + publicClient.readContract.mockImplementation(async ({address, functionName, args}: any) => { + if (address === VESTING_ADDRESS && functionName === "addressManager") return ADDRESS_MANAGER_ADDRESS; + if (address === ADDRESS_MANAGER_ADDRESS && functionName === "getAddress") { + expect(args).toEqual(["ValidatorWalletFactory"]); + return VALIDATOR_WALLET_FACTORY_ADDRESS; + } + throw new Error(`Unexpected read: ${functionName}`); + }); + const registration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + registrar: VALIDATOR_WALLET_FACTORY_ADDRESS, + owner: VESTING_ADDRESS, + chainId: 1n, + }); const result = await actions.vestingValidatorJoin({ vesting: VESTING_ADDRESS, - operator: OPERATOR_ADDRESS, + registration, amount: "3gen", }); expect(publicClient.call.mock.calls[0][0].to).toBe(VESTING_ADDRESS); + expect(publicClient.getChainId).toHaveBeenCalledTimes(1); expect(publicClient.call.mock.calls[0][0].value).toBeUndefined(); expect(decodedWrite(publicClient)).toEqual({ functionName: "vestingValidatorJoin", - args: [OPERATOR_ADDRESS, parseEther("3")], + args: [registration.operatorPubKey, registration.possessionProof, parseEther("3")], }); expect(result).toMatchObject({ vesting: VESTING_ADDRESS, - operator: OPERATOR_ADDRESS, + operator: registration.operator, beneficiary: ACCOUNT_ADDRESS, amount: "3 GEN", amountRaw: parseEther("3"), @@ -377,6 +396,7 @@ const makeProviderHarness = () => { prepareTransactionRequest: vi.fn().mockImplementation(async (r: any) => r), sendRawTransaction: vi.fn().mockResolvedValue(MOCK_TX_HASH), waitForTransactionReceipt: vi.fn().mockResolvedValue(makeReceipt()), + getChainId: vi.fn().mockResolvedValue(1), readContract: vi.fn(), }; From 3bf95549a8ef5642778932d2223e1621dfbb21a0 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Tue, 18 Aug 2026 04:50:54 +0100 Subject: [PATCH 2/3] feat(staking): support the two-step operator rotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CON-715 removed ValidatorWalletBlueprint.setOperator in favour of initiateOperatorTransfer + completeOperatorTransfer. The SDK only spoke the single-call surface, so rotating an operator against a consensus deployment that carries the change fails: the selector no longer exists, so viem reverts with no reason and the Python SDK reports ABIFunctionNotFound. Adds both calls plus cancelOperatorTransfer and the getPendingOperator view, and keeps setOperator for deployments that still expose it. The proof binding is the subtle part. validatorJoin's possession proof is verified by the ValidatorWalletFactory and is bound to it; rotation is verified by the wallet, so the registrar is the wallet's own address (PubKeyUtils.validateWithPossession(pubKey, address(this), owner(), proof)). createOperatorRegistration already takes the registrar as a parameter, so no change was needed there — but a join proof silently fails to verify for a rotation, which is the easy mistake to make. getOperatorTransferContext builds the wallet-bound context and reads owner() from the wallet rather than assuming the caller is the owner, so a mismatch surfaces as a clear local error instead of an onlyOwner revert. A test pins that a join-bound proof does not verify for rotation while a wallet-bound one does. Callers still need updating; this only adds the surface they need: genlayer-e2e's driver interface, genlayer-cli's staking set-operator, and genlayer-py's staking_set_operator all remain single-call. Verified: npx tsc --noEmit, npm test -- --run (123/123, no type errors), npm run build. --- src/abi/staking.ts | 39 +++++++++++ src/staking/actions.ts | 100 +++++++++++++++++++++++++++- src/types/staking.ts | 29 ++++++++ tests/operator-registration.test.ts | 33 +++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) diff --git a/src/abi/staking.ts b/src/abi/staking.ts index ad6e6a7..07ac58a 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -55,6 +55,45 @@ export const VALIDATOR_WALLET_ABI = [ inputs: [{name: "_operator", type: "address"}], outputs: [], }, + // Two-step operator rotation (CON-715). setOperator above is the single-call + // predecessor and is absent from newer consensus deployments, so callers pick + // whichever the deployed wallet exposes. Unlike validatorJoin, the possession + // proof here is verified by the wallet itself, so its registrar is the wallet + // address rather than the ValidatorWalletFactory. + { + name: "initiateOperatorTransfer", + type: "function", + stateMutability: "nonpayable", + inputs: [ + {name: "_newOperatorPubKey", type: "uint256[2]"}, + {name: "_possessionProof", type: "bytes"}, + ], + outputs: [], + }, + { + name: "completeOperatorTransfer", + type: "function", + stateMutability: "nonpayable", + inputs: [], + outputs: [], + }, + { + name: "cancelOperatorTransfer", + type: "function", + stateMutability: "nonpayable", + inputs: [], + outputs: [], + }, + { + name: "getPendingOperator", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [ + {name: "", type: "address"}, + {name: "", type: "uint256"}, + ], + }, { name: "setIdentity", type: "function", diff --git a/src/staking/actions.ts b/src/staking/actions.ts index d6eea76..9bbd361 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -20,6 +20,10 @@ import { ValidatorClaimOptions, ValidatorPrimeOptions, SetOperatorOptions, + InitiateOperatorTransferOptions, + CompleteOperatorTransferOptions, + CancelOperatorTransferOptions, + PendingOperatorInfo, SetIdentityOptions, DelegatorJoinOptions, DelegatorExitOptions, @@ -28,6 +32,7 @@ import { PendingDeposit, PendingWithdrawal, } from "@/types/staking"; +import type {OperatorRegistrationContext} from "@/types/vesting"; type ReadOnlyStakingContract = GetContractReturnType; type WalletClientWithAccount = Client; @@ -277,6 +282,29 @@ export const stakingActions = ( }; }; + /** + * Rotation is verified by the wallet, not the factory, so the registrar is the + * wallet's own address. The owner is read from the wallet rather than assumed + * to be the caller: the proof is bound to whoever `owner()` returns, and a + * mismatch is far easier to diagnose here than as an onlyOwner revert. + */ + const getOperatorTransferContext = async (validator: Address): Promise => { + const [owner, chainId] = await Promise.all([ + publicClient.readContract({ + address: validator as ViemAddress, + abi: VALIDATOR_WALLET_ABI, + functionName: "owner", + }) as Promise
, + publicClient.getChainId(), + ]); + + return { + registrar: validator, + owner, + chainId: BigInt(chainId), + }; + }; + return { /** Joins as a validator with the specified stake amount. */ validatorJoin: async (options: ValidatorJoinOptions): Promise => { @@ -386,7 +414,14 @@ export const stakingActions = ( return executeWrite({to: getStakingAddress(), data}); }, - /** Sets the operator address for a validator wallet. */ + /** + * Sets the operator address for a validator wallet in one call. + * + * Removed from consensus by CON-715 in favour of the two-step rotation + * below; against a deployment that dropped it this reverts with no reason, + * because the selector simply does not exist. Prefer + * initiateOperatorTransfer + completeOperatorTransfer. + */ setOperator: async (options: SetOperatorOptions): Promise => { const data = encodeFunctionData({ abi: VALIDATOR_WALLET_ABI, @@ -396,6 +431,69 @@ export const stakingActions = ( return executeWrite({to: options.validator as ViemAddress, data}); }, + getOperatorTransferContext, + + /** + * Starts the two-step operator rotation. The proof is checked against the + * wallet-bound context before submission so a registration built for the + * wrong registrar fails locally instead of as an opaque on-chain revert. + */ + initiateOperatorTransfer: async ( + options: InitiateOperatorTransferOptions, + ): Promise => { + const context = await getOperatorTransferContext(options.validator); + if (!await verifyOperatorRegistration(options.registration, context)) { + throw new Error( + "Operator registration proof does not match the wallet, owner, chain, or public key. " + + "Rotation proofs must use the validator wallet as their registrar.", + ); + } + const data = encodeFunctionData({ + abi: VALIDATOR_WALLET_ABI, + functionName: "initiateOperatorTransfer", + args: [options.registration.operatorPubKey, options.registration.possessionProof], + }); + return executeWrite({to: options.validator as ViemAddress, data}); + }, + + /** + * Completes a pending rotation. Callable by the wallet owner or the pending + * operator, and only once the factory's operatorTransferDelay has elapsed. + */ + completeOperatorTransfer: async ( + options: CompleteOperatorTransferOptions, + ): Promise => { + const data = encodeFunctionData({ + abi: VALIDATOR_WALLET_ABI, + functionName: "completeOperatorTransfer", + args: [], + }); + return executeWrite({to: options.validator as ViemAddress, data}); + }, + + /** Abandons a pending rotation, leaving the current operator in place. */ + cancelOperatorTransfer: async ( + options: CancelOperatorTransferOptions, + ): Promise => { + const data = encodeFunctionData({ + abi: VALIDATOR_WALLET_ABI, + functionName: "cancelOperatorTransfer", + args: [], + }); + return executeWrite({to: options.validator as ViemAddress, data}); + }, + + /** Reads the pending operator and when its transfer was initiated. */ + getPendingOperator: async (validator: Address): Promise => { + const [operator, initiatedAt] = await publicClient.readContract({ + address: validator as ViemAddress, + abi: VALIDATOR_WALLET_ABI, + functionName: "getPendingOperator", + }) as [Address, bigint]; + + return {operator, initiatedAt}; + }, + /** Sets validator identity information (name, website, social links). */ setIdentity: async (options: SetIdentityOptions): Promise => { let extraCidBytes: `0x${string}` = "0x"; diff --git a/src/types/staking.ts b/src/types/staking.ts index 237ae4a..0af016b 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -182,6 +182,30 @@ export interface SetOperatorOptions { operator: Address; } +/** + * Starts the two-step operator rotation. `registration` must be built with the + * validator wallet as its registrar — the wallet verifies the possession proof + * itself, unlike validatorJoin where the factory does. + */ +export interface InitiateOperatorTransferOptions { + validator: Address; + registration: OperatorRegistrationProof; +} + +export interface CompleteOperatorTransferOptions { + validator: Address; +} + +export interface CancelOperatorTransferOptions { + validator: Address; +} + +/** Pending operator and the timestamp its transfer was initiated (0 when none). */ +export interface PendingOperatorInfo { + operator: Address; + initiatedAt: bigint; +} + export interface SetIdentityOptions { validator: Address; moniker: string; @@ -213,6 +237,11 @@ export interface DelegatorClaimOptions { export interface StakingActions { validatorJoin: (options: ValidatorJoinOptions) => Promise; getValidatorRegistrationContext: () => Promise; + getOperatorTransferContext: (validator: Address) => Promise; + initiateOperatorTransfer: (options: InitiateOperatorTransferOptions) => Promise; + completeOperatorTransfer: (options: CompleteOperatorTransferOptions) => Promise; + cancelOperatorTransfer: (options: CancelOperatorTransferOptions) => Promise; + getPendingOperator: (validator: Address) => Promise; validatorDeposit: (options: ValidatorDepositOptions) => Promise; validatorExit: (options: ValidatorExitOptions) => Promise; validatorClaim: (options?: ValidatorClaimOptions) => Promise; diff --git a/tests/operator-registration.test.ts b/tests/operator-registration.test.ts index 4a616a0..8d3818f 100644 --- a/tests/operator-registration.test.ts +++ b/tests/operator-registration.test.ts @@ -70,4 +70,37 @@ describe("operator registration", () => { verifyOperatorRegistration(registration, {...CONTEXT, chainId: CONTEXT.chainId + 1n}), ).resolves.toBe(false); }); + + // The two proof-bearing flows differ only in who verifies, and therefore in + // the registrar the proof is bound to: validatorJoin is checked by the + // ValidatorWalletFactory, while initiateOperatorTransfer is checked by the + // wallet itself (PubKeyUtils.validateWithPossession(pubKey, address(this), + // owner(), proof)). Reusing a join proof to rotate is the easy mistake, so + // pin that it does not verify. + it("binds rotation proofs to the wallet, not the factory", async () => { + const factory = "0x1111111111111111111111111111111111111111"; + const wallet = "0x5555555555555555555555555555555555555555"; + const owner = "0x2222222222222222222222222222222222222222"; + const chainId = 61999n; + + const joinRegistration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + registrar: factory, + owner, + chainId, + }); + const rotationRegistration = await createOperatorRegistration({ + privateKey: OPERATOR_KEY, + registrar: wallet, + owner, + chainId, + }); + + const rotationContext: OperatorRegistrationContext = {registrar: wallet, owner, chainId}; + + await expect(verifyOperatorRegistration(rotationRegistration, rotationContext)).resolves.toBe(true); + await expect(verifyOperatorRegistration(joinRegistration, rotationContext)).resolves.toBe(false); + expect(rotationRegistration.possessionProof).not.toBe(joinRegistration.possessionProof); + expect(rotationRegistration.operator).toBe(joinRegistration.operator); + }); }); From 5c18edf55dd84d514c7cc914fa31ef30cfb794fb Mon Sep 17 00:00:00 2001 From: kirilaa Date: Tue, 18 Aug 2026 08:31:28 +0100 Subject: [PATCH 3/3] fix(staking): resolve the Claim/Commit layout from the chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CON-715 widened both staking structs — Claim gained `offset`, Commit gained `outstanding`/`priced`/`fragmented` and narrowed several members — while keeping the same function names and arguments. Static tuples decode positionally, so reading a post-CON-715 chain with the shape in STAKING_ABI does not fail: `commit.input` picks up `claim.commit`. Pending deposits came back as small indices instead of amounts, which is why the e2e delegator scenarios asserted 0.1 GEN and got "0" or "2". Both layouts are deployed — consensus v0.6-dev has the old one, #1290 the new — so neither shape can simply replace the other. STAKING_COMMIT_VIEWS_CURRENT_ABI adds the post-CON-715 shape for the four struct-returning views, and readCommitView resolves which one the chain speaks, once, then caches it for the client: getStakeInfo loops over every pending entry and must not re-probe per entry. The probe only works in one direction, and the tests pin that: decoding a legacy response with the current shape throws (the response is short), while decoding a current response with the legacy shape succeeds and lies. So the current shape is always attempted first and a decode *failure* identifies a legacy chain. If that asymmetry ever breaks, the probe would quietly start reporting wrong balances again — hence the test asserting the exact misread value rather than merely "not equal". Also drops duplicate ValidatorWalletBlueprint entries: the ABI already carried initiateOperatorTransfer/completeOperatorTransfer/cancelOperatorTransfer/ getPendingOperator, with initiateOperatorTransfer declared as taking an address rather than the pubkey and possession proof it actually takes. Verified: npx tsc --noEmit, npm test -- --run (126/126, no type errors), npm run build. --- src/abi/staking.ts | 122 ++++++++++++++++++++-------- src/staking/actions.ts | 62 ++++++++++++-- tests/staking-commit-layout.test.ts | 64 +++++++++++++++ 3 files changed, 211 insertions(+), 37 deletions(-) create mode 100644 tests/staking-commit-layout.test.ts diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 07ac58a..9f3ea14 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -133,38 +133,6 @@ export const VALIDATOR_WALLET_ABI = [ inputs: [], outputs: [], }, - // Two-step operator transfer - { - name: "initiateOperatorTransfer", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_newOperator", type: "address"}], - outputs: [], - }, - { - name: "completeOperatorTransfer", - type: "function", - stateMutability: "nonpayable", - inputs: [], - outputs: [], - }, - { - name: "cancelOperatorTransfer", - type: "function", - stateMutability: "nonpayable", - inputs: [], - outputs: [], - }, - { - name: "getPendingOperator", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [ - {name: "", type: "address"}, - {name: "", type: "uint256"}, - ], - }, { name: "getOperator", type: "function", @@ -1548,3 +1516,93 @@ export const SLASH_ABI = [ ], }, ] as const; + +/** + * The staking Claim/Commit views as consensus exposes them after CON-715. + * + * That change widened both structs — Claim gained `offset`, Commit gained + * `outstanding`/`priced`/`fragmented` and narrowed several members — while + * keeping the same function names and argument lists. Static tuples decode + * positionally, so reading a post-CON-715 chain with the older shape in + * STAKING_ABI silently returns the wrong words rather than failing: `commit.input` + * picks up `claim.commit`, which is why pending deposits read back as small + * indices instead of amounts. + * + * Both shapes are still in the wild, so neither can simply replace the other. + * stakingActions probes once per client and then reads with whichever matches. + * Decoding the OLD layout with this one throws (the response is too short), + * which is what makes the probe possible; the reverse direction is the silent + * one, so the current shape must always be tried first. + */ +const CURRENT_CLAIM_COMPONENTS = [ + {name: "quantity", type: "uint96"}, + {name: "offset", type: "uint96"}, + {name: "commit", type: "uint256"}, +] as const; + +const CURRENT_COMMIT_COMPONENTS = [ + {name: "input", type: "uint256"}, + {name: "output", type: "uint256"}, + {name: "outstanding", type: "uint120"}, + {name: "epoch", type: "uint64"}, + {name: "linkToNextCommit", type: "uint56"}, + {name: "priced", type: "bool"}, + {name: "fragmented", type: "bool"}, +] as const; + +export const STAKING_COMMIT_VIEWS_CURRENT_ABI = [ + { + name: "delegatorDeposit", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_delegator", type: "address"}, + {name: "_validator", type: "address"}, + {name: "_index", type: "uint256"}, + ], + outputs: [ + {name: "claim_", type: "tuple", components: CURRENT_CLAIM_COMPONENTS}, + {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, + ], + }, + { + name: "delegatorWithdrawal", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_delegator", type: "address"}, + {name: "_validator", type: "address"}, + {name: "_index", type: "uint256"}, + ], + outputs: [ + {name: "claim_", type: "tuple", components: CURRENT_CLAIM_COMPONENTS}, + {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, + ], + }, + { + name: "validatorDeposit", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_validator", type: "address"}, + {name: "_index", type: "uint256"}, + ], + outputs: [ + {name: "epoch_", type: "uint256"}, + {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, + ], + }, + { + name: "validatorWithdrawal", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_validator", type: "address"}, + {name: "_index", type: "uint256"}, + ], + outputs: [ + {name: "epoch_", type: "uint256"}, + {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, + ], + }, +] as const; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index 9bbd361..cf46ab1 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,6 +1,6 @@ import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError, zeroAddress} from "viem"; import {GenLayerClient, GenLayerChain, Address} from "@/types"; -import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; +import {STAKING_ABI, VALIDATOR_WALLET_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "@/abi/staking"; import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; import {operatorAddressFromPublicKey, verifyOperatorRegistration} from "@/vesting/operatorRegistration"; @@ -282,6 +282,58 @@ export const stakingActions = ( }; }; + /** + * Which Claim/Commit layout the deployed staking contract uses. + * + * CON-715 widened both structs without renaming anything, and static tuples + * decode positionally, so the wrong shape does not fail — it silently returns + * neighbouring words (commit.input picks up claim.commit, i.e. an index where + * an amount belongs). Both shapes are deployed in the wild, so the layout is + * resolved from the chain rather than assumed, then cached for the client: + * getStakeInfo loops over every pending entry and must not re-probe each time. + * + * The probe only works in one direction. Reading the OLD layout with the + * CURRENT shape throws, because the response is shorter than the decoder + * expects; reading the CURRENT layout with the OLD shape succeeds and lies. + * So the current shape is always attempted first, and a decode failure — not + * a success — is what identifies a legacy chain. + */ + let commitLayout: "current" | "legacy" | null = null; + + const readCommitView = async ( + functionName: "delegatorDeposit" | "delegatorWithdrawal" | "validatorDeposit" | "validatorWithdrawal", + args: readonly unknown[], + ): Promise => { + const read = (layout: "current" | "legacy") => + publicClient.readContract({ + address: getStakingAddress(), + abi: (layout === "current" ? STAKING_COMMIT_VIEWS_CURRENT_ABI : STAKING_ABI) as any, + functionName, + args: args as any, + }); + + if (commitLayout) { + return read(commitLayout); + } + + try { + const result = await read("current"); + commitLayout = "current"; + return result; + } catch (currentError) { + // Could be a legacy layout, or a genuine failure (bad index, RPC error). + // Only a successful legacy decode distinguishes them; otherwise surface + // the original error, which describes the current-shape attempt. + try { + const result = await read("legacy"); + commitLayout = "legacy"; + return result; + } catch { + throw currentError; + } + } + }; + /** * Rotation is verified by the wallet, not the factory, so the registrar is the * wallet's own address. The owner is read from the wallet rather than assumed @@ -622,7 +674,7 @@ export const stakingActions = ( const pendingDeposits: PendingDeposit[] = []; for (let i = 0n; i < depositLen; i++) { - const [epoch, commit] = (await contract.read.validatorDeposit([validator as ViemAddress, i])) as [ + const [epoch, commit] = (await readCommitView("validatorDeposit", [validator as ViemAddress, i])) as [ bigint, {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, ]; @@ -639,7 +691,7 @@ export const stakingActions = ( const pendingWithdrawals: PendingWithdrawal[] = []; for (let i = 0n; i < withdrawalLen; i++) { - const [epoch, commit] = (await contract.read.validatorWithdrawal([validator as ViemAddress, i])) as [ + const [epoch, commit] = (await readCommitView("validatorWithdrawal", [validator as ViemAddress, i])) as [ bigint, {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, ]; @@ -715,7 +767,7 @@ export const stakingActions = ( const pendingDeposits: PendingDeposit[] = []; for (let i = 0n; i < depositLen; i++) { - const [claim, commit] = (await contract.read.delegatorDeposit([ + const [claim, commit] = (await readCommitView("delegatorDeposit", [ delegator as ViemAddress, validator as ViemAddress, i, @@ -739,7 +791,7 @@ export const stakingActions = ( const pendingWithdrawals: PendingWithdrawal[] = []; for (let i = 0n; i < withdrawalLen; i++) { - const [claim, commit] = (await contract.read.delegatorWithdrawal([ + const [claim, commit] = (await readCommitView("delegatorWithdrawal", [ delegator as ViemAddress, validator as ViemAddress, i, diff --git a/tests/staking-commit-layout.test.ts b/tests/staking-commit-layout.test.ts new file mode 100644 index 0000000..0c2aede --- /dev/null +++ b/tests/staking-commit-layout.test.ts @@ -0,0 +1,64 @@ +/** + * The Claim/Commit layout probe rests on one asymmetry, so pin it. + * + * CON-715 widened both structs without renaming the functions. Decoding a + * post-CON-715 response with the older shape does NOT fail — it returns + * neighbouring words — so a successful decode cannot identify the layout. + * Only the reverse throws. That is why stakingActions tries the current shape + * first and treats a decode failure, rather than a success, as the signal. + * + * If this asymmetry ever stops holding, the probe silently starts reporting + * wrong balances again, which is exactly the bug it exists to prevent. + */ +import {describe, expect, it} from "vitest"; +import {decodeFunctionResult, encodeAbiParameters} from "viem"; +import {STAKING_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "../src/abi/staking"; + +const STAKE = 100000000000000000n; // 0.1 GEN +const CLAIM_COMMIT_INDEX = 2n; + +const outputsOf = (abi: readonly any[], name: string) => + abi.find((e: any) => e.type === "function" && e.name === name)!.outputs; + +const legacyOutputs = outputsOf(STAKING_ABI as any, "delegatorDeposit"); +const currentOutputs = outputsOf(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, "delegatorDeposit"); + +const legacyResponse = encodeAbiParameters(legacyOutputs, [ + {quantity: 7n, commit: CLAIM_COMMIT_INDEX}, + {input: STAKE, output: 5n, epoch: 3n, linkToNextCommit: 0n}, +] as any); + +const currentResponse = encodeAbiParameters(currentOutputs, [ + {quantity: 7n, offset: 0n, commit: CLAIM_COMMIT_INDEX}, + { + input: STAKE, + output: 5n, + outstanding: 9n, + epoch: 3n, + linkToNextCommit: 0n, + priced: true, + fragmented: false, + }, +] as any); + +const decodeWith = (abi: readonly any[], data: `0x${string}`) => + decodeFunctionResult({abi: abi as any, functionName: "delegatorDeposit", data}) as any; + +describe("staking Claim/Commit layout", () => { + it("reads the amount when the shape matches the response", () => { + expect(decodeWith(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, currentResponse)[1].input).toBe(STAKE); + expect(decodeWith(STAKING_ABI as any, legacyResponse)[1].input).toBe(STAKE); + }); + + it("throws when the current shape meets a legacy response — this is the probe", () => { + expect(() => decodeWith(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, legacyResponse)).toThrow(); + }); + + it("silently misreads when the legacy shape meets a current response", () => { + // Not a throw: claim.commit lands where commit.input is expected, which is + // how pending deposits came back as small indices instead of amounts. + const misread = decodeWith(STAKING_ABI as any, currentResponse)[1].input; + expect(misread).not.toBe(STAKE); + expect(misread).toBe(CLAIM_COMMIT_INDEX); + }); +});