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(), };