From a972eedc7713ad751f909218dd559a2d091e6b00 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Tue, 25 Aug 2026 21:43:21 +0200 Subject: [PATCH 1/9] fix(staking): read the joined validator set a page at a time The staking contract withdrew the unpaged validator reads the CLI was built on. `activeValidators()` is gone outright, and the balanced-tree walk lost both of its footholds: `validatorsRoot()` no longer exists and `validatorView()` no longer carries the left/right/parent links. None of this degrades gracefully -- the calls revert rather than truncating, so `genlayer staking active-validators` exits non-zero against a deployment carrying the change. Read the append-only joined registry instead, one page at a time: `validatorsJoinedCount()` bounds the walk and `getValidatorsJoined(start, pageSize)` returns each page. The count is read first so a set that grows underneath the walk cannot spin the loop, and a short page means it shrank instead -- stop there and let the next read see the settled set. Page size is 64, matching the convention the contract's own paged reads are written around; committee capacity is 1,543 seats, which is why the unpaged read had to go in the first place. `staking validators` loses its one-call answer for "in the current draw", so the active marker is now derived from what is still readable: joined, and neither banned nor quarantined. --- src/commands/staking/StakingAction.ts | 87 +++++++++++++----------- src/commands/staking/stakingInfo.ts | 29 +++++--- src/commands/staking/validatorPrime.ts | 6 +- src/commands/staking/validators.ts | 19 ++++-- tests/actions/staking.test.ts | 2 +- tests/commands/stakingValidators.test.ts | 3 +- 6 files changed, 85 insertions(+), 61 deletions(-) diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 02d3b0ec..3612baa4 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -20,17 +20,38 @@ import {createPublicClient, http} from "viem"; import {glHttpConfig, type BrowserSession} from "../../lib/wallet/browserSend"; import {resolveBrowserWalletSession} from "../../lib/wallet/sessionResolver"; -// Extended ABI for tree traversal (not in SDK) -const STAKING_TREE_ABI = [ +// Extended ABI for the joined-validator registry (not in SDK). +// +// The staking contract no longer exposes the balanced-tree view the CLI used to +// walk: validatorsRoot() is gone, and validatorView() no longer carries the +// left/right/parent links the walk needed. The joined validators are read from +// an append-only registry instead, one page at a time. +const STAKING_REGISTRY_ABI = [ { - name: "validatorsRoot", + name: "validatorsJoinedCount", type: "function", stateMutability: "view", inputs: [], - outputs: [{name: "", type: "address"}], + outputs: [{name: "", type: "uint256"}], + }, + { + name: "getValidatorsJoined", + type: "function", + stateMutability: "view", + inputs: [ + {name: "_startIndex", type: "uint256"}, + {name: "_pageSize", type: "uint256"}, + ], + outputs: [{name: "", type: "address[]"}], }, ] as const; +// Committee capacity is 1,543 seats, and an address[] that long overruns the +// return-size limit — which is why the unpaged read was withdrawn in the first +// place. This is the page size the contract's own paged reads are written +// around; nothing truncates or auto-switches, so the caller does the walking. +const VALIDATORS_JOINED_PAGE_SIZE = 64n; + // Re-export for use by other staking commands export {BUILT_IN_NETWORKS}; @@ -366,10 +387,13 @@ export class StakingAction extends BaseAction { } /** - * Get all validators by traversing the validator tree. - * This finds ALL validators including those not yet active/primed. + * Get every validator in the joined registry, read one page at a time. + * + * This is the whole joined set, not the subset eligible for the current + * epoch's draw — so, like the tree walk it replaces, it includes validators + * that have not been primed yet. */ - protected async getAllValidatorsFromTree(config: StakingConfig): Promise { + protected async getJoinedValidators(config: StakingConfig): Promise { const network = this.getNetwork(config); const rpcUrl = config.rpc || network.rpcUrls.default.http[0]; const stakingAddress = config.stakingAddress || network.stakingContract?.address; @@ -383,45 +407,30 @@ export class StakingAction extends BaseAction { transport: http(rpcUrl, glHttpConfig), }); - // Get the root of the validator tree - const root = await publicClient.readContract({ + // Read the count first so a set that grows underneath the walk cannot spin + // the loop. A short or empty page means it shrank instead: stop there and + // let the next read see the settled set. + const total = (await publicClient.readContract({ address: stakingAddress as `0x${string}`, - abi: STAKING_TREE_ABI, - functionName: "validatorsRoot", - }); - - if (root === ZeroAddress) { - return []; - } + abi: STAKING_REGISTRY_ABI, + functionName: "validatorsJoinedCount", + })) as bigint; const validators: Address[] = []; - const stack: string[] = [root as string]; - const visited = new Set(); - - // Use validatorView from SDK's ABI (has left/right fields) - while (stack.length > 0) { - const addr = stack.pop()!; - - if (addr === ZeroAddress || visited.has(addr.toLowerCase())) continue; - visited.add(addr.toLowerCase()); - - validators.push(addr as Address); - const info = (await publicClient.readContract({ + for (let start = 0n; start < total; start += VALIDATORS_JOINED_PAGE_SIZE) { + const page = (await publicClient.readContract({ address: stakingAddress as `0x${string}`, - abi: abi.STAKING_ABI, - functionName: "validatorView", - args: [addr as `0x${string}`], - })) as {left: string; right: string}; + abi: STAKING_REGISTRY_ABI, + functionName: "getValidatorsJoined", + args: [start, VALIDATORS_JOINED_PAGE_SIZE], + })) as Address[]; - if (info.left !== ZeroAddress) { - stack.push(info.left); - } - if (info.right !== ZeroAddress) { - stack.push(info.right); - } + if (page.length === 0) break; + + validators.push(...page); } - return validators; + return validators.filter(v => v !== ZeroAddress); } } diff --git a/src/commands/staking/stakingInfo.ts b/src/commands/staking/stakingInfo.ts index 4f4d5a80..bc7b7bf1 100644 --- a/src/commands/staking/stakingInfo.ts +++ b/src/commands/staking/stakingInfo.ts @@ -466,9 +466,11 @@ export class StakingInfoAction extends StakingAction { this.startSpinner("Fetching active validators..."); try { - const client = await this.getReadOnlyStakingClient(options); - - const activeValidators = await client.getActiveValidators(); + // Read the registry directly rather than through the SDK: the staking + // contract dropped activeValidators(), and the unpaged read reverts + // rather than degrading, so the paged registry walk is the only surface + // that answers now. + const activeValidators = await this.getJoinedValidators(options); const result = { count: activeValidators.length, @@ -542,12 +544,11 @@ export class StakingInfoAction extends StakingAction { // No account or session configured, that's fine } - // Use tree traversal to get ALL validators (including not-yet-primed) - const allTreeAddresses = await this.getAllValidatorsFromTree(options); + // Read the registry to get ALL validators (including not-yet-primed) + const allJoinedAddresses = await this.getJoinedValidators(options); // Also fetch status lists in parallel - const [activeAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([ - client.getActiveValidators(), + const [quarantinedList, bannedList, epochInfo] = await Promise.all([ client.getQuarantinedValidatorsDetailed(), options.all ? client.getBannedValidators() : Promise.resolve([]), client.getEpochInfo(), @@ -556,12 +557,20 @@ export class StakingInfoAction extends StakingAction { // Build set of quarantined/banned for status lookup const quarantinedSet = new Map(quarantinedList.map(v => [v.validator.toLowerCase(), v])); const bannedSet = new Map(bannedList.map(v => [v.validator.toLowerCase(), v])); - const activeSet = new Set(activeAddresses.map(a => a.toLowerCase())); + + // With activeValidators() withdrawn there is no single read that answers + // "in the current draw", so the active marker is derived from what is + // still readable: joined, and neither banned nor quarantined. + const activeSet = new Set( + allJoinedAddresses + .map(a => a.toLowerCase()) + .filter(a => !bannedSet.has(a) && !quarantinedSet.has(a)), + ); // Filter out banned if not --all const allAddresses = options.all - ? allTreeAddresses - : allTreeAddresses.filter(addr => !bannedSet.has(addr.toLowerCase())); + ? allJoinedAddresses + : allJoinedAddresses.filter(addr => !bannedSet.has(addr.toLowerCase())); this.setSpinnerText(`Fetching details for ${allAddresses.length} validators...`); diff --git a/src/commands/staking/validatorPrime.ts b/src/commands/staking/validatorPrime.ts index 7cb96c0a..97b5f133 100644 --- a/src/commands/staking/validatorPrime.ts +++ b/src/commands/staking/validatorPrime.ts @@ -116,9 +116,9 @@ export class ValidatorPrimeAction extends StakingAction { try { const client = await this.getStakingClient(options); - // Get all validators from tree + // Get all validators from the joined registry this.setSpinnerText("Fetching validators..."); - const allValidators = await this.getAllValidatorsFromTree(options); + const allValidators = await this.getJoinedValidators(options); this.stopSpinner(); console.log(`\nPriming ${allValidators.length} validators:\n`); @@ -158,7 +158,7 @@ export class ValidatorPrimeAction extends StakingAction { try { this.startSpinner("Fetching validators..."); - const allValidators = await this.getAllValidatorsFromTree(options); + const allValidators = await this.getJoinedValidators(options); const client = this.getBrowserStakingClient(options, session) as ClientWithPrime; this.stopSpinner(); diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts index 8399f539..ff79e41e 100644 --- a/src/commands/staking/validators.ts +++ b/src/commands/staking/validators.ts @@ -110,9 +110,8 @@ export class ValidatorsAction extends StakingAction { // Listing validators should not require a local account or session. } - const [allTreeAddresses, activeAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([ - this.getAllValidatorsFromTree(options), - client.getActiveValidators(), + const [allJoinedAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([ + this.getJoinedValidators(options), client.getQuarantinedValidatorsDetailed(), client.getBannedValidators(), client.getEpochInfo(), @@ -120,13 +119,21 @@ export class ValidatorsAction extends StakingAction { const quarantinedSet = new Map(quarantinedList.map((v: any) => [v.validator.toLowerCase(), v])); const bannedSet = new Map(bannedList.map((v: any) => [v.validator.toLowerCase(), v])); - const activeSet = new Set(activeAddresses.map((a: string) => a.toLowerCase())); + + // With activeValidators() withdrawn there is no single read that answers + // "in the current draw", so the active marker is derived from what is + // still readable: joined, and neither banned nor quarantined. + const activeSet = new Set( + allJoinedAddresses + .map((a: string) => a.toLowerCase()) + .filter((a: string) => !bannedSet.has(a) && !quarantinedSet.has(a)), + ); const currentEpoch = BigInt(epochInfo.currentEpoch); const validatorMinStakeRaw = BigInt(epochInfo.validatorMinStakeRaw ?? 0n); const allAddresses: Address[] = options.all - ? allTreeAddresses - : allTreeAddresses.filter((addr: Address) => !bannedSet.has(addr.toLowerCase())); + ? allJoinedAddresses + : allJoinedAddresses.filter((addr: Address) => !bannedSet.has(addr.toLowerCase())); this.setSpinnerText(`Fetching details for ${allAddresses.length} validators...`); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index c2744324..58731a94 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -651,7 +651,7 @@ describe("StakingInfoAction", () => { }); test("lists active validators", async () => { - mockClient.getActiveValidators.mockResolvedValue(["0xV1", "0xV2", "0xV3"]); + vi.spyOn(action as any, "getJoinedValidators").mockResolvedValue(["0xV1", "0xV2", "0xV3"]); await action.listActiveValidators({stakingAddress: "0xStaking"}); diff --git a/tests/commands/stakingValidators.test.ts b/tests/commands/stakingValidators.test.ts index 6ce6beb5..bb2caa22 100644 --- a/tests/commands/stakingValidators.test.ts +++ b/tests/commands/stakingValidators.test.ts @@ -64,7 +64,6 @@ function createMockClient({ ]); return { - getActiveValidators: vi.fn().mockResolvedValue([A]), getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), getBannedValidators: vi.fn().mockResolvedValue([]), getEpochInfo: vi.fn().mockResolvedValue({ @@ -86,7 +85,7 @@ function setupAction(mockClient = createMockClient()) { throw new Error(`${message}: ${String(error)}`); }); vi.spyOn(action as any, "getReadOnlyStakingClient").mockResolvedValue(mockClient); - vi.spyOn(action as any, "getAllValidatorsFromTree").mockResolvedValue([A, B]); + vi.spyOn(action as any, "getJoinedValidators").mockResolvedValue([A, B]); vi.spyOn(action as any, "getSignerAddress").mockRejectedValue(new Error("no account")); vi.spyOn(action as any, "getConfig").mockReturnValue({network: "localnet"}); vi.spyOn(action as any, "formatAmount").mockImplementation((amount: unknown) => String((amount as bigint) / GEN) + " GEN"); From b40afea74ca7afe8aaf40cc2433030fb0b06ba3e Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 17:56:24 +0100 Subject: [PATCH 2/9] fix(staking): preserve train validator UX --- .github/e2e-track | 2 +- README.md | 10 +- .../configuration/network/add.mdx | 1 + docs/api-references/index.mdx | 2 +- docs/api-references/staking/staking.mdx | 9 +- .../staking/staking/active-validators.mdx | 2 +- .../staking/complete-operator-transfer.mdx | 25 +++ .../staking/initiate-operator-transfer.mdx | 29 ++++ .../staking/staking/joined-validators.mdx | 18 ++ .../staking/staking/set-operator.mdx | 4 +- .../staking/staking/validator-deposit.mdx | 1 + .../staking/staking/validator-info.mdx | 1 + .../staking/staking/validator-join.mdx | 4 +- .../api-references/staking/staking/wizard.mdx | 21 ++- docs/api-references/transactions/receipt.mdx | 2 +- .../vesting/validator/create.mdx | 3 +- .../vesting/validator/deposit.mdx | 1 + .../api-references/vesting/validator/join.mdx | 3 +- docs/delegator-guide.md | 2 +- docs/validator-guide.md | 17 +- package-lock.json | 5 +- package.json | 2 +- src/commands/balances/BalancesAction.ts | 35 ++-- src/commands/contracts/execution.ts | 7 + src/commands/staking/StakingAction.ts | 85 +--------- src/commands/staking/index.ts | 15 +- src/commands/staking/setOperator.ts | 160 ++++++------------ src/commands/staking/stakingInfo.ts | 60 ++++--- src/commands/staking/validators.ts | 15 +- src/commands/vesting/vestingTypes.ts | 1 + tests/actions/balances.test.ts | 45 ++--- tests/actions/deploy.test.ts | 21 +++ tests/actions/staking.test.ts | 96 +++++++---- tests/actions/write.test.ts | 21 +++ tests/commands/balances.test.ts | 4 +- tests/commands/staking.test.ts | 9 + tests/commands/stakingValidators.test.ts | 46 ++++- tests/smoke.test.ts | 11 ++ 38 files changed, 463 insertions(+), 332 deletions(-) create mode 100644 docs/api-references/staking/staking/complete-operator-transfer.mdx create mode 100644 docs/api-references/staking/staking/initiate-operator-transfer.mdx create mode 100644 docs/api-references/staking/staking/joined-validators.mdx diff --git a/.github/e2e-track b/.github/e2e-track index 83b4ac55..74d51203 100644 --- a/.github/e2e-track +++ b/.github/e2e-track @@ -1 +1 @@ -v0.5 +v0.6 diff --git a/README.md b/README.md index 585d80a2..792cdf37 100644 --- a/README.md +++ b/README.md @@ -485,7 +485,8 @@ COMMANDS: delegation-info [validator] Get delegation info for a delegator with a validator epoch-info [options] Get current/previous epoch info (--epoch for specific) validators [options] Show validator set with stake, primed status, and weight - active-validators [options] List all active validators + active-validators [options] List validators currently eligible for consensus duties + joined-validators [options] List every validator in the joined registry quarantined-validators List all quarantined validators banned-validators List all banned validators @@ -551,7 +552,7 @@ EXAMPLES: # # Current Epoch: 5 (started 9h 30m ago) # Next Epoch: in 14h 30m - # Validators: 33 + # Active Validators: 33 # ... # # Previous Epoch: 4 (finalized) @@ -563,7 +564,7 @@ EXAMPLES: # Query specific epoch data genlayer staking epoch-info --epoch 4 - # List active validators + # List validators currently eligible for consensus duties genlayer staking active-validators # Output: # { @@ -575,6 +576,9 @@ EXAMPLES: # ] # } + # List every joined validator, including identities that are not currently active + genlayer staking joined-validators + # Show validator set table with stake, status, weight genlayer staking validators genlayer staking validators --all # Include banned validators diff --git a/docs/api-references/configuration/network/add.mdx b/docs/api-references/configuration/network/add.mdx index a070fa9c..5c28c5b3 100644 --- a/docs/api-references/configuration/network/add.mdx +++ b/docs/api-references/configuration/network/add.mdx @@ -29,4 +29,5 @@ alias Custom network alias | | --rounds-storage <addr> | RoundsStorage contract address override | No | | | | --appeals <addr> | Appeals contract address override | No | | | | --chain-id <n> | Chain ID override | No | | +| | --explorer <url> | Block explorer URL for this custom network (custom networks do NOT inherit the base's explorer, to avoid misleading links) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/index.mdx b/docs/api-references/index.mdx index 6caf0ea5..f0ec5483 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -6,7 +6,7 @@ GenLayer CLI is a development environment for the GenLayer ecosystem. It allows developers to interact with the protocol by creating accounts, sending transactions, and working with Intelligent Contracts by testing, debugging, and deploying them. -Version: `0.40.0-clarke.2` +Version: `0.40.0-clarke.4` ### Command List diff --git a/docs/api-references/staking/staking.mdx b/docs/api-references/staking/staking.mdx index 1845fe47..a4f58aec 100644 --- a/docs/api-references/staking/staking.mdx +++ b/docs/api-references/staking/staking.mdx @@ -20,14 +20,16 @@ Staking operations for validators and delegators ### Subcommands -- `genlayer wizard` — Interactive wizard to become a validator: funds the stake from your wallet or a vesting contract, and signs with a keystore key or a browser wallet (--wallet browser) +- `genlayer wizard` — Interactive wizard to become a validator: funds the stake from your wallet or a vesting contract, and signs with a keystore key or a browser wallet (--wallet browser). Every prompt can be supplied by a flag; pass --non-interactive to run scripted with zero prompts - `genlayer validator-join` — Join as a validator by staking tokens - `genlayer validator-deposit` — Make an additional deposit to a validator wallet - `genlayer validator-exit` — Exit as a validator by withdrawing shares - `genlayer validator-claim` — Claim validator withdrawals after unbonding period - `genlayer validator-prime` — Prime a validator to prepare their stake record for the next epoch - `genlayer prime-all` — Prime all validators that need priming -- `genlayer set-operator` — Change the operator address for a validator wallet +- `genlayer set-operator` — Rotate a validator operator using a possession proof +- `genlayer initiate-operator-transfer` — Start a two-step operator rotation for a validator wallet +- `genlayer complete-operator-transfer` — Finalise a pending operator rotation once its delay has elapsed - `genlayer set-identity` — Set validator identity metadata (moniker, website, socials, etc.) - `genlayer delegator-join` — Join as a delegator by staking with a validator - `genlayer delegator-exit` — Exit as a delegator by withdrawing shares from a validator @@ -35,7 +37,8 @@ Staking operations for validators and delegators - `genlayer validator-info` — Get information about a validator - `genlayer delegation-info` — Get delegation info for a delegator with a validator - `genlayer epoch-info` — Get current epoch and staking parameters -- `genlayer active-validators` — List all active validators +- `genlayer active-validators` — List validators currently eligible for consensus duties +- `genlayer joined-validators` — List every validator in the joined registry - `genlayer quarantined-validators` — List all quarantined validators - `genlayer banned-validators` — List all banned validators - `genlayer validators` — List validators with stake, status, and optional explorer performance diff --git a/docs/api-references/staking/staking/active-validators.mdx b/docs/api-references/staking/staking/active-validators.mdx index 441eba20..63123c89 100644 --- a/docs/api-references/staking/staking/active-validators.mdx +++ b/docs/api-references/staking/staking/active-validators.mdx @@ -2,7 +2,7 @@ title: staking active-validators --- -List all active validators +List validators currently eligible for consensus duties ### Usage diff --git a/docs/api-references/staking/staking/complete-operator-transfer.mdx b/docs/api-references/staking/staking/complete-operator-transfer.mdx new file mode 100644 index 00000000..9d7d1b2c --- /dev/null +++ b/docs/api-references/staking/staking/complete-operator-transfer.mdx @@ -0,0 +1,25 @@ +--- +title: staking complete-operator-transfer +--- + +Finalise a pending operator rotation once its delay has elapsed + +### Usage + +`$ genlayer staking complete-operator-transfer [options] [validator]` + +### Arguments + +- `[validator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --validator <address> | Validator wallet address | No | | +| | --account <name> | Account to use (validator owner or pending operator) | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/initiate-operator-transfer.mdx b/docs/api-references/staking/staking/initiate-operator-transfer.mdx new file mode 100644 index 00000000..e4d8db99 --- /dev/null +++ b/docs/api-references/staking/staking/initiate-operator-transfer.mdx @@ -0,0 +1,29 @@ +--- +title: staking initiate-operator-transfer +--- + +Start a two-step operator rotation for a validator wallet + +### Usage + +`$ genlayer staking initiate-operator-transfer [options] [validator] [operator]` + +### Arguments + +- `[validator]` +- `[operator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --validator <address> | Validator wallet address | No | | +| | --operator <address> | Incoming operator address (key must be in the local keystore) | No | | +| | --operator-account <name> | Keystore account holding the incoming operator key | No | | +| | --operator-password <password> | Password to unlock the incoming operator account | No | | +| | --account <name> | Account to use (must be validator owner) | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/joined-validators.mdx b/docs/api-references/staking/staking/joined-validators.mdx new file mode 100644 index 00000000..24859446 --- /dev/null +++ b/docs/api-references/staking/staking/joined-validators.mdx @@ -0,0 +1,18 @@ +--- +title: staking joined-validators +--- + +List every validator in the joined registry + +### Usage + +`$ genlayer staking joined-validators [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/set-operator.mdx b/docs/api-references/staking/staking/set-operator.mdx index bde668f7..f091d3db 100644 --- a/docs/api-references/staking/staking/set-operator.mdx +++ b/docs/api-references/staking/staking/set-operator.mdx @@ -2,7 +2,7 @@ title: staking set-operator --- -Change the operator address for a validator wallet +Rotate a validator operator using a possession proof ### Usage @@ -19,6 +19,8 @@ Change the operator address for a validator wallet | --- | --- | --- | :---: | --- | | | --validator <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --operator <address> | New operator address (deprecated, use positional arg) | No | | +| | --operator-account <name> | Keystore account holding the incoming operator key | No | | +| | --operator-password <password> | Password to unlock the incoming operator account | No | | | | --account <name> | Account to use (must be validator owner) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | diff --git a/docs/api-references/staking/staking/validator-deposit.mdx b/docs/api-references/staking/staking/validator-deposit.mdx index 029c7071..aefabee8 100644 --- a/docs/api-references/staking/staking/validator-deposit.mdx +++ b/docs/api-references/staking/staking/validator-deposit.mdx @@ -22,5 +22,6 @@ Make an additional deposit to a validator wallet | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --force | Proceed even if self-stake is below the minimum required to become active | No | | | | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-info.mdx b/docs/api-references/staking/staking/validator-info.mdx index f0088acf..31c6aca4 100644 --- a/docs/api-references/staking/staking/validator-info.mdx +++ b/docs/api-references/staking/staking/validator-info.mdx @@ -21,5 +21,6 @@ Get information about a validator | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --json | Output raw validator info as machine-readable JSON | No | | | | --debug | Show raw unfiltered pending deposits/withdrawals | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-join.mdx b/docs/api-references/staking/staking/validator-join.mdx index 09a65a35..3673f2ad 100644 --- a/docs/api-references/staking/staking/validator-join.mdx +++ b/docs/api-references/staking/staking/validator-join.mdx @@ -13,11 +13,13 @@ Join as a validator by staking tokens | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --amount <amount> | Amount to stake (in wei or with 'eth'/'gen' suffix, e.g., '42000gen') | No | | -| | --operator <address> | Operator address (defaults to signer) | No | | +| | --operator <address> | Operator address for an imported local CLI account (defaults to signer) | No | | +| | --operator-password <password> | Password for the selected operator keystore (for non-interactive proof signing) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --force | Proceed even if self-stake is below the minimum required to become active | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/wizard.mdx b/docs/api-references/staking/staking/wizard.mdx index 1da536cc..c0407e15 100644 --- a/docs/api-references/staking/staking/wizard.mdx +++ b/docs/api-references/staking/staking/wizard.mdx @@ -4,7 +4,8 @@ title: staking wizard Interactive wizard to become a validator: funds the stake from your wallet or a vesting contract, and signs with a keystore key or a browser wallet (--wallet -browser) +browser). Every prompt can be supplied by a flag; pass --non-interactive to run +scripted with zero prompts ### Usage @@ -19,5 +20,23 @@ browser) | | --skip-identity | Skip identity setup step | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --non-interactive | Run end-to-end with no prompts; every choice must come from a flag | No | | +| | --yes | Alias for --non-interactive (assume yes to confirmations) | No | | +| | --funding-source <source> | Where the self-stake is funded from: 'wallet' (default) or 'vesting' | No | | +| | --vesting-contract <address> | Vesting contract to fund from (with --funding-source vesting) | No | | +| | --operator <address> | Operator address for an imported local CLI account (0x...) | No | | +| | --create-operator <name> | Create a new operator account and export its keystore | No | | +| | --operator-same | Use the owner address as the operator | No | | +| | --operator-password <password> | Password for the exported operator keystore (with --create-operator) | No | | +| | --operator-keystore-out <path> | Output filename for the exported operator keystore | No | | +| | --amount <amount> | Self-stake amount (GEN, e.g. '42' or '42gen') | No | | +| | --moniker <name> | Validator display name (enables the identity step) | No | | +| | --logo-uri <uri> | Logo URI | No | | +| | --website <url> | Website URL | No | | +| | --description <text> | Description | No | | +| | --email <email> | Contact email | No | | +| | --twitter <handle> | Twitter handle | No | | +| | --telegram <handle> | Telegram handle | No | | +| | --github <handle> | GitHub handle | No | | | | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/transactions/receipt.mdx b/docs/api-references/transactions/receipt.mdx index 1ab60eda..61c13305 100644 --- a/docs/api-references/transactions/receipt.mdx +++ b/docs/api-references/transactions/receipt.mdx @@ -16,7 +16,7 @@ Get transaction receipt by hash | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --status <status> | Transaction status to wait for (UNINITIALIZED, PENDING, PROPOSING, COMMITTING, REVEALING, ACCEPTED, UNDETERMINED, FINALIZED, CANCELED, APPEAL_REVEALING, APPEAL_COMMITTING, READY_TO_FINALIZE, VALIDATORS_TIMEOUT, LEADER_TIMEOUT, LEADER_REVEALING) | No | `FINALIZED` | +| | --status <status> | Transaction status to wait for (UNINITIALIZED, PENDING, PROPOSING, COMMITTING, REVEALING, ACCEPTED, UNDETERMINED, FINALIZED, CANCELED, APPEAL_REVEALING, APPEAL_COMMITTING, VALIDATORS_TIMEOUT, LEADER_TIMEOUT, LEADER_REVEALING) | No | `FINALIZED` | | | --retries <retries> | Number of retries | No | `100` | | | --interval <interval> | Interval between retries in milliseconds (default: 5000) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | diff --git a/docs/api-references/vesting/validator/create.mdx b/docs/api-references/vesting/validator/create.mdx index f6b28c43..f6df2fe3 100644 --- a/docs/api-references/vesting/validator/create.mdx +++ b/docs/api-references/vesting/validator/create.mdx @@ -16,8 +16,9 @@ Create a vesting-backed validator | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --operator <address> | Operator address (deprecated, use positional arg) | No | | +| | --operator <address> | Operator address for an imported local CLI account (deprecated, use positional arg) | No | | | | --amount <amount> | Amount to self-stake (in wei or with 'eth'/'gen' suffix) | No | | +| | --force | Proceed even if self-stake is below the minimum required to become active | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | diff --git a/docs/api-references/vesting/validator/deposit.mdx b/docs/api-references/vesting/validator/deposit.mdx index fcf8099b..79bc701c 100644 --- a/docs/api-references/vesting/validator/deposit.mdx +++ b/docs/api-references/vesting/validator/deposit.mdx @@ -17,6 +17,7 @@ Deposit more vesting-held tokens to a validator wallet | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --amount <amount> | Amount to deposit (in wei or with 'eth'/'gen' suffix) | No | | +| | --force | Proceed even if self-stake is below the minimum required to become active | No | | | | --validator-wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | diff --git a/docs/api-references/vesting/validator/join.mdx b/docs/api-references/vesting/validator/join.mdx index 35a4a9f1..e8891554 100644 --- a/docs/api-references/vesting/validator/join.mdx +++ b/docs/api-references/vesting/validator/join.mdx @@ -16,8 +16,9 @@ Create a vesting-backed validator | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --operator <address> | Operator address (deprecated, use positional arg) | No | | +| | --operator <address> | Operator address for an imported local CLI account (deprecated, use positional arg) | No | | | | --amount <amount> | Amount to self-stake (in wei or with 'eth'/'gen' suffix) | No | | +| | --force | Proceed even if self-stake is below the minimum required to become active | No | | | | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | diff --git a/docs/delegator-guide.md b/docs/delegator-guide.md index 7c5b1e29..3c67430a 100644 --- a/docs/delegator-guide.md +++ b/docs/delegator-guide.md @@ -44,7 +44,7 @@ Note the `delegatorMinStake` - you need at least this amount. ## Step 5: Find a Validator -List all active validators: +List validators currently eligible for consensus duties: ```bash genlayer staking active-validators diff --git a/docs/validator-guide.md b/docs/validator-guide.md index e023b04a..419bfe07 100644 --- a/docs/validator-guide.md +++ b/docs/validator-guide.md @@ -92,7 +92,7 @@ Output: Current Epoch: 5 (started 9h 30m ago) Next Epoch: in 14h 30m - Validators: 33 + Active Validators: 33 Weight: 6061746783417938774454 Slashed: 0 GEN @@ -165,9 +165,17 @@ Transfer `operator-keystore.json` to your validator server and import it into yo You can change the operator later: ```bash -genlayer staking set-operator --validator 0xYourValidator... --operator 0xNewOperator... +genlayer account create --name new-operator +genlayer staking set-operator 0xYourValidator... 0xNewOperator... \ + --account validator-owner \ + --operator-account new-operator ``` +The incoming operator key signs a possession proof. The validator owner then +initiates the transfer. If the configured transfer delay has not elapsed, the +command leaves the transfer pending and prints the exact +`complete-operator-transfer` command to run later. + ## Step 8: Verify Your Validator Status ```bash @@ -260,6 +268,8 @@ genlayer staking validator-deposit --validator 0xYourValidatorWallet... --amount ### Check Active Validators +This lists only validators currently eligible for consensus duties: + ```bash genlayer staking active-validators ``` @@ -270,6 +280,9 @@ genlayer staking active-validators # Show all validators with stake, primed status, and weight genlayer staking validators +# Show the raw joined registry, including validators that are not currently active +genlayer staking joined-validators + # Include banned validators genlayer staking validators --all ``` diff --git a/package-lock.json b/package-lock.json index 57da0433..59315768 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#6f1273885567ff5cda77b7459edfd6666c5859d0", + "genlayer-js": "github:genlayerlabs/genlayer-js#71a201b78501e8e0e524298779180b35b2b209de", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -5683,8 +5683,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#6f1273885567ff5cda77b7459edfd6666c5859d0", - "integrity": "sha512-ts4KjgqO/qR8pKiCOp+g5FKbAKUyLLIFTuXX6+ZjFYKOhTHPP3kL+CR4siVKp4YGVWWk+/DWeVXgCMZOBo1QLA==", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#71a201b78501e8e0e524298779180b35b2b209de", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 02f0ee9f..7feaa612 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#6f1273885567ff5cda77b7459edfd6666c5859d0", + "genlayer-js": "github:genlayerlabs/genlayer-js#71a201b78501e8e0e524298779180b35b2b209de", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts index fbb3af26..751dd160 100644 --- a/src/commands/balances/BalancesAction.ts +++ b/src/commands/balances/BalancesAction.ts @@ -92,12 +92,12 @@ export class BalancesAction extends VestingAction { if (vestingAddresses.length > 0) { // The validator set is global; fetch it once and reuse across every // vesting. Committed-delegation lookup is O(#vestings × #validators). - // A vesting can hold committed principal against validators that later - // left the active set (quarantined/banned) — scanning only the active - // set would under-count committed and thus mis-state - // available-to-stake, so union active + quarantined + banned. A - // network can have consensus (vesting factory) but no staking - // contract, so still gate the scan on staking availability. + // A vesting can hold committed principal against any validator that + // joined, including identities that are not currently selectable. + // Use the append-only joined registry rather than trying to rebuild it + // from active/quarantine/ban subsets. A network can have consensus + // (vesting factory) but no staking contract, so still gate the scan on + // staking availability. const validatorSet = this.isStakingAvailable(chain) ? await this.getKnownValidatorSet(client) : []; @@ -167,28 +167,13 @@ export class BalancesAction extends VestingAction { } /** - * The full set of validators a vesting could have committed principal to: - * active + quarantined + banned, de-duplicated (case-insensitively, keeping - * the first-seen casing). Committed principal survives a validator leaving the - * active set, so an active-only scan would under-count it. + * The full set of validators a vesting could have committed principal to. + * The append-only joined registry is authoritative and includes validators + * that are unprimed, below minimum stake, quarantined, banned, or exited. */ private async getKnownValidatorSet(client: VestingClient): Promise { this.setSpinnerText("Enumerating validator set..."); - const [active, quarantined, banned] = await Promise.all([ - client.getActiveValidators(), - client.getQuarantinedValidatorsDetailed(), - client.getBannedValidators(), - ]); - - const seen = new Map(); - const add = (addr: Address) => { - const key = addr.toLowerCase(); - if (!seen.has(key)) seen.set(key, addr); - }; - active.forEach(add); - quarantined.forEach(v => add(v.validator)); - banned.forEach(v => add(v.validator)); - return Array.from(seen.values()); + return client.getJoinedValidators(); } private async computeVestingSummary( diff --git a/src/commands/contracts/execution.ts b/src/commands/contracts/execution.ts index 69d45a75..d9956139 100644 --- a/src/commands/contracts/execution.ts +++ b/src/commands/contracts/execution.ts @@ -19,6 +19,9 @@ function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { if (normalized === ExecutionResult.NOT_VOTED) return ExecutionResult.NOT_VOTED; if (normalized === ExecutionResult.TIMEOUT) return ExecutionResult.TIMEOUT; if (normalized === ExecutionResult.NONDET_DISAGREE) return ExecutionResult.NONDET_DISAGREE; + if (normalized === ExecutionResult.DETERMINISTIC_VIOLATION) { + return ExecutionResult.DETERMINISTIC_VIOLATION; + } if (normalized === "NONDET_DISAGREE" || normalized === "NONDET_DISAGREEMENT") return ExecutionResult.NONDET_DISAGREE; if (normalized === "SUCCESS") return ExecutionResult.FINISHED_WITH_RETURN; if (normalized === "ERROR" || normalized === "FAILURE") return ExecutionResult.FINISHED_WITH_ERROR; @@ -31,6 +34,7 @@ function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { if (numeric === 2) return ExecutionResult.FINISHED_WITH_ERROR; if (numeric === 3) return ExecutionResult.TIMEOUT; if (numeric === 4) return ExecutionResult.NONDET_DISAGREE; + if (numeric === 5) return ExecutionResult.DETERMINISTIC_VIOLATION; } return undefined; } @@ -113,6 +117,9 @@ function executionDiagnosis(result: ExecutionResult | undefined): string { if (result === ExecutionResult.NONDET_DISAGREE) { return "NONDET_DISAGREE (validators disagreed on non-deterministic output)"; } + if (result === ExecutionResult.DETERMINISTIC_VIOLATION) { + return "DETERMINISTIC_VIOLATION (execution violated deterministic consensus rules)"; + } return result ?? "UNKNOWN"; } diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 3612baa4..639c9611 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -15,43 +15,10 @@ import type { OperatorRegistrationContext, } from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; -import {ethers, ZeroAddress} from "ethers"; -import {createPublicClient, http} from "viem"; -import {glHttpConfig, type BrowserSession} from "../../lib/wallet/browserSend"; +import {ethers} from "ethers"; +import {type BrowserSession} from "../../lib/wallet/browserSend"; import {resolveBrowserWalletSession} from "../../lib/wallet/sessionResolver"; -// Extended ABI for the joined-validator registry (not in SDK). -// -// The staking contract no longer exposes the balanced-tree view the CLI used to -// walk: validatorsRoot() is gone, and validatorView() no longer carries the -// left/right/parent links the walk needed. The joined validators are read from -// an append-only registry instead, one page at a time. -const STAKING_REGISTRY_ABI = [ - { - name: "validatorsJoinedCount", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{name: "", type: "uint256"}], - }, - { - name: "getValidatorsJoined", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_startIndex", type: "uint256"}, - {name: "_pageSize", type: "uint256"}, - ], - outputs: [{name: "", type: "address[]"}], - }, -] as const; - -// Committee capacity is 1,543 seats, and an address[] that long overruns the -// return-size limit — which is why the unpaged read was withdrawn in the first -// place. This is the page size the contract's own paged reads are written -// around; nothing truncates or auto-switches, so the caller does the walking. -const VALIDATORS_JOINED_PAGE_SIZE = 64n; - // Re-export for use by other staking commands export {BUILT_IN_NETWORKS}; @@ -387,50 +354,14 @@ export class StakingAction extends BaseAction { } /** - * Get every validator in the joined registry, read one page at a time. + * Get every validator in the append-only joined registry. * - * This is the whole joined set, not the subset eligible for the current - * epoch's draw — so, like the tree walk it replaces, it includes validators - * that have not been primed yet. + * This is deliberately distinct from getActiveValidators(): joined includes + * validators that are not currently eligible for consensus duties. Paging is + * owned by the SDK so every consumer observes the same registry semantics. */ protected async getJoinedValidators(config: StakingConfig): Promise { - const network = this.getNetwork(config); - const rpcUrl = config.rpc || network.rpcUrls.default.http[0]; - const stakingAddress = config.stakingAddress || network.stakingContract?.address; - - if (!stakingAddress) { - throw new Error("Staking contract address not configured"); - } - - const publicClient = createPublicClient({ - chain: network, - transport: http(rpcUrl, glHttpConfig), - }); - - // Read the count first so a set that grows underneath the walk cannot spin - // the loop. A short or empty page means it shrank instead: stop there and - // let the next read see the settled set. - const total = (await publicClient.readContract({ - address: stakingAddress as `0x${string}`, - abi: STAKING_REGISTRY_ABI, - functionName: "validatorsJoinedCount", - })) as bigint; - - const validators: Address[] = []; - - for (let start = 0n; start < total; start += VALIDATORS_JOINED_PAGE_SIZE) { - const page = (await publicClient.readContract({ - address: stakingAddress as `0x${string}`, - abi: STAKING_REGISTRY_ABI, - functionName: "getValidatorsJoined", - args: [start, VALIDATORS_JOINED_PAGE_SIZE], - })) as Address[]; - - if (page.length === 0) break; - - validators.push(...page); - } - - return validators.filter(v => v !== ZeroAddress); + const client = await this.getReadOnlyStakingClient(config); + return client.getJoinedValidators(); } } diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index 69cc370b..f728f1ce 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -193,7 +193,7 @@ export function initializeStakingCommands(program: Command) { addWalletModeOption( staking .command("set-operator [validator] [operator]") - .description("Change the operator address for a validator wallet") + .description("Rotate a validator operator using a possession proof") .option("--validator
", "Validator wallet address (deprecated, use positional arg)") .option("--operator
", "New operator address (deprecated, use positional arg)") .option("--operator-account ", "Keystore account holding the incoming operator key") @@ -410,7 +410,7 @@ export function initializeStakingCommands(program: Command) { staking .command("active-validators") - .description("List all active validators") + .description("List validators currently eligible for consensus duties") .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") @@ -419,6 +419,17 @@ export function initializeStakingCommands(program: Command) { await action.listActiveValidators(options); }); + staking + .command("joined-validators") + .description("List every validator in the joined registry") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + .action(async (options: StakingInfoOptions) => { + const action = new StakingInfoAction(); + await action.listJoinedValidators(options); + }); + staking .command("quarantined-validators") .description("List all quarantined validators") diff --git a/src/commands/staking/setOperator.ts b/src/commands/staking/setOperator.ts index 79538fd9..565ca301 100644 --- a/src/commands/staking/setOperator.ts +++ b/src/commands/staking/setOperator.ts @@ -4,7 +4,6 @@ import type { GenLayerClient, GenLayerChain, OperatorRegistrationProof, - SetOperatorOptions as SdkSetOperatorOptions, StakingTransactionResult, } from "genlayer-js/types"; @@ -19,9 +18,8 @@ export interface SetOperatorOptions extends StakingConfig { * CON-715 replaced the wallet's single-call setOperator with a two-step * rotation: the owner initiates with a possession proof signed by the incoming * operator, then completes once the factory's operatorTransferDelay elapses. - * Both surfaces exist in the wild — older deployments only have setOperator, - * newer ones only have the pair — so this command prefers the two-step flow and - * falls back when the wallet does not expose it. + * The command name remains familiar, but it always uses the train's proof-bound + * two-step flow. */ type OperatorTransferClient = GenLayerClient & { initiateOperatorTransfer(o: { @@ -32,20 +30,6 @@ type OperatorTransferClient = GenLayerClient & { getPendingOperator(validator: Address): Promise<{operator: Address; initiatedAt: bigint}>; }; -/** - * A wallet without the new surface has no such selector, so the call reverts - * with no decodable reason. Treat that — and an explicitly unknown function — - * as "this deployment predates CON-715" and retry the legacy path. - */ -function looksLikeMissingSelector(error: any): boolean { - const message = String(error?.message ?? error ?? ""); - return ( - /unknown reason/i.test(message) || - /function .*not found/i.test(message) || - /execution reverted/i.test(message) - ); -} - export class SetOperatorAction extends StakingAction { constructor() { super(); @@ -61,16 +45,7 @@ export class SetOperatorAction extends StakingAction { try { const validatorWallet = options.validator as Address; - // Route through the SDK staking client rather than a raw viem - // writeContract. The SDK's executeWrite pins `type: "legacy"` and does - // manual nonce/gas + sign + sendRawTransaction, which the GenLayer - // consensus RPC requires (it has no EIP-1559 fee support, so viem's - // default fee/tx-type negotiation fails). `setOperator` exists on the - // client at runtime but is missing from the installed genlayer-js - // StakingActions .d.ts — cast to bridge that type gap. - const client = (await this.getStakingClient(options)) as GenLayerClient & { - setOperator(o: SdkSetOperatorOptions): Promise; - }; + const client = await this.getStakingClient(options); this.setSpinnerText(`Setting operator to ${options.operator}...`); @@ -83,7 +58,7 @@ export class SetOperatorAction extends StakingAction { } /** - * Rotates via initiate + complete, falling back to the retired single call. + * Rotates via the train's initiate + complete flow. * * The incoming operator must sign its own possession proof, so its key has to * be reachable: --operator-account names it, otherwise we look it up in the @@ -92,76 +67,63 @@ export class SetOperatorAction extends StakingAction { * caller is told to finish it with complete-operator-transfer. */ private async rotateOperator( - client: GenLayerClient & { - setOperator(o: SdkSetOperatorOptions): Promise; - }, + client: GenLayerClient, validatorWallet: Address, options: SetOperatorOptions, ): Promise> { const operatorAccount = options.operatorAccount || this.findLocalAccountByAddress(options.operator); - if (operatorAccount) { - try { - const registration = await this.createOperatorTransferRegistration( - client, - validatorWallet, - operatorAccount, - options.operatorPassword, - ); - const transferClient = client as unknown as OperatorTransferClient; - - this.setSpinnerText(`Initiating operator transfer to ${options.operator}...`); - const initiated = await transferClient.initiateOperatorTransfer({ - validator: validatorWallet, - registration, - }); - - this.setSpinnerText("Completing operator transfer..."); - try { - const completed = await transferClient.completeOperatorTransfer({validator: validatorWallet}); - return { - transactionHash: completed.transactionHash, - initiateTransactionHash: initiated.transactionHash, - validator: validatorWallet, - newOperator: options.operator, - blockNumber: completed.blockNumber.toString(), - gasUsed: completed.gasUsed.toString(), - }; - } catch (completeError: any) { - return { - transactionHash: initiated.transactionHash, - validator: validatorWallet, - pendingOperator: options.operator, - blockNumber: initiated.blockNumber.toString(), - gasUsed: initiated.gasUsed.toString(), - note: - "Transfer initiated but not yet effective: " + - `${completeError?.message ?? completeError}. ` + - `Run: genlayer staking complete-operator-transfer ${validatorWallet}`, - }; - } - } catch (error: any) { - if (!looksLikeMissingSelector(error)) { - throw error; - } - // Wallet predates CON-715 — fall through to the single-call surface. - } + if (!operatorAccount) { + throw new Error( + "The incoming operator must sign its possession proof. Pass --operator-account " + + ", or use an operator address whose key is in the local keystore.", + ); + } + const registration = await this.createOperatorTransferRegistration( + client, + validatorWallet, + operatorAccount, + options.operatorPassword, + ); + if (registration.operator.toLowerCase() !== options.operator.toLowerCase()) { + throw new Error( + `--operator ${options.operator} does not match the key in --operator-account ` + + `${operatorAccount} (${registration.operator}).`, + ); } + const transferClient = client as unknown as OperatorTransferClient; - this.setSpinnerText(`Setting operator to ${options.operator}...`); - const result = await client.setOperator({ + this.setSpinnerText(`Initiating operator transfer to ${options.operator}...`); + const initiated = await transferClient.initiateOperatorTransfer({ validator: validatorWallet, - operator: options.operator as Address, + registration, }); - return { - transactionHash: result.transactionHash, - validator: validatorWallet, - newOperator: options.operator, - blockNumber: result.blockNumber.toString(), - gasUsed: result.gasUsed.toString(), - }; + this.setSpinnerText("Completing operator transfer..."); + try { + const completed = await transferClient.completeOperatorTransfer({validator: validatorWallet}); + return { + transactionHash: completed.transactionHash, + initiateTransactionHash: initiated.transactionHash, + validator: validatorWallet, + newOperator: options.operator, + blockNumber: completed.blockNumber.toString(), + gasUsed: completed.gasUsed.toString(), + }; + } catch (completeError: any) { + return { + transactionHash: initiated.transactionHash, + validator: validatorWallet, + pendingOperator: options.operator, + blockNumber: initiated.blockNumber.toString(), + gasUsed: initiated.gasUsed.toString(), + note: + "Transfer initiated but not yet effective: " + + `${completeError?.message ?? completeError}. ` + + `Run: genlayer staking complete-operator-transfer ${validatorWallet}`, + }; + } } private async executeWithBrowserWallet(options: SetOperatorOptions): Promise { @@ -176,26 +138,12 @@ export class SetOperatorAction extends StakingAction { this.startSpinner("Confirm the transaction in your browser wallet..."); try { const validatorWallet = options.validator as Address; - // `setOperator` exists at runtime but is missing from the installed - // genlayer-js StakingActions .d.ts — cast to bridge that type gap. - const client = this.getBrowserStakingClient(options, session) as GenLayerClient & { - setOperator(o: SdkSetOperatorOptions): Promise; - }; + const client = this.getBrowserStakingClient(options, session); this.log(` From (browser wallet): ${session.signerAddress}`); - session.setNextLabel(`Set operator to ${options.operator}`); - const result = await client.setOperator({ - validator: validatorWallet, - operator: options.operator as Address, - }); - - this.succeedSpinner("Operator updated!", { - transactionHash: result.transactionHash, - validator: validatorWallet, - newOperator: options.operator, - blockNumber: result.blockNumber.toString(), - gasUsed: result.gasUsed.toString(), - }); + session.setNextLabel(`Rotate operator to ${options.operator}`); + const output = await this.rotateOperator(client, validatorWallet, options); + this.succeedSpinner("Operator updated!", output); } catch (error: any) { this.failSpinner("Failed to set operator", error.message || error); } finally { diff --git a/src/commands/staking/stakingInfo.ts b/src/commands/staking/stakingInfo.ts index bc7b7bf1..e1cfcc28 100644 --- a/src/commands/staking/stakingInfo.ts +++ b/src/commands/staking/stakingInfo.ts @@ -427,7 +427,7 @@ export class StakingInfoAction extends StakingAction { console.log(`\n Current Epoch: ${info.currentEpoch} (started ${formatDuration(timeSinceStart)} ago)`); console.log(` Next Epoch: ${nextEstimate}`); - console.log(` Validators: ${info.activeValidatorsCount}`); + console.log(` Active Validators: ${info.activeValidatorsCount}`); console.log(` Weight: ${currentEpochData.weight}`); console.log(` Slashed: ${formatAmount(currentEpochData.slashed)}`); @@ -466,11 +466,8 @@ export class StakingInfoAction extends StakingAction { this.startSpinner("Fetching active validators..."); try { - // Read the registry directly rather than through the SDK: the staking - // contract dropped activeValidators(), and the unpaged read reverts - // rather than degrading, so the paged registry walk is the only surface - // that answers now. - const activeValidators = await this.getJoinedValidators(options); + const client = await this.getReadOnlyStakingClient(options); + const activeValidators = await client.getActiveValidators(); const result = { count: activeValidators.length, @@ -483,6 +480,23 @@ export class StakingInfoAction extends StakingAction { } } + async listJoinedValidators(options: StakingConfig): Promise { + this.startSpinner("Fetching joined validators..."); + + try { + const joinedValidators = await this.getJoinedValidators(options); + + const result = { + count: joinedValidators.length, + validators: joinedValidators, + }; + + this.succeedSpinner("Joined validators retrieved", result); + } catch (error: any) { + this.failSpinner("Failed to get joined validators", error.message || error); + } + } + async listQuarantinedValidators(options: StakingConfig): Promise { this.startSpinner("Fetching quarantined validators..."); @@ -545,27 +559,22 @@ export class StakingInfoAction extends StakingAction { } // Read the registry to get ALL validators (including not-yet-primed) - const allJoinedAddresses = await this.getJoinedValidators(options); - - // Also fetch status lists in parallel - const [quarantinedList, bannedList, epochInfo] = await Promise.all([ - client.getQuarantinedValidatorsDetailed(), - options.all ? client.getBannedValidators() : Promise.resolve([]), - client.getEpochInfo(), - ]); + // Joined is the complete registry; active is the strict subset currently + // selectable for consensus duties. Keep both identities explicit. + const [allJoinedAddresses, activeValidators, quarantinedList, bannedList, epochInfo] = + await Promise.all([ + this.getJoinedValidators(options), + client.getActiveValidators(), + client.getQuarantinedValidatorsDetailed(), + options.all ? client.getBannedValidators() : Promise.resolve([]), + client.getEpochInfo(), + ]); // Build set of quarantined/banned for status lookup const quarantinedSet = new Map(quarantinedList.map(v => [v.validator.toLowerCase(), v])); const bannedSet = new Map(bannedList.map(v => [v.validator.toLowerCase(), v])); - // With activeValidators() withdrawn there is no single read that answers - // "in the current draw", so the active marker is derived from what is - // still readable: joined, and neither banned nor quarantined. - const activeSet = new Set( - allJoinedAddresses - .map(a => a.toLowerCase()) - .filter(a => !bannedSet.has(a) && !quarantinedSet.has(a)), - ); + const activeSet = new Set(activeValidators.map(a => a.toLowerCase())); // Filter out banned if not --all const allAddresses = options.all @@ -613,8 +622,10 @@ export class StakingInfoAction extends StakingAction { status = `quarant(e${qInfo.untilEpoch})`; } else if (isActive) { status = "active"; + } else if (info.needsPriming) { + status = "needs-priming"; } else { - status = "pending"; + status = info.live ? "pending" : "inactive"; } const isMine = myAddress @@ -739,7 +750,8 @@ export class StakingInfoAction extends StakingAction { else if (status === "BANNED") statusStr = chalk.red(status); else if (status.startsWith("quarant")) statusStr = chalk.yellow(status); else if (status.startsWith("banned")) statusStr = chalk.red(status); - else if (status === "pending") statusStr = chalk.gray(status); + else if (status === "needs-priming") statusStr = chalk.yellow(status); + else if (status === "pending" || status === "inactive") statusStr = chalk.gray(status); table.push([ (idx + 1).toString(), diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts index ff79e41e..81313d21 100644 --- a/src/commands/staking/validators.ts +++ b/src/commands/staking/validators.ts @@ -110,8 +110,9 @@ export class ValidatorsAction extends StakingAction { // Listing validators should not require a local account or session. } - const [allJoinedAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([ + const [allJoinedAddresses, activeValidators, quarantinedList, bannedList, epochInfo] = await Promise.all([ this.getJoinedValidators(options), + client.getActiveValidators(), client.getQuarantinedValidatorsDetailed(), client.getBannedValidators(), client.getEpochInfo(), @@ -120,14 +121,7 @@ export class ValidatorsAction extends StakingAction { const quarantinedSet = new Map(quarantinedList.map((v: any) => [v.validator.toLowerCase(), v])); const bannedSet = new Map(bannedList.map((v: any) => [v.validator.toLowerCase(), v])); - // With activeValidators() withdrawn there is no single read that answers - // "in the current draw", so the active marker is derived from what is - // still readable: joined, and neither banned nor quarantined. - const activeSet = new Set( - allJoinedAddresses - .map((a: string) => a.toLowerCase()) - .filter((a: string) => !bannedSet.has(a) && !quarantinedSet.has(a)), - ); + const activeSet = new Set(activeValidators.map((a: string) => a.toLowerCase())); const currentEpoch = BigInt(epochInfo.currentEpoch); const validatorMinStakeRaw = BigInt(epochInfo.validatorMinStakeRaw ?? 0n); @@ -255,6 +249,8 @@ export class ValidatorsAction extends StakingAction { status = "inactive/below-min"; } else if (isActive) { status = "active"; + } else if (info.needsPriming) { + status = "needs-priming"; } else { status = info.live ? "pending" : "inactive"; } @@ -613,6 +609,7 @@ export class ValidatorsAction extends StakingAction { if (status.startsWith("banned")) return chalk.red(status); if (status.startsWith("quarantined")) return chalk.yellow(status); if (status === "inactive/below-min") return chalk.yellow(status); + if (status === "needs-priming") return chalk.yellow(status); if (status === "pending" || status === "pending-activation") return chalk.gray(status); return status; } diff --git a/src/commands/vesting/vestingTypes.ts b/src/commands/vesting/vestingTypes.ts index 5060f3f5..080940a9 100644 --- a/src/commands/vesting/vestingTypes.ts +++ b/src/commands/vesting/vestingTypes.ts @@ -161,6 +161,7 @@ export type VestingClient = GenLayerClient & { validatorDeposited: (vesting: Address, wallet: Address) => Promise; isValidatorWallet: (vesting: Address, wallet: Address) => Promise; getActiveValidators: () => Promise; + getJoinedValidators: () => Promise; getQuarantinedValidatorsDetailed: () => Promise< Array<{validator: Address; untilEpoch: bigint; permanentlyBanned: boolean}> >; diff --git a/tests/actions/balances.test.ts b/tests/actions/balances.test.ts index 25a7c15c..cb47912b 100644 --- a/tests/actions/balances.test.ts +++ b/tests/actions/balances.test.ts @@ -40,6 +40,7 @@ function makeClient(overrides: Record = {}) { getVestingState: vi.fn(), getValidatorWallets: vi.fn().mockResolvedValue([]), validatorDeposited: vi.fn().mockResolvedValue(0n), + getJoinedValidators: vi.fn().mockResolvedValue([]), getActiveValidators: vi.fn().mockResolvedValue([]), getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), getBannedValidators: vi.fn().mockResolvedValue([]), @@ -95,7 +96,7 @@ describe("BalancesAction", () => { expect(summary.vestings).toEqual([]); // No vesting → never touches vesting state / validator enumeration. expect(client.getVestingState).not.toHaveBeenCalled(); - expect(client.getActiveValidators).not.toHaveBeenCalled(); + expect(client.getJoinedValidators).not.toHaveBeenCalled(); }); test("(a') consensus deployed but no staking contract (localnet): vesting shown, validator scan skipped", async () => { @@ -108,7 +109,7 @@ describe("BalancesAction", () => { getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake still computed // No staking contract ⇒ the validator reads must never be called. - getActiveValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")), + getJoinedValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")), getQuarantinedValidatorsDetailed: vi .fn() .mockRejectedValue(new Error("Staking is not supported on studio-based networks")), @@ -121,7 +122,7 @@ describe("BalancesAction", () => { await action.execute({network: "localnet"}); expect(failSpy).not.toHaveBeenCalled(); - expect(client.getActiveValidators).not.toHaveBeenCalled(); + expect(client.getJoinedValidators).not.toHaveBeenCalled(); expect(client.getQuarantinedValidatorsDetailed).not.toHaveBeenCalled(); expect(client.getBannedValidators).not.toHaveBeenCalled(); expect(client.vestingDepositedPerValidator).not.toHaveBeenCalled(); @@ -154,7 +155,7 @@ describe("BalancesAction", () => { expect(failSpy).not.toHaveBeenCalled(); // The consensus-dependent reads must never run. expect(client.getBeneficiaryVestings).not.toHaveBeenCalled(); - expect(client.getActiveValidators).not.toHaveBeenCalled(); + expect(client.getJoinedValidators).not.toHaveBeenCalled(); const summary = renderSpy.mock.calls[0][0]; expect(summary.consensusAvailable).toBe(false); expect(summary.walletBalanceRaw).toBe(7n * WEI); @@ -167,7 +168,7 @@ describe("BalancesAction", () => { getVestingState: vi.fn().mockResolvedValue(makeState()), getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake principal 5 - getActiveValidators: vi.fn().mockResolvedValue(["0xVal1"]), + getJoinedValidators: vi.fn().mockResolvedValue(["0xVal1"]), vestingDepositedPerValidator: vi.fn().mockResolvedValue(4n * WEI), // delegated principal 4 // Wallet reads 7; the vesting contract's live on-chain balance is 30. getBalance: vi.fn(async ({address}: {address: string}) => (address === "0xV1" ? 30n * WEI : 7n * WEI)), @@ -201,7 +202,7 @@ describe("BalancesAction", () => { getVestingState: vi.fn().mockResolvedValue(makeState({revoked: true})), getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), validatorDeposited: vi.fn().mockResolvedValue(10n * WEI), // still-committed principal - getActiveValidators: vi.fn().mockResolvedValue([]), + getJoinedValidators: vi.fn().mockResolvedValue([]), // Non-zero balance, but staking is disabled post-revoke ⇒ available must be 0. getBalance: vi.fn().mockResolvedValue(50n * WEI), }); @@ -223,7 +224,7 @@ describe("BalancesAction", () => { getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xVA", "0xVB"]), getVestingState: vi.fn().mockImplementation((addr: string) => (addr === "0xVA" ? stateA : stateB)), getValidatorWallets: vi.fn().mockResolvedValue([]), - getActiveValidators: vi.fn().mockResolvedValue([]), + getJoinedValidators: vi.fn().mockResolvedValue([]), // Each contract's available-to-stake is its own live on-chain balance. getBalance: vi.fn(async ({address}: {address: string}) => (({"0xVA": 20n * WEI, "0xVB": 45n * WEI}) as Record)[address] ?? 7n * WEI, @@ -240,29 +241,18 @@ describe("BalancesAction", () => { expect(summary.vestings[0].availableToStakeRaw).toBe(20n * WEI); // balance of 0xVA expect(summary.vestings[1].name).toBe("B"); expect(summary.vestings[1].availableToStakeRaw).toBe(45n * WEI); // balance of 0xVB - // Active validator set is global: fetched once and reused across vestings. - expect(client.getActiveValidators).toHaveBeenCalledTimes(1); + // Joined validator registry is global: fetched once and reused across vestings. + expect(client.getJoinedValidators).toHaveBeenCalledTimes(1); }); - test("(c') committed-delegation scan unions active + quarantined + banned validators", async () => { - // A vesting can hold committed principal against validators that left the - // active set. The scan must union all three lists (de-duped) so committed — - // and hence available-to-stake — is not under-counted. + test("(c') committed-delegation scan uses every joined validator", async () => { + // A vesting can hold committed principal against joined validators that are + // not selectable. The append-only registry is the authoritative full set. const client = makeClient({ getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), getVestingState: vi.fn().mockResolvedValue(makeState()), getValidatorWallets: vi.fn().mockResolvedValue([]), - getActiveValidators: vi.fn().mockResolvedValue(["0xActive"]), - getQuarantinedValidatorsDetailed: vi - .fn() - .mockResolvedValue([{validator: "0xQuar", untilEpoch: 5n, permanentlyBanned: false}]), - getBannedValidators: vi - .fn() - // "0xActive" also appears here to prove de-duplication (case-insensitive). - .mockResolvedValue([ - {validator: "0xBanned", untilEpoch: 9n, permanentlyBanned: true}, - {validator: "0xactive", untilEpoch: 0n, permanentlyBanned: false}, - ]), + getJoinedValidators: vi.fn().mockResolvedValue(["0xActive", "0xUnprimed", "0xBanned"]), // 1 GEN committed against every scanned validator. vestingDepositedPerValidator: vi.fn().mockResolvedValue(1n * WEI), getBalance: vi.fn().mockResolvedValue(7n * WEI), @@ -273,10 +263,11 @@ describe("BalancesAction", () => { await action.execute({network: "testnet-bradbury"}); expect(failSpy).not.toHaveBeenCalled(); - // Active + quarantined + banned, with the duplicate "0xActive"/"0xactive" - // collapsed → 3 distinct validators scanned for delegated principal. + expect(client.getActiveValidators).not.toHaveBeenCalled(); + expect(client.getQuarantinedValidatorsDetailed).not.toHaveBeenCalled(); + expect(client.getBannedValidators).not.toHaveBeenCalled(); const scanned = client.vestingDepositedPerValidator.mock.calls.map((c: any[]) => c[1].toLowerCase()); - expect(new Set(scanned)).toEqual(new Set(["0xactive", "0xquar", "0xbanned"])); + expect(new Set(scanned)).toEqual(new Set(["0xactive", "0xunprimed", "0xbanned"])); const v = renderSpy.mock.calls[0][0].vestings[0]; expect(v.delegatedRaw).toBe(3n * WEI); // 3 distinct validators × 1 GEN }); diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index d3ebb609..9abfa195 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -328,6 +328,27 @@ describe("DeployAction", () => { ); }); + test("diagnoses deterministic execution violations from train ordinal 5", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 5, + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining( + "DETERMINISTIC_VIOLATION (execution violated deterministic consensus rules)", + ), + }), + ); + }); + test("fails when deployment is canceled", async () => { vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue("contract code"); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 58731a94..d892eb41 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -91,6 +91,7 @@ const mockClient = { getEpochInfo: vi.fn(), getEpochData: vi.fn(), getActiveValidators: vi.fn(), + getJoinedValidators: vi.fn(), formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), }; @@ -259,12 +260,8 @@ describe("ValidatorExitAction", () => { }); }); -// SetOperatorAction / ValidatorClaimAction / SetIdentityAction: keystore path -// goes through the SDK staking client (client.setOperator / validatorClaim / -// setIdentity), matching every other staking write. Previously these used raw -// viem writeContract, which fails on the GenLayer consensus RPC (no EIP-1559 -// fee support). getViemClients has been removed entirely, so routing through -// the SDK is the only path. +// Staking writes route through the SDK. Operator rotation always uses the +// train's proof-bound initiate + complete flow. describe("SetOperatorAction", () => { let action: SetOperatorAction; @@ -272,34 +269,36 @@ describe("SetOperatorAction", () => { vi.clearAllMocks(); action = new SetOperatorAction(); setupActionMocks(action); - mockClient.setOperator.mockResolvedValue(mockTxResult); }); afterEach(() => { vi.restoreAllMocks(); }); - test("sets operator via the SDK client (not raw viem)", async () => { + test("fails actionably before broadcasting when the incoming key is unavailable", async () => { + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue(undefined); + await action.execute({ validator: "0xValidatorWallet", operator: "0xNewOperator", stakingAddress: "0xStaking", }); - expect(mockClient.setOperator).toHaveBeenCalledWith({ - validator: "0xValidatorWallet", - operator: "0xNewOperator", - }); - expect(action["succeedSpinner"]).toHaveBeenCalledWith("Operator updated!", expect.any(Object)); + expect(mockClient.setOperator).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to set operator", + expect.stringContaining("incoming operator must sign"), + ); }); - // CON-715 rotation. The incoming operator signs its own possession proof, so - // the two-step path is only reachable when its key is resolvable locally; - // without that the command must keep working against older wallets. + // CON-715 rotation. The incoming operator signs its own possession proof. describe("two-step rotation", () => { beforeEach(() => { vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("rotated-acct"); - vi.spyOn(action as any, "createOperatorTransferRegistration").mockResolvedValue(mockRegistration); + vi.spyOn(action as any, "createOperatorTransferRegistration").mockResolvedValue({ + ...mockRegistration, + operator: "0xNewOperator", + }); mockClient.initiateOperatorTransfer = vi.fn().mockResolvedValue(mockTxResult); mockClient.completeOperatorTransfer = vi.fn().mockResolvedValue(mockTxResult); }); @@ -313,7 +312,7 @@ describe("SetOperatorAction", () => { expect(mockClient.initiateOperatorTransfer).toHaveBeenCalledWith({ validator: "0xValidatorWallet", - registration: mockRegistration, + registration: expect.objectContaining({operator: "0xNewOperator"}), }); expect(mockClient.completeOperatorTransfer).toHaveBeenCalledWith({ validator: "0xValidatorWallet", @@ -338,7 +337,7 @@ describe("SetOperatorAction", () => { ); }); - test("falls back to setOperator on a wallet without the new surface", async () => { + test("does not fall back to the removed selector", async () => { mockClient.initiateOperatorTransfer.mockRejectedValue( new Error("Execution reverted for an unknown reason."), ); @@ -349,16 +348,21 @@ describe("SetOperatorAction", () => { stakingAddress: "0xStaking", }); - expect(mockClient.setOperator).toHaveBeenCalledWith({ - validator: "0xValidatorWallet", - operator: "0xNewOperator", - }); - expect(action["succeedSpinner"]).toHaveBeenCalledWith("Operator updated!", expect.any(Object)); + expect(mockClient.setOperator).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to set operator", + "Execution reverted for an unknown reason.", + ); }); }); test("handles errors", async () => { - mockClient.setOperator.mockRejectedValue(new Error("set operator failed")); + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("rotated-acct"); + vi.spyOn(action as any, "createOperatorTransferRegistration").mockResolvedValue({ + ...mockRegistration, + operator: "0xNewOperator", + }); + mockClient.initiateOperatorTransfer = vi.fn().mockRejectedValue(new Error("initiate failed")); await action.execute({ validator: "0xValidatorWallet", @@ -366,7 +370,7 @@ describe("SetOperatorAction", () => { stakingAddress: "0xStaking", }); - expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to set operator", "set operator failed"); + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to set operator", "initiate failed"); }); }); @@ -651,13 +655,25 @@ describe("StakingInfoAction", () => { }); test("lists active validators", async () => { - vi.spyOn(action as any, "getJoinedValidators").mockResolvedValue(["0xV1", "0xV2", "0xV3"]); + mockClient.getActiveValidators.mockResolvedValue(["0xSelectable"]); await action.listActiveValidators({stakingAddress: "0xStaking"}); expect(action["succeedSpinner"]).toHaveBeenCalledWith("Active validators retrieved", { - count: 3, - validators: ["0xV1", "0xV2", "0xV3"], + count: 1, + validators: ["0xSelectable"], + }); + }); + + test("lists the joined registry separately from active validators", async () => { + vi.spyOn(action as any, "getJoinedValidators").mockResolvedValue(["0xSelectable", "0xUnprimed"]); + + await action.listJoinedValidators({stakingAddress: "0xStaking"}); + + expect(mockClient.getActiveValidators).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Joined validators retrieved", { + count: 2, + validators: ["0xSelectable", "0xUnprimed"], }); }); }); @@ -822,7 +838,7 @@ describe("ValidatorDepositAction --wallet browser", () => { vi.restoreAllMocks(); }); - test("routes through the browser SDK client, skips keystore, closes session", async () => { + test("routes proof-bound rotation through the browser SDK client and closes session", async () => { const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); const getReadOnlyStakingClientSpy = vi.spyOn(action as any, "getReadOnlyStakingClient"); const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); @@ -926,15 +942,25 @@ describe("SetOperatorAction --wallet browser", () => { const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); const session = makeBrowserSession(); vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); - const mockClient = { - setOperator: vi.fn().mockResolvedValue({transactionHash: "0xBH", blockNumber: 5n, gasUsed: 6n}), + vi.spyOn(action as any, "findLocalAccountByAddress").mockReturnValue("operator-acct"); + vi.spyOn(action as any, "createOperatorTransferRegistration").mockResolvedValue({ + ...mockRegistration, + operator: "0xOp", + }); + const browserClient = { + initiateOperatorTransfer: vi.fn().mockResolvedValue({transactionHash: "0xBI", blockNumber: 4n, gasUsed: 5n}), + completeOperatorTransfer: vi.fn().mockResolvedValue({transactionHash: "0xBH", blockNumber: 5n, gasUsed: 6n}), }; - vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(mockClient); + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(browserClient); await action.execute({validator: "0xVW", operator: "0xOp", wallet: "browser"}); - expect(mockClient.setOperator).toHaveBeenCalledWith({validator: "0xVW", operator: "0xOp"}); - expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Set operator to 0xOp")); + expect(browserClient.initiateOperatorTransfer).toHaveBeenCalledWith({ + validator: "0xVW", + registration: expect.objectContaining({operator: "0xOp"}), + }); + expect(browserClient.completeOperatorTransfer).toHaveBeenCalledWith({validator: "0xVW"}); + expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Rotate operator to 0xOp")); expect(getStakingClientSpy).not.toHaveBeenCalled(); expect(getReadOnlyStakingClientSpy).not.toHaveBeenCalled(); expect(getSignerAddressSpy).not.toHaveBeenCalled(); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index b5a9bbab..5ee5a537 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -301,6 +301,27 @@ describe("WriteAction", () => { ); }); + test("diagnoses the SDK deterministic-violation result name", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "DETERMINISTIC_VIOLATION", + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining( + "DETERMINISTIC_VIOLATION (execution violated deterministic consensus rules)", + ), + }), + ); + }); + test("fails when write is canceled", async () => { const mockHash = "0xMockedTransactionHash"; diff --git a/tests/commands/balances.test.ts b/tests/commands/balances.test.ts index 6f7ada2f..eb2d3f36 100644 --- a/tests/commands/balances.test.ts +++ b/tests/commands/balances.test.ts @@ -25,7 +25,7 @@ const mockClient = { getVestingState: vi.fn(), getValidatorWallets: vi.fn(), validatorDeposited: vi.fn(), - getActiveValidators: vi.fn(), + getJoinedValidators: vi.fn(), getQuarantinedValidatorsDetailed: vi.fn(), getBannedValidators: vi.fn(), vestingDepositedPerValidator: vi.fn(), @@ -41,7 +41,7 @@ describe("balances command", () => { mockClient.getBalance.mockResolvedValue(0n); mockClient.getCode.mockResolvedValue("0x6001"); // consensus infra deployed mockClient.getBeneficiaryVestings.mockResolvedValue([]); - mockClient.getActiveValidators.mockResolvedValue([]); + mockClient.getJoinedValidators.mockResolvedValue([]); mockClient.getQuarantinedValidatorsDetailed.mockResolvedValue([]); mockClient.getBannedValidators.mockResolvedValue([]); diff --git a/tests/commands/staking.test.ts b/tests/commands/staking.test.ts index 4bf92aac..3354b52a 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -297,6 +297,15 @@ describe("staking commands", () => { }); }); + describe("joined-validators", () => { + test("calls StakingInfoAction.listJoinedValidators", async () => { + program.parse(["node", "test", "staking", "joined-validators"]); + + expect(StakingInfoAction).toHaveBeenCalledTimes(1); + expect(StakingInfoAction.prototype.listJoinedValidators).toHaveBeenCalledWith({}); + }); + }); + describe("validators", () => { test("calls ValidatorsAction.execute with discovery options", async () => { program.parse([ diff --git a/tests/commands/stakingValidators.test.ts b/tests/commands/stakingValidators.test.ts index bb2caa22..bbcf633d 100644 --- a/tests/commands/stakingValidators.test.ts +++ b/tests/commands/stakingValidators.test.ts @@ -16,7 +16,7 @@ function validatorInfo( selfStake: number, delegatedStake: number, moniker: string, - options: {live?: boolean} = {}, + options: {live?: boolean; needsPriming?: boolean} = {}, ) { return { address, @@ -35,7 +35,7 @@ function validatorInfo( ePrimed: 5n, live: options.live ?? true, banned: false, - needsPriming: false, + needsPriming: options.needsPriming ?? false, identity: {moniker}, pendingDeposits: [], pendingWithdrawals: [], @@ -53,17 +53,22 @@ function createMockClient({ currentEpoch = 6n, validatorMinStakeRaw = rawGen(75), betaLive = true, + betaSelfStake = 50, + activeValidators = [A], }: { currentEpoch?: bigint; validatorMinStakeRaw?: bigint; betaLive?: boolean; + betaSelfStake?: number; + activeValidators?: string[]; } = {}) { const infos = new Map([ [A.toLowerCase(), validatorInfo(A, 100, 20, "Alpha")], - [B.toLowerCase(), validatorInfo(B, 50, 10, "Beta", {live: betaLive})], + [B.toLowerCase(), validatorInfo(B, betaSelfStake, 10, "Beta", {live: betaLive, needsPriming: true})], ]); return { + getActiveValidators: vi.fn().mockResolvedValue(activeValidators), getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), getBannedValidators: vi.fn().mockResolvedValue([]), getEpochInfo: vi.fn().mockResolvedValue({ @@ -127,7 +132,42 @@ describe("staking validators action", () => { expect(output.validators[0].delegatorCount).toBeNull(); expect(output.validators[0].performance).toBeNull(); expect(output.validators[1].below_min).toBe(true); + expect(output.validators[1].active).toBe(false); expect(output.validators[1].status).toBe("inactive/below-min"); + expect(output.activeCount).toBe(1); + }); + + test("keeps joined but non-selectable validators out of the active set", async () => { + const action = setupAction(createMockClient({betaSelfStake: 100, betaLive: true})); + + await action.execute({json: true}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + const alpha = output.validators.find((row: any) => row.address === A); + const beta = output.validators.find((row: any) => row.address === B); + + expect(alpha.active).toBe(true); + expect(alpha.status).toBe("active"); + expect(beta.below_min).toBe(false); + expect(beta.active).toBe(false); + expect(beta.status).toBe("needs-priming"); + expect(output.activeCount).toBe(1); + }); + + test("does not label quarantined joined validators active", async () => { + const client = createMockClient({betaSelfStake: 100}); + client.getQuarantinedValidatorsDetailed.mockResolvedValue([ + {validator: B, untilEpoch: 9n, permanentlyBanned: false}, + ]); + const action = setupAction(client); + + await action.execute({json: true}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + const beta = output.validators.find((row: any) => row.address === B); + expect(beta.active).toBe(false); + expect(beta.status).toBe("quarantined(e9)"); + expect(output.activeCount).toBe(1); }); test("renders epoch 0 below-min validators as pending activation", async () => { diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 7c34ed33..68ff4e71 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -52,6 +52,17 @@ describe(`Testnet ${name} - CLI Staking Smoke Tests`, () => { } }, TIMEOUT); + it("keeps active validators distinct from the joined registry", async () => { + const [active, joined] = await Promise.all([ + client.getActiveValidators(), + client.getJoinedValidators(), + ]); + const joinedSet = new Set(joined.map(address => address.toLowerCase())); + + expect(joined.length).toBeGreaterThanOrEqual(active.length); + expect(active.every(address => joinedSet.has(address.toLowerCase()))).toBe(true); + }, TIMEOUT); + describe("validator-dependent tests", () => { let validator: Address | undefined; From c38e7f002d0296f6ac10f7c2f3fe71f4101ecb36 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 23:31:09 +0100 Subject: [PATCH 3/9] feat(transactions): simplify lifecycle output Present the SDK's stored-state lifecycle in ordinary receipt output and reserve raw stored/projected/action data for an explicit advanced lifecycle command with optional timestamp evaluation. Keep manual finalization under advanced recovery and preserve full raw receipts behind --raw.\n\nValidation: full 75-file Vitest suite (814 tests); build; generated docs; diff check. --- docs/api-references/_meta.json | 1 + docs/api-references/finalize-batch.mdx | 2 +- docs/api-references/finalize.mdx | 2 +- docs/api-references/index.mdx | 5 +- docs/api-references/lifecycle.mdx | 21 +++ docs/api-references/transactions/receipt.mdx | 1 + src/commands/transactions/index.ts | 40 ++++- src/commands/transactions/lifecycle.ts | 27 ++++ src/commands/transactions/presentation.ts | 145 +++++++++++++++++++ src/commands/transactions/receipt.ts | 33 +++-- tests/actions/lifecycle.test.ts | 50 +++++++ tests/actions/presentation.test.ts | 35 +++++ tests/actions/receipt.test.ts | 59 +++++--- tests/commands/lifecycle.test.ts | 46 ++++++ tests/commands/receipt.test.ts | 41 +++--- 15 files changed, 442 insertions(+), 66 deletions(-) create mode 100644 docs/api-references/lifecycle.mdx create mode 100644 src/commands/transactions/lifecycle.ts create mode 100644 src/commands/transactions/presentation.ts create mode 100644 tests/actions/lifecycle.test.ts create mode 100644 tests/actions/presentation.test.ts create mode 100644 tests/commands/lifecycle.test.ts diff --git a/docs/api-references/_meta.json b/docs/api-references/_meta.json index eba5ac45..b4186e93 100644 --- a/docs/api-references/_meta.json +++ b/docs/api-references/_meta.json @@ -11,6 +11,7 @@ "estimate-fees": "estimate-fees", "finalize": "finalize", "finalize-batch": "finalize-batch", + "lifecycle": "lifecycle", "vesting": "vesting", "wallet": "wallet" } \ No newline at end of file diff --git a/docs/api-references/finalize-batch.mdx b/docs/api-references/finalize-batch.mdx index 37fa3895..d92795ed 100644 --- a/docs/api-references/finalize-batch.mdx +++ b/docs/api-references/finalize-batch.mdx @@ -2,7 +2,7 @@ title: finalize-batch --- -Finalize a batch of idle transactions in a single call (public call) +Advanced recovery: manually finalize eligible idle transactions ### Usage diff --git a/docs/api-references/finalize.mdx b/docs/api-references/finalize.mdx index 74ad96ea..8cdbca1a 100644 --- a/docs/api-references/finalize.mdx +++ b/docs/api-references/finalize.mdx @@ -2,7 +2,7 @@ title: finalize --- -Finalize a transaction that is ready to be finalized (public call) +Advanced recovery: manually finalize an eligible transaction ### Usage diff --git a/docs/api-references/index.mdx b/docs/api-references/index.mdx index f0ec5483..16f811f3 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -29,12 +29,13 @@ Version: `0.40.0-clarke.4` - `genlayer appeal` — Appeal a transaction by its hash - `genlayer appeal-bond` — Show minimum appeal bond required for a transaction - `genlayer trace` — Get execution trace for a transaction (return data, stdout, stderr, GenVM logs) -- `genlayer finalize` — Finalize a transaction that is ready to be finalized (public call) -- `genlayer finalize-batch` — Finalize a batch of idle transactions in a single call (public call) - `genlayer staking` — Staking operations for validators and delegators - `genlayer vesting` — Vesting operations for beneficiaries - `genlayer wallet` — Manage the persistent browser-wallet (MetaMask) signing session - `genlayer balances` — Show wallet + vesting balances and committed stake (read-only) +- `genlayer lifecycle` — Advanced: inspect raw stored/projected lifecycle and resolution action +- `genlayer finalize` — Advanced recovery: manually finalize an eligible transaction +- `genlayer finalize-batch` — Advanced recovery: manually finalize eligible idle transactions --- diff --git a/docs/api-references/lifecycle.mdx b/docs/api-references/lifecycle.mdx new file mode 100644 index 00000000..ba409a8a --- /dev/null +++ b/docs/api-references/lifecycle.mdx @@ -0,0 +1,21 @@ +--- +title: lifecycle +--- + +Advanced: inspect raw stored/projected lifecycle and resolution action + +### Usage + +`$ genlayer lifecycle [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --timestamp <timestamp> | Evaluate lifecycle at a Unix timestamp | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/transactions/receipt.mdx b/docs/api-references/transactions/receipt.mdx index 61c13305..98ad13db 100644 --- a/docs/api-references/transactions/receipt.mdx +++ b/docs/api-references/transactions/receipt.mdx @@ -22,4 +22,5 @@ Get transaction receipt by hash | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --stdout | Print only stdout from the receipt | No | | | | --stderr | Print only stderr from the receipt | No | | +| | --raw | Show full raw receipt data | No | | | -h | --help | display help for command | No | | diff --git a/src/commands/transactions/index.ts b/src/commands/transactions/index.ts index ef08d3de..868d98b8 100644 --- a/src/commands/transactions/index.ts +++ b/src/commands/transactions/index.ts @@ -4,6 +4,7 @@ import {ReceiptAction, ReceiptOptions} from "./receipt"; import {AppealAction, AppealOptions, AppealBondOptions} from "./appeal"; import {TraceAction, TraceOptions} from "./trace"; import {FinalizeAction, FinalizeOptions} from "./finalize"; +import {LifecycleAction, LifecycleOptions} from "./lifecycle"; import {addWalletModeOption} from "../../lib/wallet/walletOption"; function parseIntOption(value: string, fallback: number): number { @@ -17,17 +18,27 @@ export function initializeTransactionsCommands(program: Command) { program .command("receipt ") .description("Get transaction receipt by hash") - .option("--status ", `Transaction status to wait for (${validStatuses})`, TransactionStatus.FINALIZED) - .option("--retries ", "Number of retries", (value) => parseIntOption(value, 100), 100) - .option("--interval ", "Interval between retries in milliseconds", (value) => parseIntOption(value, 5000), 5000) + .option( + "--status ", + `Transaction status to wait for (${validStatuses})`, + TransactionStatus.FINALIZED, + ) + .option("--retries ", "Number of retries", value => parseIntOption(value, 100), 100) + .option( + "--interval ", + "Interval between retries in milliseconds", + value => parseIntOption(value, 5000), + 5000, + ) .option("--rpc ", "RPC URL for the network") .option("--stdout", "Print only stdout from the receipt") .option("--stderr", "Print only stderr from the receipt") + .option("--raw", "Show full raw receipt data") .action(async (txId: TransactionHash, options: ReceiptOptions) => { const receiptAction = new ReceiptAction(); await receiptAction.receipt({txId, ...options}); - }) + }); addWalletModeOption( program @@ -52,17 +63,31 @@ export function initializeTransactionsCommands(program: Command) { program .command("trace ") .description("Get execution trace for a transaction (return data, stdout, stderr, GenVM logs)") - .option("--round ", "Consensus round number (default: 0)", (value) => parseIntOption(value, 0), 0) + .option("--round ", "Consensus round number (default: 0)", value => parseIntOption(value, 0), 0) .option("--rpc ", "RPC URL for the network") .action(async (txId: TransactionHash, options: TraceOptions) => { const traceAction = new TraceAction(); await traceAction.trace({txId, ...options}); }); + program + .command("lifecycle ") + .helpGroup("Advanced and recovery") + .description("Advanced: inspect raw stored/projected lifecycle and resolution action") + .option("--timestamp ", "Evaluate lifecycle at a Unix timestamp", value => + parseIntOption(value, 0), + ) + .option("--rpc ", "RPC URL for the network") + .action(async (txId: TransactionHash, options: LifecycleOptions) => { + const lifecycleAction = new LifecycleAction(); + await lifecycleAction.lifecycle({txId, ...options}); + }); + addWalletModeOption( program .command("finalize ") - .description("Finalize a transaction that is ready to be finalized (public call)") + .helpGroup("Advanced and recovery") + .description("Advanced recovery: manually finalize an eligible transaction") .option("--rpc ", "RPC URL for the network"), ).action(async (txId: TransactionHash, options: FinalizeOptions) => { const finalizeAction = new FinalizeAction(); @@ -72,7 +97,8 @@ export function initializeTransactionsCommands(program: Command) { addWalletModeOption( program .command("finalize-batch ") - .description("Finalize a batch of idle transactions in a single call (public call)") + .helpGroup("Advanced and recovery") + .description("Advanced recovery: manually finalize eligible idle transactions") .option("--rpc ", "RPC URL for the network"), ).action(async (txIds: TransactionHash[], options: FinalizeOptions) => { const finalizeAction = new FinalizeAction(); diff --git a/src/commands/transactions/lifecycle.ts b/src/commands/transactions/lifecycle.ts new file mode 100644 index 00000000..306f141b --- /dev/null +++ b/src/commands/transactions/lifecycle.ts @@ -0,0 +1,27 @@ +import {BaseAction} from "../../lib/actions/BaseAction"; +import type {TransactionHash} from "genlayer-js/types"; + +export interface LifecycleOptions { + rpc?: string; + timestamp?: number; +} + +export class LifecycleAction extends BaseAction { + async lifecycle({txId, rpc, timestamp}: LifecycleOptions & {txId: TransactionHash}): Promise { + this.startSpinner(`Reading advanced lifecycle for ${txId}...`); + try { + const client = await this.getClient(rpc, true); + const request = client.request as unknown as (args: { + method: string; + params: unknown[]; + }) => Promise; + const lifecycle = await request({ + method: "gen_getTransactionLifecycle", + params: [{txId, ...(timestamp === undefined ? {} : {timestamp})}], + }); + this.succeedSpinner("Advanced transaction lifecycle", lifecycle); + } catch (error) { + this.failSpinner("Error retrieving advanced transaction lifecycle", error); + } + } +} diff --git a/src/commands/transactions/presentation.ts b/src/commands/transactions/presentation.ts new file mode 100644 index 00000000..6b65b02a --- /dev/null +++ b/src/commands/transactions/presentation.ts @@ -0,0 +1,145 @@ +import {TransactionStatus, transactionsStatusNumberToName, type GenLayerTransaction} from "genlayer-js/types"; + +export type TransactionPresentation = { + state: "processing" | "decided" | "finalized" | "canceled"; + phase?: string; + outcome?: string; + label: string; +}; + +type LifecycleSummary = + | {state: "processing"; phase: string} + | {state: "decided"; outcome: string} + | {state: "finalized"; outcome?: string} + | {state: "canceled"}; + +const PROCESSING_PHASES: Partial> = { + [TransactionStatus.UNINITIALIZED]: "Waiting to start", + [TransactionStatus.PENDING]: "Pending activation", + [TransactionStatus.PROPOSING]: "Proposal", + [TransactionStatus.COMMITTING]: "Voting", + [TransactionStatus.REVEALING]: "Vote reveal", + [TransactionStatus.APPEAL_COMMITTING]: "Appeal voting", + [TransactionStatus.APPEAL_REVEALING]: "Appeal reveal", + [TransactionStatus.LEADER_REVEALING]: "Leader reveal", +}; + +const DECISION_OUTCOMES: Partial> = { + [TransactionStatus.ACCEPTED]: "Accepted", + [TransactionStatus.UNDETERMINED]: "Undetermined", + [TransactionStatus.VALIDATORS_TIMEOUT]: "Validator timeout", + [TransactionStatus.LEADER_TIMEOUT]: "Leader timeout", +}; + +const FINALIZED_OUTCOMES: Record = { + SUCCESS: "Succeeded", + FINISHED_WITH_RETURN: "Succeeded", + FAILURE: "Failed", + FINISHED_WITH_ERROR: "Failed", + TIMEOUT: "Timed out", + NOT_VOTED: "Timed out", + NONDET_DISAGREE: "Undetermined", + DETERMINISTIC_VIOLATION: "Deterministic violation", +}; + +function humanize(value: string): string { + return value + .toLowerCase() + .split("_") + .filter(Boolean) + .map(word => word[0]?.toUpperCase() + word.slice(1)) + .join(" "); +} + +function label(state: string, detail?: string): string { + return detail ? `${state} · ${detail}` : state; +} + +function fromLifecycle(lifecycle: LifecycleSummary): TransactionPresentation { + if (lifecycle.state === "canceled") { + return {state: "canceled", label: "Canceled"}; + } + if (lifecycle.state === "processing") { + const phase = humanize(lifecycle.phase); + return {state: "processing", phase, label: label("Processing", phase)}; + } + if (lifecycle.state === "decided") { + const outcome = humanize(lifecycle.outcome); + return {state: "decided", outcome, label: label("Decided", outcome)}; + } + const outcome = lifecycle.outcome ? humanize(lifecycle.outcome) : "Complete"; + return {state: "finalized", outcome, label: label("Finalized", outcome)}; +} + +function statusName(transaction: GenLayerTransaction): TransactionStatus { + const legacy = transaction as GenLayerTransaction & { + storedStatusName?: TransactionStatus; + storedStatus?: number; + statusName?: TransactionStatus; + status?: string | number; + }; + + if (legacy.storedStatusName) return legacy.storedStatusName; + if (typeof legacy.storedStatus === "number") { + return transactionsStatusNumberToName[legacy.storedStatus as keyof typeof transactionsStatusNumberToName]; + } + if (legacy.statusName) return legacy.statusName; + if (typeof legacy.status === "string") { + return legacy.status.toUpperCase() as TransactionStatus; + } + if (typeof legacy.status === "number") { + return transactionsStatusNumberToName[legacy.status as keyof typeof transactionsStatusNumberToName]; + } + return TransactionStatus.UNINITIALIZED; +} + +/** + * Format the SDK's simple lifecycle when available. The train fallback uses + * storedStatus before the legacy projected status so ordinary output never + * invents a materialized transition. + */ +export function presentTransaction(transaction: GenLayerTransaction): TransactionPresentation { + const lifecycle = (transaction as GenLayerTransaction & {lifecycle?: LifecycleSummary}).lifecycle; + if (lifecycle) return fromLifecycle(lifecycle); + + const status = statusName(transaction); + if (status === TransactionStatus.CANCELED) { + return {state: "canceled", label: "Canceled"}; + } + if (status === TransactionStatus.FINALIZED) { + const legacy = transaction as GenLayerTransaction & { + txExecutionResultName?: string; + resultName?: string; + }; + const rawOutcome = legacy.txExecutionResultName || legacy.resultName || "COMPLETE"; + const outcome = FINALIZED_OUTCOMES[rawOutcome] || humanize(rawOutcome); + return {state: "finalized", outcome, label: label("Finalized", outcome)}; + } + + const outcome = DECISION_OUTCOMES[status]; + if (outcome) { + return {state: "decided", outcome, label: label("Decided", outcome)}; + } + + const phase = PROCESSING_PHASES[status] || "Processing"; + return {state: "processing", phase, label: label("Processing", phase)}; +} + +const ADVANCED_LIFECYCLE_FIELDS = new Set([ + "lifecycle", + "currentTimestamp", + "status", + "statusName", + "storedStatus", + "storedStatusName", + "resolutionAction", + "resolutionActionName", + "canFinalize", +]); + +/** Keep receipt data useful while reserving raw lifecycle internals for debug. */ +export function withoutAdvancedLifecycle(transaction: GenLayerTransaction): Record { + return Object.fromEntries( + Object.entries(transaction).filter(([key]) => !ADVANCED_LIFECYCLE_FIELDS.has(key)), + ); +} diff --git a/src/commands/transactions/receipt.ts b/src/commands/transactions/receipt.ts index d9165c4d..b6f73f6b 100644 --- a/src/commands/transactions/receipt.ts +++ b/src/commands/transactions/receipt.ts @@ -1,5 +1,6 @@ import {BaseAction} from "../../lib/actions/BaseAction"; import {TransactionHash, TransactionStatus} from "genlayer-js/types"; +import {presentTransaction, withoutAdvancedLifecycle} from "./presentation"; export interface ReceiptParams { txId: TransactionHash; @@ -9,9 +10,10 @@ export interface ReceiptParams { rpc?: string; stdout?: boolean; stderr?: boolean; + raw?: boolean; } -export interface ReceiptOptions extends Omit {} +export interface ReceiptOptions extends Omit {} export class ReceiptAction extends BaseAction { constructor() { @@ -20,16 +22,16 @@ export class ReceiptAction extends BaseAction { private validateTransactionStatus(status: string): TransactionStatus | undefined { const upperStatus = status.toUpperCase() as keyof typeof TransactionStatus; - + if (!(upperStatus in TransactionStatus)) { const validStatuses = Object.values(TransactionStatus); this.failSpinner( - "Invalid transaction status", - `Invalid status: ${status}. Valid values are: ${validStatuses.join(", ")}` + "Invalid transaction status", + `Invalid status: ${status}. Valid values are: ${validStatuses.join(", ")}`, ); - return + return; } - + return TransactionStatus[upperStatus]; } @@ -41,6 +43,7 @@ export class ReceiptAction extends BaseAction { rpc, stdout, stderr, + raw, }: ReceiptParams): Promise { const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); @@ -52,7 +55,7 @@ export class ReceiptAction extends BaseAction { if (!validatedStatus) { return; } - + const result = await client.waitForTransactionReceipt({ hash: txId, status: validatedStatus, @@ -66,7 +69,7 @@ export class ReceiptAction extends BaseAction { const stderrValue = (result as any)?.consensus_data?.leader_receipt[0]?.genvm_result?.stderr; if (stdout && stderr) { - this.succeedSpinner("Transaction stdout and stderr", { stdout: stdoutValue, stderr: stderrValue }); + this.succeedSpinner("Transaction stdout and stderr", {stdout: stdoutValue, stderr: stderrValue}); return; } @@ -81,10 +84,18 @@ export class ReceiptAction extends BaseAction { } } - // Default behavior (no flags): show full receipt result - this.succeedSpinner("Transaction receipt retrieved successfully", result); + if (raw) { + this.succeedSpinner("Raw transaction receipt", result); + return; + } + + const presentation = presentTransaction(result); + this.succeedSpinner(presentation.label, { + status: presentation.label, + ...withoutAdvancedLifecycle(result), + }); } catch (error) { this.failSpinner("Error retrieving transaction receipt", error); } } -} \ No newline at end of file +} diff --git a/tests/actions/lifecycle.test.ts b/tests/actions/lifecycle.test.ts new file mode 100644 index 00000000..703a253a --- /dev/null +++ b/tests/actions/lifecycle.test.ts @@ -0,0 +1,50 @@ +import {beforeEach, describe, expect, test, vi} from "vitest"; +import {createClient} from "genlayer-js"; +import type {TransactionHash} from "genlayer-js/types"; +import {LifecycleAction} from "../../src/commands/transactions/lifecycle"; + +vi.mock("genlayer-js"); + +describe("LifecycleAction", () => { + const txId = `0x${"12".repeat(32)}` as TransactionHash; + const request = vi.fn(); + let action: LifecycleAction; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(createClient).mockReturnValue({request} as any); + action = new LifecycleAction(); + vi.spyOn(action as any, "getAccount").mockResolvedValue(undefined); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + }); + + test("reads the raw lifecycle only through the explicit advanced action", async () => { + const lifecycle = { + storedStatus: "Proposing", + projectedStatus: "Undetermined", + resolutionAction: "MaterializeDecision", + }; + request.mockResolvedValue(lifecycle); + + await action.lifecycle({txId}); + + expect(request).toHaveBeenCalledWith({ + method: "gen_getTransactionLifecycle", + params: [{txId}], + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Advanced transaction lifecycle", lifecycle); + }); + + test("passes an optional evaluation timestamp to the lifecycle RPC", async () => { + request.mockResolvedValue({}); + + await action.lifecycle({txId, timestamp: 1_700_000_000}); + + expect(request).toHaveBeenCalledWith({ + method: "gen_getTransactionLifecycle", + params: [{txId, timestamp: 1_700_000_000}], + }); + }); +}); diff --git a/tests/actions/presentation.test.ts b/tests/actions/presentation.test.ts new file mode 100644 index 00000000..e4286e1f --- /dev/null +++ b/tests/actions/presentation.test.ts @@ -0,0 +1,35 @@ +import {describe, expect, test} from "vitest"; +import {presentTransaction, withoutAdvancedLifecycle} from "../../src/commands/transactions/presentation"; + +describe("transaction presentation", () => { + test.each([ + [{lifecycle: {state: "processing", phase: "appeal_revealing"}}, "Processing · Appeal Revealing"], + [{lifecycle: {state: "decided", outcome: "undetermined"}}, "Decided · Undetermined"], + [{lifecycle: {state: "finalized", outcome: "accepted"}}, "Finalized · Accepted"], + [{lifecycle: {state: "canceled"}}, "Canceled"], + ])("formats the SDK simple lifecycle %#", (transaction, expected) => { + expect(presentTransaction(transaction as any).label).toBe(expected); + }); + + test("prefers stored status over projected status on the train fallback", () => { + expect( + presentTransaction({ + storedStatusName: "PROPOSING", + statusName: "UNDETERMINED", + resolutionActionName: "MATERIALIZE_DECISION", + } as any).label, + ).toBe("Processing · Proposal"); + }); + + test("removes raw lifecycle internals from ordinary receipt output", () => { + expect( + withoutAdvancedLifecycle({ + hash: "0x01", + status: "UNDETERMINED", + storedStatusName: "PROPOSING", + resolutionActionName: "MATERIALIZE_DECISION", + canFinalize: false, + } as any), + ).toEqual({hash: "0x01"}); + }); +}); diff --git a/tests/actions/receipt.test.ts b/tests/actions/receipt.test.ts index 3875cff3..1c17c3ad 100644 --- a/tests/actions/receipt.test.ts +++ b/tests/actions/receipt.test.ts @@ -51,10 +51,10 @@ describe("ReceiptAction", () => { retries: defaultRetries, interval: defaultInterval, }); - expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith( - "Transaction receipt retrieved successfully", - mockReceipt, - ); + expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith("Finalized · Complete", { + status: "Finalized · Complete", + data: {hash: mockTxId}, + }); }); test("retrieves transaction receipt with custom options", async () => { @@ -75,10 +75,10 @@ describe("ReceiptAction", () => { retries: 50, interval: 3000, }); - expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith( - "Transaction receipt retrieved successfully", - mockReceipt, - ); + expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith("Decided · Accepted", { + status: "Decided · Accepted", + data: {hash: mockTxId}, + }); }); test("handles waitForTransactionReceipt errors", async () => { @@ -120,10 +120,30 @@ describe("ReceiptAction", () => { retries: defaultRetries, interval: defaultInterval, }); - expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith( - "Transaction receipt retrieved successfully", - mockReceipt, - ); + expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith("Finalized · Complete", { + status: "Finalized · Complete", + data: {hash: mockTxId}, + }); + }); + + test("returns the full raw receipt behind --raw", async () => { + const mockReceipt = { + status: "ACCEPTED", + statusName: "ACCEPTED", + storedStatus: 5, + storedStatusName: "ACCEPTED", + resolutionActionName: "FINALIZE", + }; + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt as any); + + await receiptAction.receipt({ + txId: mockTxId, + retries: defaultRetries, + interval: defaultInterval, + raw: true, + }); + + expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith("Raw transaction receipt", mockReceipt); }); test("validates transaction status and shows error for invalid status", async () => { @@ -136,9 +156,9 @@ describe("ReceiptAction", () => { expect(receiptAction["failSpinner"]).toHaveBeenCalledWith( "Invalid transaction status", - expect.stringContaining("Invalid status: INVALID_STATUS") + expect.stringContaining("Invalid status: INVALID_STATUS"), ); - + expect(mockClient.waitForTransactionReceipt).not.toHaveBeenCalled(); }); @@ -252,10 +272,9 @@ describe("ReceiptAction", () => { stderr: true, } as ReceiptParams); - expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith( - "Transaction stdout and stderr", - { stdout: "program stdout", stderr: "program stderr" }, - ); + expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith("Transaction stdout and stderr", { + stdout: "program stdout", + stderr: "program stderr", + }); }); - -}); \ No newline at end of file +}); diff --git a/tests/commands/lifecycle.test.ts b/tests/commands/lifecycle.test.ts new file mode 100644 index 00000000..cfbfe1ef --- /dev/null +++ b/tests/commands/lifecycle.test.ts @@ -0,0 +1,46 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from "vitest"; +import {Command} from "commander"; +import {LifecycleAction} from "../../src/commands/transactions/lifecycle"; +import {initializeTransactionsCommands} from "../../src/commands/transactions"; + +vi.mock("../../src/commands/transactions/lifecycle"); + +describe("lifecycle command", () => { + const txId = `0x${"34".repeat(32)}`; + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeTransactionsCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => vi.restoreAllMocks()); + + test("routes the explicit debug command to the raw lifecycle RPC action", () => { + program.parse([ + "node", + "test", + "lifecycle", + txId, + "--rpc", + "https://rpc.example", + "--timestamp", + "1700000000", + ]); + + expect(LifecycleAction.prototype.lifecycle).toHaveBeenCalledWith({ + txId, + rpc: "https://rpc.example", + timestamp: 1_700_000_000, + }); + }); + + test("groups lifecycle and manual finalization as advanced recovery commands", () => { + for (const name of ["lifecycle", "finalize", "finalize-batch"]) { + expect(program.commands.find(command => command.name() === name)?.helpGroup()).toBe( + "Advanced and recovery", + ); + } + }); +}); diff --git a/tests/commands/receipt.test.ts b/tests/commands/receipt.test.ts index 496b2a36..f01671dd 100644 --- a/tests/commands/receipt.test.ts +++ b/tests/commands/receipt.test.ts @@ -63,22 +63,13 @@ describe("receipt command", () => { test("throws error for unrecognized options", async () => { const receiptCommand = program.commands.find(cmd => cmd.name() === "receipt"); receiptCommand?.exitOverride(); - expect(() => - program.parse(["node", "test", "receipt", mockTxId, "--invalid-option"]), - ).toThrowError("error: unknown option '--invalid-option'"); + expect(() => program.parse(["node", "test", "receipt", mockTxId, "--invalid-option"])).toThrowError( + "error: unknown option '--invalid-option'", + ); }); test("parses numeric options correctly", async () => { - program.parse([ - "node", - "test", - "receipt", - mockTxId, - "--retries", - "25", - "--interval", - "1000", - ]); + program.parse(["node", "test", "receipt", mockTxId, "--retries", "25", "--interval", "1000"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, status: "FINALIZED", @@ -88,16 +79,7 @@ describe("receipt command", () => { }); test("uses fallback value for invalid numeric options", async () => { - program.parse([ - "node", - "test", - "receipt", - mockTxId, - "--retries", - "invalid", - "--interval", - "notanumber", - ]); + program.parse(["node", "test", "receipt", mockTxId, "--retries", "invalid", "--interval", "notanumber"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, status: "FINALIZED", @@ -128,6 +110,17 @@ describe("receipt command", () => { }); }); + test("parses --raw as full receipt data", async () => { + program.parse(["node", "test", "receipt", mockTxId, "--raw"]); + expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ + txId: mockTxId, + status: "FINALIZED", + retries: 100, + interval: 5000, + raw: true, + }); + }); + test("parses both --stdout and --stderr flags", async () => { program.parse(["node", "test", "receipt", mockTxId, "--stdout", "--stderr"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ @@ -139,4 +132,4 @@ describe("receipt command", () => { stderr: true, }); }); -}); \ No newline at end of file +}); From f4bb1af291928e139a36c840af7f18a115501d41 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 23:47:17 +0100 Subject: [PATCH 4/9] fix(tooling): consume layered SDK lifecycle --- docs/api-references/transactions/receipt.mdx | 2 +- package-lock.json | 4 +- package.json | 2 +- src/commands/staking/StakingAction.ts | 4 +- src/commands/staking/wizard.ts | 3 + src/commands/transactions/index.ts | 10 +- src/commands/transactions/lifecycle.ts | 10 +- src/commands/transactions/presentation.ts | 112 ++----------------- src/commands/transactions/receipt.ts | 29 +++-- src/commands/vesting/validatorCreate.ts | 15 +-- src/lib/config/KeychainManager.ts | 22 ++-- tests/actions/lifecycle.test.ts | 18 +-- tests/actions/presentation.test.ts | 19 +--- tests/actions/receipt.test.ts | 47 ++++---- tests/actions/staking.test.ts | 2 + tests/actions/stakingWizard.test.ts | 1 + tests/commands/receipt.test.ts | 20 ++-- 17 files changed, 96 insertions(+), 224 deletions(-) diff --git a/docs/api-references/transactions/receipt.mdx b/docs/api-references/transactions/receipt.mdx index 98ad13db..e7b98f7a 100644 --- a/docs/api-references/transactions/receipt.mdx +++ b/docs/api-references/transactions/receipt.mdx @@ -16,7 +16,7 @@ Get transaction receipt by hash | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --status <status> | Transaction status to wait for (UNINITIALIZED, PENDING, PROPOSING, COMMITTING, REVEALING, ACCEPTED, UNDETERMINED, FINALIZED, CANCELED, APPEAL_REVEALING, APPEAL_COMMITTING, VALIDATORS_TIMEOUT, LEADER_TIMEOUT, LEADER_REVEALING) | No | `FINALIZED` | +| | --wait-until <stage> | Wait for a materialized decision or finalization (decided, finalized) | No | `finalized` | | | --retries <retries> | Number of retries | No | `100` | | | --interval <interval> | Interval between retries in milliseconds (default: 5000) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | diff --git a/package-lock.json b/package-lock.json index 59315768..01f5f7d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#71a201b78501e8e0e524298779180b35b2b209de", + "genlayer-js": "github:genlayerlabs/genlayer-js#7161e8edc27ca6ae0e54a625dd8318f1252d18c9", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -5683,7 +5683,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#71a201b78501e8e0e524298779180b35b2b209de", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#7161e8edc27ca6ae0e54a625dd8318f1252d18c9", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7feaa612..02985ad6 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#71a201b78501e8e0e524298779180b35b2b209de", + "genlayer-js": "github:genlayerlabs/genlayer-js#7161e8edc27ca6ae0e54a625dd8318f1252d18c9", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 639c9611..6d371097 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -150,7 +150,7 @@ export class StakingAction extends BaseAction { // Override staking address if provided if (config.stakingAddress) { network.stakingContract = { - address: config.stakingAddress, + address: config.stakingAddress as Address, abi: abi.STAKING_ABI, }; } @@ -178,7 +178,7 @@ export class StakingAction extends BaseAction { if (config.stakingAddress) { network.stakingContract = { - address: config.stakingAddress, + address: config.stakingAddress as Address, abi: abi.STAKING_ABI, }; } diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index 19e8c230..deab9659 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -724,6 +724,9 @@ export class ValidatorWizardAction extends StakingAction { ]); if (!useOperator) { + if (!state.accountAddress) { + throw new Error("Owner account address is unavailable"); + } state.operatorAddress = ensureHexPrefix(state.accountAddress); state.operatorAccountName = state.accountName; console.log("\nOperator will be the same as owner address.\n"); diff --git a/src/commands/transactions/index.ts b/src/commands/transactions/index.ts index 868d98b8..d10b07c4 100644 --- a/src/commands/transactions/index.ts +++ b/src/commands/transactions/index.ts @@ -1,5 +1,5 @@ import {Command} from "commander"; -import {TransactionStatus, TransactionHash} from "genlayer-js/types"; +import type {TransactionHash} from "genlayer-js/types"; import {ReceiptAction, ReceiptOptions} from "./receipt"; import {AppealAction, AppealOptions, AppealBondOptions} from "./appeal"; import {TraceAction, TraceOptions} from "./trace"; @@ -13,16 +13,10 @@ function parseIntOption(value: string, fallback: number): number { } export function initializeTransactionsCommands(program: Command) { - const validStatuses = Object.values(TransactionStatus).join(", "); - program .command("receipt ") .description("Get transaction receipt by hash") - .option( - "--status ", - `Transaction status to wait for (${validStatuses})`, - TransactionStatus.FINALIZED, - ) + .option("--wait-until ", "Wait for a materialized decision or finalization (decided, finalized)", "finalized") .option("--retries ", "Number of retries", value => parseIntOption(value, 100), 100) .option( "--interval ", diff --git a/src/commands/transactions/lifecycle.ts b/src/commands/transactions/lifecycle.ts index 306f141b..3c993148 100644 --- a/src/commands/transactions/lifecycle.ts +++ b/src/commands/transactions/lifecycle.ts @@ -11,13 +11,9 @@ export class LifecycleAction extends BaseAction { this.startSpinner(`Reading advanced lifecycle for ${txId}...`); try { const client = await this.getClient(rpc, true); - const request = client.request as unknown as (args: { - method: string; - params: unknown[]; - }) => Promise; - const lifecycle = await request({ - method: "gen_getTransactionLifecycle", - params: [{txId, ...(timestamp === undefined ? {} : {timestamp})}], + const lifecycle = await client.advanced.getTransactionLifecycle({ + hash: txId, + ...(timestamp === undefined ? {} : {timestamp}), }); this.succeedSpinner("Advanced transaction lifecycle", lifecycle); } catch (error) { diff --git a/src/commands/transactions/presentation.ts b/src/commands/transactions/presentation.ts index 6b65b02a..a9e9c054 100644 --- a/src/commands/transactions/presentation.ts +++ b/src/commands/transactions/presentation.ts @@ -1,4 +1,4 @@ -import {TransactionStatus, transactionsStatusNumberToName, type GenLayerTransaction} from "genlayer-js/types"; +import type {GenLayerTransaction, TransactionLifecycle} from "genlayer-js/types"; export type TransactionPresentation = { state: "processing" | "decided" | "finalized" | "canceled"; @@ -7,45 +7,10 @@ export type TransactionPresentation = { label: string; }; -type LifecycleSummary = - | {state: "processing"; phase: string} - | {state: "decided"; outcome: string} - | {state: "finalized"; outcome?: string} - | {state: "canceled"}; - -const PROCESSING_PHASES: Partial> = { - [TransactionStatus.UNINITIALIZED]: "Waiting to start", - [TransactionStatus.PENDING]: "Pending activation", - [TransactionStatus.PROPOSING]: "Proposal", - [TransactionStatus.COMMITTING]: "Voting", - [TransactionStatus.REVEALING]: "Vote reveal", - [TransactionStatus.APPEAL_COMMITTING]: "Appeal voting", - [TransactionStatus.APPEAL_REVEALING]: "Appeal reveal", - [TransactionStatus.LEADER_REVEALING]: "Leader reveal", -}; - -const DECISION_OUTCOMES: Partial> = { - [TransactionStatus.ACCEPTED]: "Accepted", - [TransactionStatus.UNDETERMINED]: "Undetermined", - [TransactionStatus.VALIDATORS_TIMEOUT]: "Validator timeout", - [TransactionStatus.LEADER_TIMEOUT]: "Leader timeout", -}; - -const FINALIZED_OUTCOMES: Record = { - SUCCESS: "Succeeded", - FINISHED_WITH_RETURN: "Succeeded", - FAILURE: "Failed", - FINISHED_WITH_ERROR: "Failed", - TIMEOUT: "Timed out", - NOT_VOTED: "Timed out", - NONDET_DISAGREE: "Undetermined", - DETERMINISTIC_VIOLATION: "Deterministic violation", -}; - function humanize(value: string): string { return value .toLowerCase() - .split("_") + .split(/[-_]/) .filter(Boolean) .map(word => word[0]?.toUpperCase() + word.slice(1)) .join(" "); @@ -55,7 +20,7 @@ function label(state: string, detail?: string): string { return detail ? `${state} · ${detail}` : state; } -function fromLifecycle(lifecycle: LifecycleSummary): TransactionPresentation { +function fromLifecycle(lifecycle: TransactionLifecycle): TransactionPresentation { if (lifecycle.state === "canceled") { return {state: "canceled", label: "Canceled"}; } @@ -71,75 +36,14 @@ function fromLifecycle(lifecycle: LifecycleSummary): TransactionPresentation { return {state: "finalized", outcome, label: label("Finalized", outcome)}; } -function statusName(transaction: GenLayerTransaction): TransactionStatus { - const legacy = transaction as GenLayerTransaction & { - storedStatusName?: TransactionStatus; - storedStatus?: number; - statusName?: TransactionStatus; - status?: string | number; - }; - - if (legacy.storedStatusName) return legacy.storedStatusName; - if (typeof legacy.storedStatus === "number") { - return transactionsStatusNumberToName[legacy.storedStatus as keyof typeof transactionsStatusNumberToName]; - } - if (legacy.statusName) return legacy.statusName; - if (typeof legacy.status === "string") { - return legacy.status.toUpperCase() as TransactionStatus; - } - if (typeof legacy.status === "number") { - return transactionsStatusNumberToName[legacy.status as keyof typeof transactionsStatusNumberToName]; - } - return TransactionStatus.UNINITIALIZED; -} - -/** - * Format the SDK's simple lifecycle when available. The train fallback uses - * storedStatus before the legacy projected status so ordinary output never - * invents a materialized transition. - */ +/** Format the SDK's non-projecting, consumer-oriented lifecycle. */ export function presentTransaction(transaction: GenLayerTransaction): TransactionPresentation { - const lifecycle = (transaction as GenLayerTransaction & {lifecycle?: LifecycleSummary}).lifecycle; - if (lifecycle) return fromLifecycle(lifecycle); - - const status = statusName(transaction); - if (status === TransactionStatus.CANCELED) { - return {state: "canceled", label: "Canceled"}; - } - if (status === TransactionStatus.FINALIZED) { - const legacy = transaction as GenLayerTransaction & { - txExecutionResultName?: string; - resultName?: string; - }; - const rawOutcome = legacy.txExecutionResultName || legacy.resultName || "COMPLETE"; - const outcome = FINALIZED_OUTCOMES[rawOutcome] || humanize(rawOutcome); - return {state: "finalized", outcome, label: label("Finalized", outcome)}; - } - - const outcome = DECISION_OUTCOMES[status]; - if (outcome) { - return {state: "decided", outcome, label: label("Decided", outcome)}; - } - - const phase = PROCESSING_PHASES[status] || "Processing"; - return {state: "processing", phase, label: label("Processing", phase)}; + return fromLifecycle(transaction.lifecycle); } -const ADVANCED_LIFECYCLE_FIELDS = new Set([ - "lifecycle", - "currentTimestamp", - "status", - "statusName", - "storedStatus", - "storedStatusName", - "resolutionAction", - "resolutionActionName", - "canFinalize", -]); +const PROTOCOL_LIFECYCLE_FIELDS = new Set(["lifecycle", "status", "statusName"]); -/** Keep receipt data useful while reserving raw lifecycle internals for debug. */ +/** Keep receipt data useful while reserving protocol details for `--raw`. */ export function withoutAdvancedLifecycle(transaction: GenLayerTransaction): Record { - return Object.fromEntries( - Object.entries(transaction).filter(([key]) => !ADVANCED_LIFECYCLE_FIELDS.has(key)), - ); + return Object.fromEntries(Object.entries(transaction).filter(([key]) => !PROTOCOL_LIFECYCLE_FIELDS.has(key))); } diff --git a/src/commands/transactions/receipt.ts b/src/commands/transactions/receipt.ts index b6f73f6b..dc61290b 100644 --- a/src/commands/transactions/receipt.ts +++ b/src/commands/transactions/receipt.ts @@ -1,10 +1,10 @@ import {BaseAction} from "../../lib/actions/BaseAction"; -import {TransactionHash, TransactionStatus} from "genlayer-js/types"; +import type {TransactionHash, TransactionReceiptWaitUntil} from "genlayer-js/types"; import {presentTransaction, withoutAdvancedLifecycle} from "./presentation"; export interface ReceiptParams { txId: TransactionHash; - status?: string | TransactionStatus; + waitUntil?: string | TransactionReceiptWaitUntil; retries?: number; interval?: number; rpc?: string; @@ -20,24 +20,21 @@ export class ReceiptAction extends BaseAction { super(); } - private validateTransactionStatus(status: string): TransactionStatus | undefined { - const upperStatus = status.toUpperCase() as keyof typeof TransactionStatus; - - if (!(upperStatus in TransactionStatus)) { - const validStatuses = Object.values(TransactionStatus); + private validateWaitUntil(waitUntil: string): TransactionReceiptWaitUntil | undefined { + const normalized = waitUntil.toLowerCase(); + if (normalized !== "decided" && normalized !== "finalized") { this.failSpinner( - "Invalid transaction status", - `Invalid status: ${status}. Valid values are: ${validStatuses.join(", ")}`, + "Invalid receipt wait target", + `Invalid wait target: ${waitUntil}. Valid values are: decided, finalized`, ); return; } - - return TransactionStatus[upperStatus]; + return normalized; } async receipt({ txId, - status = TransactionStatus.FINALIZED, + waitUntil = "finalized", retries, interval, rpc, @@ -47,18 +44,18 @@ export class ReceiptAction extends BaseAction { }: ReceiptParams): Promise { const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); - this.startSpinner(`Waiting for transaction receipt ${txId} (status: ${status})...`); + this.startSpinner(`Waiting for transaction receipt ${txId} (${waitUntil})...`); try { - let validatedStatus = this.validateTransactionStatus(status); + const validatedWaitUntil = this.validateWaitUntil(waitUntil); - if (!validatedStatus) { + if (!validatedWaitUntil) { return; } const result = await client.waitForTransactionReceipt({ hash: txId, - status: validatedStatus, + waitUntil: validatedWaitUntil, retries, interval, }); diff --git a/src/commands/vesting/validatorCreate.ts b/src/commands/vesting/validatorCreate.ts index b592f503..89d0f1c1 100644 --- a/src/commands/vesting/validatorCreate.ts +++ b/src/commands/vesting/validatorCreate.ts @@ -69,16 +69,13 @@ export class VestingValidatorCreateAction extends VestingAction { amount, }); - // The join receipt does not carry the wallet address; the vesting - // contract tracks its wallets, so the newest entry is the one created. - let validatorWallet = result.validatorWallet || result.wallet; + // The join result intentionally does not invent a wallet address from an + // unrelated event. The vesting contract's append-only wallet list is the + // authoritative source, and the new wallet is its final entry. + const wallets = await client.getValidatorWallets(vesting); + const validatorWallet = wallets[wallets.length - 1]; if (!validatorWallet) { - try { - const wallets = await client.getValidatorWallets(vesting); - validatorWallet = wallets[wallets.length - 1]; - } catch { - validatorWallet = "(read getValidatorWallets to inspect)"; - } + throw new Error("Validator creation succeeded, but the vesting contract returned no validator wallet"); } const output = { diff --git a/src/lib/config/KeychainManager.ts b/src/lib/config/KeychainManager.ts index 3793145f..da8281ff 100644 --- a/src/lib/config/KeychainManager.ts +++ b/src/lib/config/KeychainManager.ts @@ -1,4 +1,4 @@ -type Keytar = typeof import('keytar').default; +type Keytar = typeof import("keytar"); let keytarModule: Keytar | null = null; let keytarLoadAttempted = false; @@ -7,8 +7,8 @@ async function getKeytar(): Promise { if (keytarLoadAttempted) return keytarModule; keytarLoadAttempted = true; try { - const mod = await import('keytar'); - keytarModule = mod.default ?? mod; + const mod = await import("keytar"); + keytarModule = (mod as {default?: Keytar}).default ?? mod; return keytarModule; } catch { return null; @@ -16,7 +16,7 @@ async function getKeytar(): Promise { } export class KeychainManager { - private static readonly SERVICE = 'genlayer-cli'; + private static readonly SERVICE = "genlayer-cli"; constructor() {} @@ -28,7 +28,7 @@ export class KeychainManager { try { const keytar = await getKeytar(); if (!keytar) return false; - await keytar.findCredentials('test-service'); + await keytar.findCredentials("test-service"); return true; } catch { return false; @@ -37,12 +37,12 @@ export class KeychainManager { async storePrivateKey(accountName: string, privateKey: string): Promise { const keytar = await getKeytar(); - if (!keytar) throw new Error('Keychain not available. Install libsecret-1-dev on Linux.'); + if (!keytar) throw new Error("Keychain not available. Install libsecret-1-dev on Linux."); try { return await keytar.setPassword(KeychainManager.SERVICE, this.getKeychainAccount(accountName), privateKey); } catch (error: any) { - if (error?.message?.includes('org.freedesktop.secrets')) { - throw new Error('Keychain service not running. Install and start gnome-keyring or another secrets service.'); + if (error?.message?.includes("org.freedesktop.secrets")) { + throw new Error("Keychain service not running. Install and start gnome-keyring or another secrets service."); } throw error; } @@ -75,8 +75,8 @@ export class KeychainManager { const credentials = await keytar.findCredentials(KeychainManager.SERVICE); return credentials .map(c => c.account) - .filter(a => a.startsWith('account:')) - .map(a => a.replace('account:', '')); + .filter(a => a.startsWith("account:")) + .map(a => a.replace("account:", "")); } catch { return []; } @@ -86,4 +86,4 @@ export class KeychainManager { const key = await this.getPrivateKey(accountName); return key !== null; } -} \ No newline at end of file +} diff --git a/tests/actions/lifecycle.test.ts b/tests/actions/lifecycle.test.ts index 703a253a..137b322c 100644 --- a/tests/actions/lifecycle.test.ts +++ b/tests/actions/lifecycle.test.ts @@ -7,12 +7,12 @@ vi.mock("genlayer-js"); describe("LifecycleAction", () => { const txId = `0x${"12".repeat(32)}` as TransactionHash; - const request = vi.fn(); + const getTransactionLifecycle = vi.fn(); let action: LifecycleAction; beforeEach(() => { vi.clearAllMocks(); - vi.mocked(createClient).mockReturnValue({request} as any); + vi.mocked(createClient).mockReturnValue({advanced: {getTransactionLifecycle}} as any); action = new LifecycleAction(); vi.spyOn(action as any, "getAccount").mockResolvedValue(undefined); vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); @@ -26,25 +26,19 @@ describe("LifecycleAction", () => { projectedStatus: "Undetermined", resolutionAction: "MaterializeDecision", }; - request.mockResolvedValue(lifecycle); + getTransactionLifecycle.mockResolvedValue(lifecycle); await action.lifecycle({txId}); - expect(request).toHaveBeenCalledWith({ - method: "gen_getTransactionLifecycle", - params: [{txId}], - }); + expect(getTransactionLifecycle).toHaveBeenCalledWith({hash: txId}); expect(action["succeedSpinner"]).toHaveBeenCalledWith("Advanced transaction lifecycle", lifecycle); }); test("passes an optional evaluation timestamp to the lifecycle RPC", async () => { - request.mockResolvedValue({}); + getTransactionLifecycle.mockResolvedValue({}); await action.lifecycle({txId, timestamp: 1_700_000_000}); - expect(request).toHaveBeenCalledWith({ - method: "gen_getTransactionLifecycle", - params: [{txId, timestamp: 1_700_000_000}], - }); + expect(getTransactionLifecycle).toHaveBeenCalledWith({hash: txId, timestamp: 1_700_000_000}); }); }); diff --git a/tests/actions/presentation.test.ts b/tests/actions/presentation.test.ts index e4286e1f..2b2e0da6 100644 --- a/tests/actions/presentation.test.ts +++ b/tests/actions/presentation.test.ts @@ -3,7 +3,7 @@ import {presentTransaction, withoutAdvancedLifecycle} from "../../src/commands/t describe("transaction presentation", () => { test.each([ - [{lifecycle: {state: "processing", phase: "appeal_revealing"}}, "Processing · Appeal Revealing"], + [{lifecycle: {state: "processing", phase: "appeal-revealing"}}, "Processing · Appeal Revealing"], [{lifecycle: {state: "decided", outcome: "undetermined"}}, "Decided · Undetermined"], [{lifecycle: {state: "finalized", outcome: "accepted"}}, "Finalized · Accepted"], [{lifecycle: {state: "canceled"}}, "Canceled"], @@ -11,24 +11,13 @@ describe("transaction presentation", () => { expect(presentTransaction(transaction as any).label).toBe(expected); }); - test("prefers stored status over projected status on the train fallback", () => { - expect( - presentTransaction({ - storedStatusName: "PROPOSING", - statusName: "UNDETERMINED", - resolutionActionName: "MATERIALIZE_DECISION", - } as any).label, - ).toBe("Processing · Proposal"); - }); - - test("removes raw lifecycle internals from ordinary receipt output", () => { + test("removes protocol lifecycle details from ordinary receipt output", () => { expect( withoutAdvancedLifecycle({ hash: "0x01", status: "UNDETERMINED", - storedStatusName: "PROPOSING", - resolutionActionName: "MATERIALIZE_DECISION", - canFinalize: false, + statusName: "UNDETERMINED", + lifecycle: {state: "decided", outcome: "undetermined"}, } as any), ).toEqual({hash: "0x01"}); }); diff --git a/tests/actions/receipt.test.ts b/tests/actions/receipt.test.ts index 1c17c3ad..45f55fd2 100644 --- a/tests/actions/receipt.test.ts +++ b/tests/actions/receipt.test.ts @@ -1,7 +1,6 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; import {createClient, createAccount} from "genlayer-js"; import type {TransactionHash} from "genlayer-js/types"; -import {TransactionStatus} from "genlayer-js/types"; import {ReceiptAction, type ReceiptParams} from "../../src/commands/transactions/receipt"; vi.mock("genlayer-js"); @@ -35,7 +34,7 @@ describe("ReceiptAction", () => { }); test("retrieves transaction receipt successfully with default options", async () => { - const mockReceipt = {status: "FINALIZED", data: {hash: mockTxId}}; + const mockReceipt = {lifecycle: {state: "finalized"}, data: {hash: mockTxId}}; vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -47,7 +46,7 @@ describe("ReceiptAction", () => { expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ hash: mockTxId, - status: TransactionStatus.FINALIZED, + waitUntil: "finalized", retries: defaultRetries, interval: defaultInterval, }); @@ -58,20 +57,20 @@ describe("ReceiptAction", () => { }); test("retrieves transaction receipt with custom options", async () => { - const mockReceipt = {status: "ACCEPTED", data: {hash: mockTxId}}; + const mockReceipt = {lifecycle: {state: "decided", outcome: "accepted"}, data: {hash: mockTxId}}; vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); await receiptAction.receipt({ txId: mockTxId, - status: "ACCEPTED", + waitUntil: "decided", retries: 50, interval: 3000, }); expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ hash: mockTxId, - status: TransactionStatus.ACCEPTED, + waitUntil: "decided", retries: 50, interval: 3000, }); @@ -98,7 +97,7 @@ describe("ReceiptAction", () => { test("uses custom RPC URL for receipt operations", async () => { const rpcUrl = "https://custom-rpc-url.com"; - const mockReceipt = {status: "FINALIZED", data: {hash: mockTxId}}; + const mockReceipt = {lifecycle: {state: "finalized"}, data: {hash: mockTxId}}; vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -116,7 +115,7 @@ describe("ReceiptAction", () => { ); expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ hash: mockTxId, - status: TransactionStatus.FINALIZED, + waitUntil: "finalized", retries: defaultRetries, interval: defaultInterval, }); @@ -128,11 +127,9 @@ describe("ReceiptAction", () => { test("returns the full raw receipt behind --raw", async () => { const mockReceipt = { - status: "ACCEPTED", + status: 5, statusName: "ACCEPTED", - storedStatus: 5, - storedStatusName: "ACCEPTED", - resolutionActionName: "FINALIZE", + lifecycle: {state: "decided", outcome: "accepted"}, }; vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt as any); @@ -146,44 +143,42 @@ describe("ReceiptAction", () => { expect(receiptAction["succeedSpinner"]).toHaveBeenCalledWith("Raw transaction receipt", mockReceipt); }); - test("validates transaction status and shows error for invalid status", async () => { + test("validates the receipt wait target", async () => { await receiptAction.receipt({ txId: mockTxId, - status: "INVALID_STATUS", + waitUntil: "projected", retries: defaultRetries, interval: defaultInterval, }); expect(receiptAction["failSpinner"]).toHaveBeenCalledWith( - "Invalid transaction status", - expect.stringContaining("Invalid status: INVALID_STATUS"), + "Invalid receipt wait target", + expect.stringContaining("Invalid wait target: projected"), ); expect(mockClient.waitForTransactionReceipt).not.toHaveBeenCalled(); }); - test("accepts valid transaction statuses", async () => { - const mockReceipt = {status: "PENDING", data: {hash: mockTxId}}; + test("accepts materialized decision and finalization targets", async () => { + const mockReceipt = {lifecycle: {state: "processing", phase: "pending"}, data: {hash: mockTxId}}; vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); - const testStatuses = [ - {input: "accepted", expected: TransactionStatus.ACCEPTED}, - {input: "FINALIZED", expected: TransactionStatus.FINALIZED}, - {input: "pending", expected: TransactionStatus.PENDING}, - {input: "COMMITTING", expected: TransactionStatus.COMMITTING}, + const targets = [ + {input: "decided", expected: "decided"}, + {input: "FINALIZED", expected: "finalized"}, ]; - for (const {input, expected} of testStatuses) { + for (const {input, expected} of targets) { await receiptAction.receipt({ txId: mockTxId, - status: input, + waitUntil: input, retries: defaultRetries, interval: defaultInterval, }); expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ hash: mockTxId, - status: expected, + waitUntil: expected, retries: defaultRetries, interval: defaultInterval, }); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index d892eb41..a214bc12 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -81,6 +81,8 @@ const mockClient = { validatorExit: vi.fn(), validatorClaim: vi.fn(), setOperator: vi.fn(), + initiateOperatorTransfer: vi.fn(), + completeOperatorTransfer: vi.fn(), setIdentity: vi.fn(), delegatorJoin: vi.fn(), delegatorExit: vi.fn(), diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts index b6f4788e..a7c2eaeb 100644 --- a/tests/actions/stakingWizard.test.ts +++ b/tests/actions/stakingWizard.test.ts @@ -33,6 +33,7 @@ const mockGlClient = { })), getBeneficiaryVestings: vi.fn(async (_beneficiary?: string) => ["0xVesting"]), getVestingState: vi.fn(async () => ({ + revoked: false, totalAmountRaw: 100n * 10n ** 18n, totalWithdrawnRaw: 0n, })), diff --git a/tests/commands/receipt.test.ts b/tests/commands/receipt.test.ts index f01671dd..6a79b181 100644 --- a/tests/commands/receipt.test.ts +++ b/tests/commands/receipt.test.ts @@ -24,7 +24,7 @@ describe("receipt command", () => { expect(ReceiptAction).toHaveBeenCalledTimes(1); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, - status: "FINALIZED", + waitUntil: "finalized", retries: 100, interval: 5000, }); @@ -36,8 +36,8 @@ describe("receipt command", () => { "test", "receipt", mockTxId, - "--status", - "ACCEPTED", + "--wait-until", + "decided", "--retries", "50", "--interval", @@ -48,7 +48,7 @@ describe("receipt command", () => { expect(ReceiptAction).toHaveBeenCalledTimes(1); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, - status: "ACCEPTED", + waitUntil: "decided", retries: 50, interval: 3000, rpc: "https://custom-rpc-url-for-receipt.com", @@ -72,7 +72,7 @@ describe("receipt command", () => { program.parse(["node", "test", "receipt", mockTxId, "--retries", "25", "--interval", "1000"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, - status: "FINALIZED", + waitUntil: "finalized", retries: 25, interval: 1000, }); @@ -82,7 +82,7 @@ describe("receipt command", () => { program.parse(["node", "test", "receipt", mockTxId, "--retries", "invalid", "--interval", "notanumber"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, - status: "FINALIZED", + waitUntil: "finalized", retries: 100, interval: 5000, }); @@ -92,7 +92,7 @@ describe("receipt command", () => { program.parse(["node", "test", "receipt", mockTxId, "--stdout"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, - status: "FINALIZED", + waitUntil: "finalized", retries: 100, interval: 5000, stdout: true, @@ -103,7 +103,7 @@ describe("receipt command", () => { program.parse(["node", "test", "receipt", mockTxId, "--stderr"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, - status: "FINALIZED", + waitUntil: "finalized", retries: 100, interval: 5000, stderr: true, @@ -114,7 +114,7 @@ describe("receipt command", () => { program.parse(["node", "test", "receipt", mockTxId, "--raw"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, - status: "FINALIZED", + waitUntil: "finalized", retries: 100, interval: 5000, raw: true, @@ -125,7 +125,7 @@ describe("receipt command", () => { program.parse(["node", "test", "receipt", mockTxId, "--stdout", "--stderr"]); expect(ReceiptAction.prototype.receipt).toHaveBeenCalledWith({ txId: mockTxId, - status: "FINALIZED", + waitUntil: "finalized", retries: 100, interval: 5000, stdout: true, From 63f510839da06a630f0257aed37e5bf558e3b687 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 23:54:56 +0100 Subject: [PATCH 5/9] chore(tooling): pin corrected SDK enum --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 01f5f7d0..f8232a9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#7161e8edc27ca6ae0e54a625dd8318f1252d18c9", + "genlayer-js": "github:genlayerlabs/genlayer-js#d9c564ebb3294f4472fcc69638b0ab0b7624cf50", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -5683,7 +5683,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#7161e8edc27ca6ae0e54a625dd8318f1252d18c9", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#d9c564ebb3294f4472fcc69638b0ab0b7624cf50", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 02985ad6..1a0dea7c 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#7161e8edc27ca6ae0e54a625dd8318f1252d18c9", + "genlayer-js": "github:genlayerlabs/genlayer-js#d9c564ebb3294f4472fcc69638b0ab0b7624cf50", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", From 721177597d1655802757c44a69876e3c9248bddd Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 14:27:22 +0100 Subject: [PATCH 6/9] chore(tooling): pin current Studio SDK surface --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f8232a9d..0a38135a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#d9c564ebb3294f4472fcc69638b0ab0b7624cf50", + "genlayer-js": "github:genlayerlabs/genlayer-js#b223dfe264bfb43c77465be58b061c45d842843d", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -5683,7 +5683,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#d9c564ebb3294f4472fcc69638b0ab0b7624cf50", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#b223dfe264bfb43c77465be58b061c45d842843d", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1a0dea7c..4a1fa9d0 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#d9c564ebb3294f4472fcc69638b0ab0b7624cf50", + "genlayer-js": "github:genlayerlabs/genlayer-js#b223dfe264bfb43c77465be58b061c45d842843d", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", From 8a7085ddf1c8be3792ef8908622ce6baabad52e8 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 20:32:15 +0100 Subject: [PATCH 7/9] fix(fees): preserve network rotation defaults --- src/commands/contracts/fees.ts | 13 ++++++++++--- tests/actions/estimateFees.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/commands/contracts/fees.ts b/src/commands/contracts/fees.ts index f276e96d..a151306c 100644 --- a/src/commands/contracts/fees.ts +++ b/src/commands/contracts/fees.ts @@ -294,13 +294,20 @@ const profileEntryToEstimateOptions = ( options.appealRounds ?? entry.appealRounds?.toString() ?? parseProfilePresetAppealRounds(options), "--appeal-rounds", )!; + result.appealRounds = appealRounds; + + // When a profile does not choose a rotation policy, let the SDK apply the + // network's consensus default. Synthesizing zero rotations here would + // underfund a transaction while the SDK still advertises the network default. + if (entry.rotationsPerRound === undefined) { + return result; + } + const rotationsPerRound = parseBigNumberishOption( - entry.rotationsPerRound?.toString() ?? "0", + entry.rotationsPerRound.toString(), "--fee-profile rotationsPerRound", )!; const rotationCount = toSafeNonNegativeNumber(appealRounds, "--appeal-rounds") + 1; - - result.appealRounds = appealRounds; result.rotations = Array(rotationCount).fill(rotationsPerRound); return result; }; diff --git a/tests/actions/estimateFees.test.ts b/tests/actions/estimateFees.test.ts index 605f4ef4..da96c0d8 100644 --- a/tests/actions/estimateFees.test.ts +++ b/tests/actions/estimateFees.test.ts @@ -124,6 +124,34 @@ describe("EstimateFeesAction", () => { }); }); + test("defers rotations to the SDK default when the fee profile omits a rotation policy", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + deploy: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + }, + methods: {}, + }); + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue({ + distribution: {appealRounds: 1n, rotations: [3n, 3n]}, + feeValue: 1n, + }); + + await action.estimate({feeProfile: profilePath}); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + appealRounds: "1", + }); + }); + test("prints a static fee estimate as JSON without spinner output", async () => { const estimate = { distribution: {leaderTimeunitsAllocation: 100n, rotations: [0n]}, From 9619493373646ef5adbe88adf63770d50102c298 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 20:40:01 +0100 Subject: [PATCH 8/9] chore(tooling): pin corrected rotation defaults --- package-lock.json | 5 +++-- package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0a38135a..78332235 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#b223dfe264bfb43c77465be58b061c45d842843d", + "genlayer-js": "github:genlayerlabs/genlayer-js#8f72796efa6f1f52d420957bd253771c8583ca14", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -5683,7 +5683,8 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#b223dfe264bfb43c77465be58b061c45d842843d", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#8f72796efa6f1f52d420957bd253771c8583ca14", + "integrity": "sha512-W8vnalIYLzFrU+PKv2e0aEG19kmNXGX9hApDfScrXC90zI8ijAeKrt+FZrWC52f0hnJrsTkB7S5tdiYYQCZ2tw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 4a1fa9d0..e69eb838 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", - "genlayer-js": "github:genlayerlabs/genlayer-js#b223dfe264bfb43c77465be58b061c45d842843d", + "genlayer-js": "github:genlayerlabs/genlayer-js#8f72796efa6f1f52d420957bd253771c8583ca14", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", From 030992dfc35f49ff19d4e9585d9ceac56a4dc8c3 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 22:38:23 +0100 Subject: [PATCH 9/9] ci(smoke): skip pre-train testnets on v0.40 --- .github/workflows/smoke.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index ec29f228..3253835e 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -9,6 +9,10 @@ on: jobs: smoke: name: Testnet Smoke Tests + # The v0.40 line intentionally targets the resolution-kernel train and + # does not support the currently deployed pre-train testnets. Its live + # coverage comes from the cross-repository E2E stack until they upgrade. + if: github.event_name != 'pull_request' || github.base_ref != 'v0.40-dev' runs-on: ubuntu-latest timeout-minutes: 10 continue-on-error: true