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..9f3ea14 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -55,76 +55,83 @@ 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: "setIdentity", + name: "initiateOperatorTransfer", type: "function", stateMutability: "nonpayable", inputs: [ - {name: "moniker", type: "string"}, - {name: "logoUri", type: "string"}, - {name: "website", type: "string"}, - {name: "description", type: "string"}, - {name: "email", type: "string"}, - {name: "twitter", type: "string"}, - {name: "telegram", type: "string"}, - {name: "github", type: "string"}, - {name: "extraCid", type: "bytes"}, + {name: "_newOperatorPubKey", type: "uint256[2]"}, + {name: "_possessionProof", type: "bytes"}, ], outputs: [], }, - // Staking functions (forwarded to staking contract) { - name: "validatorDeposit", + name: "completeOperatorTransfer", type: "function", - stateMutability: "payable", + stateMutability: "nonpayable", inputs: [], outputs: [], }, { - name: "validatorExit", + name: "cancelOperatorTransfer", type: "function", stateMutability: "nonpayable", - inputs: [{name: "_shares", type: "uint256"}], + inputs: [], outputs: [], }, { - name: "validatorClaim", + name: "getPendingOperator", type: "function", - stateMutability: "nonpayable", + stateMutability: "view", inputs: [], - outputs: [], + outputs: [ + {name: "", type: "address"}, + {name: "", type: "uint256"}, + ], }, - // Two-step operator transfer { - name: "initiateOperatorTransfer", + name: "setIdentity", type: "function", stateMutability: "nonpayable", - inputs: [{name: "_newOperator", type: "address"}], + inputs: [ + {name: "moniker", type: "string"}, + {name: "logoUri", type: "string"}, + {name: "website", type: "string"}, + {name: "description", type: "string"}, + {name: "email", type: "string"}, + {name: "twitter", type: "string"}, + {name: "telegram", type: "string"}, + {name: "github", type: "string"}, + {name: "extraCid", type: "bytes"}, + ], outputs: [], }, + // Staking functions (forwarded to staking contract) { - name: "completeOperatorTransfer", + name: "validatorDeposit", type: "function", - stateMutability: "nonpayable", + stateMutability: "payable", inputs: [], outputs: [], }, { - name: "cancelOperatorTransfer", + name: "validatorExit", type: "function", stateMutability: "nonpayable", - inputs: [], + inputs: [{name: "_shares", type: "uint256"}], outputs: [], }, { - name: "getPendingOperator", + name: "validatorClaim", type: "function", - stateMutability: "view", + stateMutability: "nonpayable", inputs: [], - outputs: [ - {name: "", type: "address"}, - {name: "", type: "uint256"}, - ], + outputs: [], }, { name: "getOperator", @@ -1249,14 +1256,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"}], }, { @@ -1513,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/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..cf46ab1 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 {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"; import { ValidatorInfo, ValidatorIdentity, @@ -18,6 +20,10 @@ import { ValidatorClaimOptions, ValidatorPrimeOptions, SetOperatorOptions, + InitiateOperatorTransferOptions, + CompleteOperatorTransferOptions, + CancelOperatorTransferOptions, + PendingOperatorInfo, SetIdentityOptions, DelegatorJoinOptions, DelegatorExitOptions, @@ -26,12 +32,14 @@ import { PendingDeposit, PendingWithdrawal, } from "@/types/staking"; +import type {OperatorRegistrationContext} from "@/types/vesting"; type ReadOnlyStakingContract = GetContractReturnType; 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 +244,134 @@ 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), + }; + }; + + /** + * 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 + * 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 => { 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 +403,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 @@ -344,7 +466,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, @@ -354,6 +483,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"; @@ -482,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}, ]; @@ -499,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}, ]; @@ -575,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, @@ -599,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/src/types/staking.ts b/src/types/staking.ts index 9d9c657..0af016b 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 { @@ -181,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; @@ -211,6 +236,12 @@ 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/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..8d3818f --- /dev/null +++ b/tests/operator-registration.test.ts @@ -0,0 +1,106 @@ +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); + }); + + // 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); + }); +}); 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/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); + }); +}); 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(), };