From b7ca8a5872144775d343456f4c9073058959a5ec Mon Sep 17 00:00:00 2001 From: kirilaa Date: Tue, 25 Aug 2026 22:03:38 +0200 Subject: [PATCH 01/12] fix: read the consensus surfaces the resolution-kernel train exposes Four entrypoints the SDK calls were removed or reshaped by the train. The first one is what fails in E2E today, on the dev-env stack, before any scenario completes: getTransactionData(txId, timestamp) -> not found on ABI That read answered with a projection evaluated at a caller-supplied clock. The train splits the two apart: the stored record is getStoredTransactionData(txId) and the projection lives behind getTransactionLifecycle. Chains upgrade independently, so the transaction read now picks whichever the chain's OWN abi offers rather than assuming -- the dev-env harness builds its chain from the live deployment, so its abi is the train's while a testnet's is not. The stored record renames three fields. The decoder already normalised two of them (txData/txCalldata, numOfInitialValidators/initialRotations); it now also accepts observedAt for currentTimestamp, which it reads unconditionally, so an unrecognised name was a crash rather than a missing field. activeValidators() is gone because committee capacity is 1,543 and an address[] that long overruns the return-size limit. The joined registry is paged instead: read validatorsJoinedCount() first so a registry that grows underneath the walk cannot spin the loop, then getValidatorsJoined 64 at a time -- the size the paged reads are written around, and the one genlayer-node uses for the same walk. activeValidatorsCount() went with it, which took all of getEpochInfo() down with a single read. validatorView lost its registry tree pointers (left, right, parent), so the struct is resynced to the nine fields the train returns; both call sites read it by name, so nothing else moves. validatorsRoot() is removed from the abi as well -- nothing here calls it, and leaving a declaration for a function that no longer exists only invites a revert. The status map is renumbered with them. ReadyToFinalize stopped being a stored status -- readiness is the resolution kernel's verdict now -- and removing it at ordinal 11 shifts the three above it down. Left alone, a ValidatorsTimeout transaction decoded as "READY_TO_FINALIZE": no error, just the wrong answer. The enum member stays, because the node still reports that state; what changed is that no chain value decodes to it, so the name->number map is now partial and says so in its type. The repo's own tests pinned the old numbering and are re-pointed to the train's. --- src/abi/staking.ts | 25 +++++++++---------------- src/staking/actions.ts | 26 +++++++++++++++++++++++--- src/transactions/actions.ts | 16 ++++++++++++++-- src/transactions/decoders.ts | 6 +++++- src/types/transactions.ts | 25 ++++++++++++++++--------- tests/transactions.test.ts | 14 +++++++++----- 6 files changed, 76 insertions(+), 36 deletions(-) diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 9f3ea14..3a179b5 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -501,18 +501,21 @@ export const STAKING_ABI = [ // Functions { - name: "activeValidators", + name: "validatorsJoinedCount", type: "function", stateMutability: "view", inputs: [], - outputs: [{name: "", type: "address[]"}], + outputs: [{name: "", type: "uint256"}], }, { - name: "activeValidatorsCount", + name: "getValidatorsJoined", type: "function", stateMutability: "view", - inputs: [], - outputs: [{name: "", type: "uint256"}], + inputs: [ + {name: "_start", type: "uint256"}, + {name: "_count", type: "uint256"}, + ], + outputs: [{name: "", type: "address[]"}], }, { name: "activeWeights", @@ -1340,10 +1343,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "left", type: "address"}, - {name: "right", type: "address"}, - {name: "parent", type: "address"}, - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, @@ -1486,13 +1486,6 @@ export const STAKING_ABI = [ inputs: [], outputs: [{name: "", type: "uint256"}], }, - { - name: "validatorsRoot", - type: "function", - stateMutability: "view", - inputs: [], - outputs: [{name: "", type: "address"}], - }, ] as const; // Slash contract ABI for slashing events diff --git a/src/staking/actions.ts b/src/staking/actions.ts index cf46ab1..9126462 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -3,6 +3,12 @@ import {GenLayerClient, GenLayerChain, Address} from "@/types"; import {STAKING_ABI, VALIDATOR_WALLET_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "@/abi/staking"; import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; + +// The joined validator registry is only readable in slices: committee capacity +// is 1,543 and an address[] that long overruns the return-size limit. 64 is the +// size the paged reads are written around, and the one genlayer-node uses for +// the same walk. +const VALIDATORS_JOINED_PAGE_SIZE = 64n; import {operatorAddressFromPublicKey, verifyOperatorRegistration} from "@/vesting/operatorRegistration"; import { ValidatorInfo, @@ -835,7 +841,7 @@ export const stakingActions = ( ] = await Promise.all([ contract.read.epoch() as Promise, contract.read.finalized() as Promise, - contract.read.activeValidatorsCount() as Promise, + contract.read.validatorsJoinedCount() as Promise, contract.read.epochMinDuration() as Promise, contract.read.epochZeroMinDuration() as Promise, contract.read.epochOdd() as Promise, @@ -923,14 +929,28 @@ export const stakingActions = ( /** Returns addresses of all currently active validators. */ getActiveValidators: async (): Promise => { const contract = getReadOnlyStakingContract(); - const validators = (await contract.read.activeValidators()) as Address[]; + + // Read a page at a time. Committee capacity is 1,543, and an address[] + // that long overruns the return-size limit, so the joined registry is + // only reachable in slices. The count is read first so a registry 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 it settled. + const total = (await contract.read.validatorsJoinedCount()) as bigint; + const validators: Address[] = []; + + for (let start = 0n; start < total; start += VALIDATORS_JOINED_PAGE_SIZE) { + const page = (await contract.read.getValidatorsJoined([start, VALIDATORS_JOINED_PAGE_SIZE])) as Address[]; + if (page.length === 0) break; + validators.push(...page); + } + return validators.filter(v => v !== "0x0000000000000000000000000000000000000000"); }, /** Returns the count of active validators. */ getActiveValidatorsCount: async (): Promise => { const contract = getReadOnlyStakingContract(); - return contract.read.activeValidatorsCount() as Promise; + return contract.read.validatorsJoinedCount() as Promise; }, /** Returns addresses of validators currently in quarantine. */ diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 96b92ea..81c33cb 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -154,12 +154,24 @@ export const transactionActions = (client: GenLayerClient, public const contractAddress = client.chain.consensusDataContract?.address as Address; const contractAbi = client.chain.consensusDataContract?.abi as Abi; + // getTransactionData(txId, timestamp) answered with a projection evaluated at + // a caller-supplied clock. The resolution-kernel train splits that apart: the + // stored record is getStoredTransactionData(txId), and the projection lives + // behind getTransactionLifecycle. Chains are upgraded independently, so pick + // whichever read the chain's own ABI actually offers rather than assuming. + const hasStoredRead = (contractAbi as readonly {name?: string}[]).some( + entry => entry?.name === "getStoredTransactionData", + ); + const dataRead = hasStoredRead + ? {functionName: "getStoredTransactionData", args: [hash] as const} + : {functionName: "getTransactionData", args: [hash, Math.round(new Date().getTime() / 1000)] as const}; + const [txDataRaw, allDataRaw] = await Promise.all([ publicClient.readContract({ address: contractAddress, abi: contractAbi, - functionName: "getTransactionData", - args: [hash, Math.round(new Date().getTime() / 1000)], + functionName: dataRead.functionName, + args: dataRead.args as unknown as readonly unknown[], }) as Promise, publicClient.readContract({ address: contractAddress, diff --git a/src/transactions/decoders.ts b/src/transactions/decoders.ts index d112a7c..34176a6 100644 --- a/src/transactions/decoders.ts +++ b/src/transactions/decoders.ts @@ -77,6 +77,10 @@ export const decodeTransaction = (tx: GenLayerRawTransaction): GenLayerTransacti // Normalize field names across chain ABIs (Bradbury uses different names) const txData = tx.txData ?? (tx as any).txCalldata; const numOfInitialValidators = tx.numOfInitialValidators ?? (tx as any).initialRotations; + // The train's stored record calls this observedAt; older chains say + // currentTimestamp. Same value, and it is read unconditionally below, so an + // unrecognised name is a crash rather than a missing field. + const currentTimestamp = tx.currentTimestamp ?? (tx as any).observedAt; const txDataDecoded = decodeInputData(txData, tx.recipient); @@ -85,7 +89,7 @@ export const decodeTransaction = (tx: GenLayerRawTransaction): GenLayerTransacti txData: txData, txDataDecoded: txDataDecoded, - currentTimestamp: tx.currentTimestamp.toString(), + currentTimestamp: currentTimestamp?.toString() ?? "0", numOfInitialValidators: numOfInitialValidators?.toString() ?? "0", txSlot: tx.txSlot.toString(), createdTimestamp: tx.createdTimestamp.toString(), diff --git a/src/types/transactions.ts b/src/types/transactions.ts index f4ad76e..2fff7ec 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -41,6 +41,11 @@ export enum TransactionResult { MAJORITY_TIMEOUT = "MAJORITY_TIMEOUT", } +// ReadyToFinalize is no longer one of these. It stopped being a status the +// chain stores -- readiness is the resolution kernel's verdict now -- and +// removing it at ordinal 11 shifted the three above it down. The name survives +// as a client-side state the node still reports, so TransactionStatus keeps the +// member; what changed is that no chain value decodes to it. export const transactionsStatusNumberToName = { "0": TransactionStatus.UNINITIALIZED, "1": TransactionStatus.PENDING, @@ -53,13 +58,12 @@ export const transactionsStatusNumberToName = { "8": TransactionStatus.CANCELED, "9": TransactionStatus.APPEAL_REVEALING, "10": TransactionStatus.APPEAL_COMMITTING, - "11": TransactionStatus.READY_TO_FINALIZE, - "12": TransactionStatus.VALIDATORS_TIMEOUT, - "13": TransactionStatus.LEADER_TIMEOUT, - "14": TransactionStatus.LEADER_REVEALING, + "11": TransactionStatus.VALIDATORS_TIMEOUT, + "12": TransactionStatus.LEADER_TIMEOUT, + "13": TransactionStatus.LEADER_REVEALING, }; -export const transactionsStatusNameToNumber = { +export const transactionsStatusNameToNumber: Partial> = { [TransactionStatus.UNINITIALIZED]: "0", [TransactionStatus.PENDING]: "1", [TransactionStatus.PROPOSING]: "2", @@ -71,10 +75,13 @@ export const transactionsStatusNameToNumber = { [TransactionStatus.CANCELED]: "8", [TransactionStatus.APPEAL_REVEALING]: "9", [TransactionStatus.APPEAL_COMMITTING]: "10", - [TransactionStatus.READY_TO_FINALIZE]: "11", - [TransactionStatus.VALIDATORS_TIMEOUT]: "12", - [TransactionStatus.LEADER_TIMEOUT]: "13", - [TransactionStatus.LEADER_REVEALING]: "14", + // READY_TO_FINALIZE has no ordinal here on purpose. It is not a stored status + // any more, so there is no number it could take that would not collide with a + // real one -- 11 is VALIDATORS_TIMEOUT now. Looking it up yields undefined, + // which is the truthful answer: no chain status decodes to it. + [TransactionStatus.VALIDATORS_TIMEOUT]: "11", + [TransactionStatus.LEADER_TIMEOUT]: "12", + [TransactionStatus.LEADER_REVEALING]: "13", }; export const DECIDED_STATES = [ diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 6c343c0..06dcfda 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -35,7 +35,7 @@ describe("DECIDED_STATES constant", () => { describe("isDecidedState utility function", () => { it("should return true for all decided states", () => { - const decidedStatusNumbers = ["5", "6", "13", "12", "8", "7"]; // ACCEPTED, UNDETERMINED, LEADER_TIMEOUT, VALIDATORS_TIMEOUT, CANCELED, FINALIZED + const decidedStatusNumbers = ["5", "6", "12", "11", "8", "7"]; // ACCEPTED, UNDETERMINED, LEADER_TIMEOUT, VALIDATORS_TIMEOUT, CANCELED, FINALIZED decidedStatusNumbers.forEach(statusNum => { expect(isDecidedState(statusNum)).toBe(true); @@ -43,7 +43,7 @@ describe("isDecidedState utility function", () => { }); it("should return false for non-decided states", () => { - const nonDecidedStatusNumbers = ["0", "1", "2", "3", "4", "9", "10", "11", "14"]; // transient states + const nonDecidedStatusNumbers = ["0", "1", "2", "3", "4", "9", "10", "13"]; // transient states nonDecidedStatusNumbers.forEach(statusNum => { expect(isDecidedState(statusNum)).toBe(false); @@ -61,8 +61,12 @@ describe("isDecidedState utility function", () => { describe("transaction enum maps", () => { it("maps v0.6 transaction status, vote type, and result type values", () => { - expect(transactionsStatusNumberToName["14"]).toBe(TransactionStatus.LEADER_REVEALING); - expect(isDecidedState("14")).toBe(false); + // The train removed ReadyToFinalize at ordinal 11 and shifted the three + // above it down, so LeaderRevealing is 13 now and nothing occupies 14. + expect(transactionsStatusNumberToName["13"]).toBe(TransactionStatus.LEADER_REVEALING); + expect(transactionsStatusNumberToName["11"]).toBe(TransactionStatus.VALIDATORS_TIMEOUT); + expect(transactionsStatusNumberToName["12"]).toBe(TransactionStatus.LEADER_TIMEOUT); + expect(isDecidedState("13")).toBe(false); expect(executionResultNumberToName["3"]).toBe(ExecutionResult.TIMEOUT); expect(executionResultNumberToName["4"]).toBe(ExecutionResult.NONDET_DISAGREE); expect(transactionResultNumberToName).toEqual({ @@ -152,7 +156,7 @@ describe("waitForTransactionReceipt with DECIDED_STATES", () => { }); it("should accept all decided states when waiting for ACCEPTED", async () => { - const decidedStatusNumbers = ["5", "6", "13", "12", "8", "7"]; // All decided states + const decidedStatusNumbers = ["5", "6", "12", "11", "8", "7"]; // All decided states for (const statusNum of decidedStatusNumbers) { const mockTransaction = { From ac8fbb1d51c0f6e8a32d46b263b36c7546e1c4b6 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Wed, 26 Aug 2026 08:39:17 +0200 Subject: [PATCH 02/12] fix: resync validatorViewPrimed and validatorViewPrePrimed The earlier commit resynced validatorView and missed its two siblings, which return the same struct and drifted the same way: the train dropped the registry tree pointers (left, right, parent), so a twelve-field decode runs off the end of a nine-field return. It surfaces as a bounds error rather than a wrong value -- "Position 319 is out of bounds (0 < position < 288)" from validatorViewPrimed -- because the reader walks past the encoded tail. --- src/abi/staking.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 3a179b5..1fb069a 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -1366,10 +1366,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "left", type: "address"}, - {name: "right", type: "address"}, - {name: "parent", type: "address"}, - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, @@ -1392,10 +1389,7 @@ export const STAKING_ABI = [ name: "", type: "tuple", components: [ - {name: "left", type: "address"}, - {name: "right", type: "address"}, - {name: "parent", type: "address"}, - {name: "eBanned", type: "uint256"}, + {name: "eBanned", type: "uint256"}, {name: "ePrimed", type: "uint256"}, {name: "vStake", type: "uint256"}, {name: "vShares", type: "uint256"}, From 71a201b78501e8e0e524298779180b35b2b209de Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 17:49:01 +0100 Subject: [PATCH 03/12] fix: align SDK consumers with train contracts --- README.md | 16 +- docs/api-references/contracts.md | 295 +++++++- docs/api-references/index.md | 245 ++++++- docs/api-references/staking.md | 111 ++- docs/api-references/transactions.md | 18 +- .../types.Enumeration.TransactionStatus.md | 16 +- ...Variable.transactionsStatusNumberToName.md | 6 +- src/abi/consensusTrain.ts | 42 ++ src/abi/staking.ts | 200 +----- src/contracts/actions.ts | 675 +++++++++++------- src/staking/actions.ts | 130 +--- src/transactions/actions.ts | 230 +++++- src/transactions/decoders.ts | 20 +- src/types/clients.ts | 16 +- src/types/staking.ts | 11 +- src/types/transactions.ts | 113 ++- tests/client.test-d.ts | 11 + tests/contracts-actions.test.ts | 398 +++++++---- tests/staking-actions.test.ts | 127 ++++ tests/staking-commit-layout.test.ts | 162 +++-- tests/transactions.test.ts | 373 ++++++++-- 21 files changed, 2370 insertions(+), 845 deletions(-) create mode 100644 src/abi/consensusTrain.ts diff --git a/README.md b/README.md index fd9aa57..aafb63f 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,17 @@ Transactions can emit messages to other contracts. These messages create new chi ```typescript const tx = await client.getTransaction({ hash: txHash }); +// Projected lifecycle state, exact stored state, and queue-head readiness are +// intentionally separate. +console.log(tx.statusName); +console.log(tx.storedStatusName); +console.log(tx.resolutionActionName); +console.log(tx.canFinalize); + +// The train retains the authoritative execution hash, not the old receipt +// bytes. `txReceipt` is therefore unavailable on train transactions. +console.log(tx.txExecutionHash); + // Messages emitted by the contract during execution console.log(tx.messages); // [{messageType, recipient, value, data, onAcceptance, saltNonce}, ...] @@ -428,9 +439,12 @@ const epochInfo = await client.getEpochInfo(); // totalClaimed: "500 GEN", // Total claimed rewards // } -// Get active validators +// Get validators currently eligible for consensus duties const validators = await client.getActiveValidators(); +// Inspect every identity in the append-only joined registry +const joinedValidators = await client.getJoinedValidators(); + // Check if address is a validator const isValidator = await client.isValidator("0x..."); diff --git a/docs/api-references/contracts.md b/docs/api-references/contracts.md index ddb4150..ceaee7f 100644 --- a/docs/api-references/contracts.md +++ b/docs/api-references/contracts.md @@ -4,7 +4,7 @@ Methods for deploying, reading, writing, and simulating GenLayer intelligent con ### getContractCode -Retrieves the source code of a deployed contract. Localnet only. +Retrieves the source code of a deployed contract. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -16,7 +16,7 @@ Retrieves the source code of a deployed contract. Localnet only. ### getContractSchema -Gets the schema (methods and constructor) of a deployed contract. Localnet only. +Gets the schema (methods and constructor) of a deployed contract. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -28,7 +28,7 @@ Gets the schema (methods and constructor) of a deployed contract. Localnet only. ### getContractSchemaForCode -Generates a schema for contract code without deploying it. Localnet only. +Generates a schema for contract code without deploying it. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -70,10 +70,15 @@ Simulates a state-modifying contract call without executing on-chain. | args | `CalldataEncodable[]` | no | | | kwargs | `Map \| {[key: string]: CalldataEncodable}` | no | | | rawReturn | `RawReturn` | no | | +| includeReceipt | `IncludeReceipt` | no | | +| value | `BigNumberish` | no | | | leaderOnly | `boolean` | no | | +| fees | `TransactionFeeOptions` | no | | | transactionHashVariant | `TransactionHashVariant` | no | | -**Returns:** `RawReturn extends true ? 0x${string} : CalldataEncodable` +**Returns:** `IncludeReceipt extends true + ? SimulateWriteContractResult + : RawReturn extends true ? 0x${string} : CalldataEncodable` --- @@ -88,9 +93,11 @@ Executes a state-modifying function on a contract through consensus. Returns the | functionName | `string` | yes | | | args | `CalldataEncodable[]` | no | | | kwargs | `Map \| {[key: string]: CalldataEncodable}` | no | | -| value | `bigint` | yes | | +| value | `bigint` | no | | | leaderOnly | `boolean` | no | | | consensusMaxRotations | `number` | no | | +| validUntil | `BigNumberish` | no | | +| fees | `TransactionFeeOptions` | no | | **Returns:** `0x${string}` @@ -108,12 +115,92 @@ Deploys a new intelligent contract to GenLayer. Returns the transaction hash. | kwargs | `Map \| {[key: string]: CalldataEncodable}` | no | | | leaderOnly | `boolean` | no | | | consensusMaxRotations | `number` | no | | +| validUntil | `BigNumberish` | no | | +| fees | `TransactionFeeOptions` | no | | + +--- + +### getCurrentFeePolicy + +Returns the active fee price policy used to build user-side caps. + +_No parameters._ + +**Returns:** `FeePolicyQuote` + +--- + +### estimateFeesDistribution + +Builds a fee distribution with caps derived from the active fee policy. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| args | `FeeEstimateOptions` | no | | + +**Returns:** `FeesDistribution` + +--- + +### estimateTransactionFees + +Builds a complete transaction `fees` object, including feeValue. +Studio has no on-chain FeeManager in the chain definition, so this uses +the same deterministic round-fee math as Studio trusted mode there. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| args | `FeeEstimateOptions` | no | | + +**Returns:** `TransactionFeeEstimate` + +--- + +### estimateTransactionFeesFromSimulation + +Builds a trusted fee preset from a representative Studio simulation. +This turns the returned fee accounting/report into execution and message +budgets while preserving mode-2 message allocations when the simulation +was run with them. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| args | `SimulationFeeEstimateOptions` | yes | | + +**Returns:** `TransactionFeeEstimate` + +--- + +### estimateTransactionFeesForWrite + +Builds a trusted fee preset for a concrete write call in one step. +The method first gives the simulation a baseline fee budget, then uses +the returned Studio/GenVM fee accounting to derive the preset the dapp +should pass with the real transaction. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| args | `WriteFeeEstimateOptions` | yes | | + +**Returns:** `TransactionFeeEstimate` + +--- + +### getAppealCharge + +Returns the full authoritative appeal charge (bond plus appeal funding). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | + +**Returns:** `bigint` --- ### getMinAppealBond -Calculates the minimum bond required to appeal a transaction. +@deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -123,6 +210,115 @@ Calculates the minimum bond required to appeal a transaction. --- +### getRoundNumber + +Returns the current consensus round number for a transaction. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | + +**Returns:** `bigint` + +--- + +### getRoundData + +Returns detailed data for a specific consensus round. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | +| round | `bigint` | yes | | + +--- + +### getLastRoundData + +Returns the current round number and its data for a transaction. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | + +--- + +### canAppeal + +Checks if a transaction can be appealed. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| txId | ``0x${string}`` | yes | | + +**Returns:** `boolean` + +--- + +### getDeveloperNft + +Returns a developer's NFT reward record, or null when no NFT is registered. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| developer | `Address` | yes | | + +**Returns:** `DeveloperNft | null` + +--- + +### getClaimableRewardsFromFees + +Returns claimable developer-NFT rewards accrued from transaction fees. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| nftId | `BigNumberish` | yes | | + +**Returns:** `bigint` + +--- + +### getClaimableRewardsFromInflation + +Returns claimable developer-NFT rewards accrued from inflation. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| nftId | `BigNumberish` | yes | | +| numberOfEpochsToClaim | `BigNumberish` | yes | | + +**Returns:** `bigint` + +--- + +### claimNftRewards + +Claims all currently available rewards for a developer NFT. Returns the EVM transaction hash. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| nftId | `BigNumberish` | yes | | + +**Returns:** `0x${string}` + +--- + +### claimNftEpochs + +Claims a bounded number of reward epochs for a developer NFT. Returns the EVM transaction hash. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| nftId | `BigNumberish` | yes | | +| numberOfEpochsToClaim | `BigNumberish` | yes | | + +**Returns:** `0x${string}` + +--- + ### appealTransaction Appeals a consensus transaction to trigger a new round of validation. @@ -135,3 +331,90 @@ Appeals a consensus transaction to trigger a new round of validation. --- +### topUpFees + +Deposits additional fee budget for an existing consensus transaction. +Returns the backend RPC hash: an EVM transaction hash on network +backends, or the target GenLayer tx id on Studio/localnet. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txId | ``0x${string}`` | yes | | +| distribution | `FeesDistributionInput` | yes | | +| value | `bigint` | yes | | + +**Returns:** `0x${string}` + +--- + +### topUpAndSubmitAppeal + +Deposits appeal fee budget and submits an appeal in the same consensus call. +Returns the existing GenLayer transaction id, matching appealTransaction. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txId | ``0x${string}`` | yes | | +| distribution | `FeesDistributionInput` | yes | | +| value | `bigint` | no | | + +**Returns:** `0x${string}` + +--- + +### finalizeTransaction + +Finalizes a single GenLayer transaction that is ready to be finalized. Returns the EVM transaction hash. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txId | ``0x${string}`` | yes | | + +**Returns:** `0x${string}` + +--- + +### finalizeIdlenessTxs + +@deprecated The train separates attempt-bound resolution from +decision-bound finalization. Use resolveTransactions or +finalizeDecisions after classifying the lifecycle action. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txIds | `readonly `0x${string}`[]` | yes | | + +**Returns:** `0x${string}` + +--- + +### resolveTransactions + +Resolves a batch of attempt-bound lifecycle actions. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txIds | `readonly `0x${string}`[]` | yes | | + +**Returns:** `0x${string}` + +--- + +### finalizeDecisions + +Finalizes a batch of active, decision-bound transactions. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txIds | `readonly `0x${string}`[]` | yes | | + +**Returns:** `0x${string}` + +--- + diff --git a/docs/api-references/index.md b/docs/api-references/index.md index c08ef14..753e632 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -103,6 +103,161 @@ const receipt = await client.waitForTransactionReceipt({ ``` +### Fee presets for transactions + +Apps can build a trusted fee preset once they know the transaction shape, then +submit the same preset with the transaction. The user may still override these +values in wallet or app UI before signing. + +```typescript +const estimate = await client.estimateTransactionFees({ + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + rotations: [0n], +}); + +const txHash = await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: estimate.distribution, + feeValue: estimate.feeValue, + }, +}); +``` + +If `fees.distribution` is provided without `feeValue`, the SDK derives the fee +deposit from FeeManager on network backends, or from `sim_getFeeConfig` on +Studio. Use `messageAllocations` with `estimateTransactionFees` for transactions +that can emit funded messages. + +Use the SDK call-key helpers when targeting a specific emitted message. Internal +messages are keyed by the GenVM method name; external EVM messages are keyed by +the first 4 bytes of the calldata selector. + +```typescript +import { + MessageType, + deriveExternalMessageCallKey, + deriveInternalMessageCallKey, + encodeExternalMessageFeeParams, + encodeInternalMessageFeeParams, +} from "genlayer-js"; + +const estimate = await client.estimateTransactionFees({ + messageAllocations: [ + { + messageType: MessageType.Internal, + recipient: childContractAddress, + callKey: deriveInternalMessageCallKey("settle_campaign"), + budget: 700_000n, + feeParams: encodeInternalMessageFeeParams({ + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + }), + }, + { + messageType: MessageType.External, + recipient: tokenAddress, + callKey: deriveExternalMessageCallKey("0xa9059cbb"), + budget: 210_000n, + feeParams: encodeExternalMessageFeeParams({ + gasLimit: 21_000n, + maxGasPrice: 10n, + }), + }, + ], +}); +``` + +You can also pass the same preset to Studio/localnet simulation. On Studio, +`includeReceipt` uses `sim_call` so the returned object includes the GenVM +receipt and fee accounting report: + +```typescript +const recommended = await client.estimateTransactionFeesForWrite({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], +}); + +await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: recommended.distribution, + messageAllocations: recommended.messageAllocations, + feeValue: recommended.feeValue, + }, +}); +``` + +For tests or tools that need to inspect the raw simulation, use the explicit +two-step flow: + +```typescript +const simulation = await client.simulateWriteContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: estimate.distribution, + feeValue: estimate.feeValue, + }, + includeReceipt: true, +}); + +console.log(simulation.feeAccounting); + +const recommended = await client.estimateTransactionFeesFromSimulation({ + simulation, +}); + +await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: recommended.distribution, + messageAllocations: recommended.messageAllocations, + feeValue: recommended.feeValue, + }, +}); +``` + +For transactions that are already submitted, use the fee-management helpers: + +```typescript +await client.topUpFees({ + txId, + value: 1_100n, + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + rotations: [0n], + }, +}); + +await client.topUpAndSubmitAppeal({ + txId, + value: 1_400n, + distribution: { + appealRounds: 1n, + rotations: [0n, 0n], + }, +}); +``` + +`topUpFees` returns the backend RPC hash. On network backends this is the EVM +transaction hash; on Studio/localnet it is the target GenLayer transaction id. + ### Checking execution results A transaction can be finalized by consensus but still have a failed execution. Always check `txExecutionResult` before reading contract state: @@ -138,6 +293,17 @@ Transactions can emit messages to other contracts. These messages create new chi ```typescript const tx = await client.getTransaction({ hash: txHash }); +// Projected lifecycle state, exact stored state, and queue-head readiness are +// intentionally separate. +console.log(tx.statusName); +console.log(tx.storedStatusName); +console.log(tx.resolutionActionName); +console.log(tx.canFinalize); + +// The train retains the authoritative execution hash, not the old receipt +// bytes. `txReceipt` is therefore unavailable on train transactions. +console.log(tx.txExecutionHash); + // Messages emitted by the contract during execution console.log(tx.messages); // [{messageType, recipient, value, data, onAcceptance, saltNonce}, ...] @@ -164,6 +330,80 @@ console.log(trace.stderr); // standard error output console.log(trace.genvm_log); // detailed GenVM execution logs ``` +### Using with a wallet provider (MetaMask) + +When building a browser dApp, create two clients: one for reads (no wallet needed) and one for writes (signed by the wallet). This follows the standard viem pattern and keeps concerns separated. + +```typescript +import { createClient } from "genlayer-js"; +import { testnetBradbury } from "genlayer-js/chains"; +import { TransactionStatus } from "genlayer-js/types"; + +// Read client — talks directly to GenLayer RPC, no wallet needed +const readClient = createClient({ + chain: testnetBradbury, +}); + +// Write client — signs transactions through the wallet +const writeClient = createClient({ + chain: testnetBradbury, + account: address as `0x${string}`, // from wallet connection + provider: window.ethereum, // or from a wallet SDK +}); + +// Use readClient for all reads +const result = await readClient.readContract({ + address: contractAddress, + functionName: "get_storage", + args: [], +}); + +const tx = await readClient.getTransaction({ hash: txHash }); + +// Use writeClient for transactions (MetaMask popup) +const txHash = await writeClient.writeContract({ + address: contractAddress, + functionName: "update_storage", + args: ["new_value"], + value: BigInt(0), +}); + +// Either client can wait for receipts +const receipt = await readClient.waitForTransactionReceipt({ + hash: txHash, + status: TransactionStatus.ACCEPTED, +}); +``` + +### Switching the wallet to the correct network + +When using MetaMask or another browser wallet, the wallet may be connected to a different chain than what your client is configured for. Use `client.connect()` to switch the wallet to the correct GenLayer network before sending transactions: + +```typescript +import { createClient } from "genlayer-js"; +import { studionet } from "genlayer-js/chains"; + +const client = createClient({ + chain: studionet, + account: address as `0x${string}`, +}); + +// Switch MetaMask to the correct chain (adds the network if not present) +await client.connect("studionet"); + +// Now transactions will go to the right network +const txHash = await client.writeContract({ + address: contractAddress, + functionName: "create_profile", + args: ["alice", "Hello world"], + value: BigInt(0), +}); +``` + +Available networks: `"localnet"`, `"studionet"`, `"testnetAsimov"`, `"testnetBradbury"`. + +> **Note:** If the wallet is on the wrong chain when you call `writeContract`, the SDK will throw a clear error telling you which chain the wallet is on vs. which chain the client expects. Call `client.connect()` to resolve this. + ### Staking Operations The SDK provides staking functionality for validators and delegators on testnet-bradbury (and testnet-asimov). @@ -195,9 +435,12 @@ const epochInfo = await client.getEpochInfo(); // totalClaimed: "500 GEN", // Total claimed rewards // } -// Get active validators +// Get validators currently eligible for consensus duties const validators = await client.getActiveValidators(); +// Inspect every identity in the append-only joined registry +const joinedValidators = await client.getJoinedValidators(); + // Check if address is a validator const isValidator = await client.isValidator("0x..."); diff --git a/docs/api-references/staking.md b/docs/api-references/staking.md index da70e2c..6ceb872 100644 --- a/docs/api-references/staking.md +++ b/docs/api-references/staking.md @@ -17,11 +17,15 @@ Joins as a validator with the specified stake amount. ### validatorDeposit -Adds additional self-stake to an active validator position. +Adds additional self-stake to an active validator position. The +underlying Staking contract requires msg.sender == ValidatorWallet, +so the call is routed through the wallet's own validatorDeposit +forwarder (which re-enters Staking with the correct sender). | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | amount | `bigint \| string` | yes | | +| validator | `Address` | yes | | **Returns:** `StakingTransactionResult` @@ -29,11 +33,13 @@ Adds additional self-stake to an active validator position. ### validatorExit -Exits a validator position by burning the specified shares. +Exits a validator position by burning the specified shares. Same +msg.sender constraint as validatorDeposit — routed via the wallet. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | shares | `bigint \| string` | yes | | +| validator | `Address` | yes | | **Returns:** `StakingTransactionResult` @@ -65,7 +71,7 @@ Primes a validator for participation in the next epoch. ### setOperator -Sets the operator address for a validator wallet. +@deprecated Use initiateOperatorTransfer followed by completeOperatorTransfer. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -76,6 +82,57 @@ Sets the operator address for a validator wallet. --- +### initiateOperatorTransfer + +Starts the two-step operator rotation. The proof is checked against the +wallet-bound context before submission so a registration built for the +wrong registrar fails locally instead of as an opaque on-chain revert. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| options | `InitiateOperatorTransferOptions` | yes | | + +**Returns:** `StakingTransactionResult` + +--- + +### completeOperatorTransfer + +Completes a pending rotation. Callable by the wallet owner or the pending +operator, and only once the factory's operatorTransferDelay has elapsed. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| options | `CompleteOperatorTransferOptions` | yes | | + +**Returns:** `StakingTransactionResult` + +--- + +### cancelOperatorTransfer + +Abandons a pending rotation, leaving the current operator in place. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| options | `CancelOperatorTransferOptions` | yes | | + +**Returns:** `StakingTransactionResult` + +--- + +### getPendingOperator + +Reads the pending operator and when its transfer was initiated. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| validator | `Address` | yes | | + +**Returns:** `PendingOperatorInfo` + +--- + ### setIdentity Sets validator identity information (name, website, social links). @@ -138,7 +195,7 @@ Claims pending delegator withdrawals. ### isValidator -Checks if an address is an active validator. +Checks whether an address is a registered/joined validator wallet. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -160,6 +217,28 @@ Returns comprehensive information about a validator including stake, identity, a --- +### getCurrentEpoch + +Returns the current epoch number. + +_No parameters._ + +**Returns:** `bigint` + +--- + +### isValidatorBelowMin + +Checks whether a validator's self-stake is below the configured validator minimum. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| validator | `Address` | yes | | + +**Returns:** `boolean` + +--- + ### getStakeInfo Returns delegation stake information for a delegator-validator pair. @@ -197,7 +276,7 @@ Returns detailed data for a specific epoch. ### getActiveValidators -Returns addresses of all currently active validators. +Returns validators currently eligible for consensus duties. _No parameters._ @@ -207,7 +286,27 @@ _No parameters._ ### getActiveValidatorsCount -Returns the count of active validators. +Returns the count of validators currently eligible for consensus duties. + +_No parameters._ + +**Returns:** `bigint` + +--- + +### getJoinedValidators + +Returns every validator identity in the append-only joined registry. + +_No parameters._ + +**Returns:** `Address[]` + +--- + +### getJoinedValidatorsCount + +Returns the size of the append-only joined validator registry. _No parameters._ diff --git a/docs/api-references/transactions.md b/docs/api-references/transactions.md index 69a518d..70ba31a 100644 --- a/docs/api-references/transactions.md +++ b/docs/api-references/transactions.md @@ -9,7 +9,8 @@ Polls until a transaction reaches the specified status. Returns the transaction | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | hash | `TransactionHash` | yes | | -| status | `TransactionStatus` | yes | | +| status | `TransactionStatus` | no | | +| waitUntil | `TransactionReceiptWaitUntil` | no | | | interval | `number` | no | | | retries | `number` | no | | | fullTransaction | `boolean` | no | | @@ -20,7 +21,8 @@ Polls until a transaction reaches the specified status. Returns the transaction ### getTransaction -Fetches transaction data including status, execution result, and consensus details. +Fetches the train transaction snapshot, including projected/stored status, +resolution action, authoritative finalization readiness, and split round data. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -67,6 +69,18 @@ Cancels a pending transaction. Studio networks only. --- +### getTransactionQueuePosition + +Returns the queue slot position of a transaction in the pending queue. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | + +**Returns:** `number` + +--- + ### estimateTransactionGas Estimates gas required for a transaction. diff --git a/docs/api-references/types.Enumeration.TransactionStatus.md b/docs/api-references/types.Enumeration.TransactionStatus.md index a774a1e..2fdd594 100644 --- a/docs/api-references/types.Enumeration.TransactionStatus.md +++ b/docs/api-references/types.Enumeration.TransactionStatus.md @@ -52,6 +52,14 @@ Defined in: [types/transactions.ts:15](https://github.com/genlayerlabs/genlayer- *** +### LEADER\_REVEALING + +> **LEADER\_REVEALING**: `"LEADER_REVEALING"` + +Defined in: [types/transactions.ts:23](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/transactions.ts#L23) + +*** + ### LEADER\_TIMEOUT > **LEADER\_TIMEOUT**: `"LEADER_TIMEOUT"` @@ -76,14 +84,6 @@ Defined in: [types/transactions.ts:10](https://github.com/genlayerlabs/genlayer- *** -### READY\_TO\_FINALIZE - -> **READY\_TO\_FINALIZE**: `"READY_TO_FINALIZE"` - -Defined in: [types/transactions.ts:19](https://github.com/genlayerlabs/genlayer-js/blob/eaba6adec6803bdd0b4968e3f0763cf22107acd1/src/types/transactions.ts#L19) - -*** - ### REVEALING > **REVEALING**: `"REVEALING"` diff --git a/docs/api-references/types.Variable.transactionsStatusNumberToName.md b/docs/api-references/types.Variable.transactionsStatusNumberToName.md index aa81168..0ed1f98 100644 --- a/docs/api-references/types.Variable.transactionsStatusNumberToName.md +++ b/docs/api-references/types.Variable.transactionsStatusNumberToName.md @@ -20,15 +20,15 @@ Defined in: [types/transactions.ts:40](https://github.com/genlayerlabs/genlayer- ### 11 -> **11**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.READY_TO_FINALIZE` +> **11**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.VALIDATORS_TIMEOUT` ### 12 -> **12**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.VALIDATORS_TIMEOUT` +> **12**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.LEADER_TIMEOUT` ### 13 -> **13**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.LEADER_TIMEOUT` +> **13**: [`TransactionStatus`](types.Enumeration.TransactionStatus.md) = `TransactionStatus.LEADER_REVEALING` ### 2 diff --git a/src/abi/consensusTrain.ts b/src/abi/consensusTrain.ts new file mode 100644 index 0000000..55f363c --- /dev/null +++ b/src/abi/consensusTrain.ts @@ -0,0 +1,42 @@ +import {Abi, parseAbi} from "viem"; + +// Keep these ABIs runtime-validated without asking TypeScript to expand the very +// large lifecycle tuple into a recursive inferred type. +const parseTrainAbi = parseAbi as (signatures: readonly string[]) => Abi; + +/** Minimal train ABIs used by the high-level transaction reader. */ +export const CONSENSUS_DATA_TRAIN_ABI = parseTrainAbi([ + "function addressManager() view returns (address)", + "function getTransactionLifecycle(bytes32 _txId, uint256 _timestamp) view returns ((uint8 storedStatus, (bytes32 txId, uint8 storedStatus, uint8 projectedStatus, uint8 action, uint8 result, bytes32 resultHash, uint8 source, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint256 basisDecisionId, uint8 context, bytes32 attemptId, uint256 boundaryAt, uint256 evaluatedAt, uint256 snapshotBlock, uint256 decisionWindow, uint256 appealDeadline, bool materializesDecision, bool actionOutcomeDeterministic, bool nonCurrentEvaluation) resolution, (bool exists, uint256 decisionId, uint256 basisDecisionId, uint8 context, uint8 source, bytes32 sourceAttemptId, uint8 sourceStatus, uint8 status, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint8 result, bytes32 resultHash, uint256 effectiveAt, uint256 materializedAt, uint256 appealDeadline) latestDecision, bool decisionActive) lifecycle)", + "function canFinalize(bytes32 _txId, uint256 _currentTimestamp, uint256 _expectedDecisionId) view returns (bool ready, uint256 evaluatedAt, uint256 appealDeadline)", + "function estimateLatestAppealCharge(bytes32 _txId) view returns (uint256 decisionId, uint256 bond, uint256 funding, uint256 appealDeadline)", +]); + +export const ADDRESS_MANAGER_TRAIN_ABI = parseTrainAbi([ + "function getAddress(string _name) view returns (address)", +]); + +export const CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI = parseTrainAbi([ + "function getStoredTransactionDataLight(bytes32 _txId) view returns ((uint256 observedAt, address sender, address recipient, uint256 initialRotations, uint256 txSlot, uint256 createdTimestamp, uint256 lastVoteTimestamp, bytes32 randomSeed, uint8 result, bytes32 txExecutionHash, bytes txCalldata, bytes eqBlocksOutputs, (uint8 messageType, address recipient, uint256 value, bytes data, bool onAcceptance, uint256 saltNonce, bytes feeParams, uint256 declaredBudget, bytes allocationSubtree, bytes32 callKey, bool useBalance)[] messages, uint8 queueType, uint256 queuePosition, address activator, address lastLeader, uint8 status, bytes32 txId, (uint256 activationBlock, uint256 processingBlock, uint256 proposalBlock) readStateBlockRange, uint256 numOfRounds, (uint256 round, uint256 leaderIndex, uint256 votesCommitted, uint256 votesRevealed, uint256 appealBond, uint256 rotationsLeft, uint8 result, uint256 validatorsCount) lastRound, uint256 consumedValidatorsCount) transaction)", + "function getRoundValidatorsPaged(bytes32 _txId, uint256 _round, uint256 _offset, uint256 _limit) view returns (address[] page, uint256 total)", + "function getConsumedValidatorsPaged(bytes32 _txId, uint256 _offset, uint256 _limit) view returns (address[] page, uint256 total)", +]); + +export const ROUNDS_STORAGE_TRAIN_READ_ABI = parseTrainAbi([ + "function getRoundNumber(bytes32 txId) view returns (uint256)", + "function getLeaderIndex(bytes32 txId, uint256 round) view returns (uint256)", + "function getVotesCommitted(bytes32 txId, uint256 round) view returns (uint256)", + "function getVotesRevealed(bytes32 txId, uint256 round) view returns (uint256)", + "function getAppealBond(bytes32 txId, uint256 round) view returns (uint256)", + "function getRotationsLeft(bytes32 txId, uint256 round) view returns (uint256)", + "function getResult(bytes32 txId, uint256 round) view returns (uint8)", + "function getRoundValidatorsPage(bytes32 txId, uint256 round, uint256 offset, uint256 pageSize) view returns (address[] validators, uint256 total)", + "function getValidatorVotes(bytes32 txId, uint256 round) view returns (uint8[] votes)", + "function getValidatorVotesHash(bytes32 txId, uint256 round) view returns (bytes32[] hashes)", + "function getValidatorResultHash(bytes32 txId, uint256 round) view returns (bytes32[] hashes)", +]); + +export const TRANSACTION_MANAGER_TRAIN_READ_ABI = parseTrainAbi([ + "function getTxExecutionResult(bytes32 txId) view returns (uint8)", + "function getNumOfInitialValidators(bytes32 txId) view returns (uint256)", +]); diff --git a/src/abi/staking.ts b/src/abi/staking.ts index 1fb069a..56bc51f 100644 --- a/src/abi/staking.ts +++ b/src/abi/staking.ts @@ -48,18 +48,9 @@ export const VALIDATOR_WALLET_ABI = [ }, ], }, - { - name: "setOperator", - type: "function", - stateMutability: "nonpayable", - inputs: [{name: "_operator", type: "address"}], - outputs: [], - }, - // Two-step operator rotation (CON-715). setOperator above is the single-call - // predecessor and is absent from newer consensus deployments, so callers pick - // whichever the deployed wallet exposes. Unlike validatorJoin, the possession - // proof here is verified by the wallet itself, so its registrar is the wallet - // address rather than the ValidatorWalletFactory. + // Two-step operator rotation (CON-715). The possession proof is verified by + // the wallet itself, so its registrar is the wallet address rather than the + // ValidatorWalletFactory. { name: "initiateOperatorTransfer", type: "function", @@ -142,6 +133,22 @@ export const VALIDATOR_WALLET_ABI = [ }, ] as const; +const STAKING_CLAIM_COMPONENTS = [ + {name: "quantity", type: "uint120"}, + {name: "offset", type: "uint120"}, + {name: "commit", type: "uint256"}, +] as const; + +const STAKING_COMMIT_COMPONENTS = [ + {name: "input", type: "uint256"}, + {name: "output", type: "uint256"}, + {name: "outstanding", type: "uint120"}, + {name: "epoch", type: "uint64"}, + {name: "linkToNextCommit", type: "uint56"}, + {name: "priced", type: "bool"}, + {name: "fragmented", type: "bool"}, +] as const; + export const STAKING_ABI = [ // Custom errors from IGenLayerStaking {name: "BurnTransferFailed", type: "error", inputs: []}, @@ -501,21 +508,18 @@ export const STAKING_ABI = [ // Functions { - name: "validatorsJoinedCount", + name: "selectableValidators", type: "function", stateMutability: "view", inputs: [], - outputs: [{name: "", type: "uint256"}], + outputs: [{name: "", type: "address[]"}], }, { - name: "getValidatorsJoined", + name: "selectableValidatorsCount", type: "function", stateMutability: "view", - inputs: [ - {name: "_start", type: "uint256"}, - {name: "_count", type: "uint256"}, - ], - outputs: [{name: "", type: "address[]"}], + inputs: [], + outputs: [{name: "", type: "uint256"}], }, { name: "activeWeights", @@ -575,20 +579,12 @@ export const STAKING_ABI = [ { name: "claim_", type: "tuple", - components: [ - {name: "quantity", type: "uint256"}, - {name: "commit", type: "uint256"}, - ], + components: STAKING_CLAIM_COMPONENTS, }, { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -605,12 +601,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -654,20 +645,12 @@ export const STAKING_ABI = [ { name: "claim_", type: "tuple", - components: [ - {name: "quantity", type: "uint256"}, - {name: "commit", type: "uint256"}, - ], + components: STAKING_CLAIM_COMPONENTS, }, { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -684,12 +667,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1204,12 +1182,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1232,12 +1205,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1415,12 +1383,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1436,12 +1399,7 @@ export const STAKING_ABI = [ { name: "commit_", type: "tuple", - components: [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "epoch", type: "uint256"}, - {name: "linkToNextCommit", type: "uint256"}, - ], + components: STAKING_COMMIT_COMPONENTS, }, ], }, @@ -1503,93 +1461,3 @@ export const SLASH_ABI = [ ], }, ] as const; - -/** - * The staking Claim/Commit views as consensus exposes them after CON-715. - * - * That change widened both structs — Claim gained `offset`, Commit gained - * `outstanding`/`priced`/`fragmented` and narrowed several members — while - * keeping the same function names and argument lists. Static tuples decode - * positionally, so reading a post-CON-715 chain with the older shape in - * STAKING_ABI silently returns the wrong words rather than failing: `commit.input` - * picks up `claim.commit`, which is why pending deposits read back as small - * indices instead of amounts. - * - * Both shapes are still in the wild, so neither can simply replace the other. - * stakingActions probes once per client and then reads with whichever matches. - * Decoding the OLD layout with this one throws (the response is too short), - * which is what makes the probe possible; the reverse direction is the silent - * one, so the current shape must always be tried first. - */ -const CURRENT_CLAIM_COMPONENTS = [ - {name: "quantity", type: "uint96"}, - {name: "offset", type: "uint96"}, - {name: "commit", type: "uint256"}, -] as const; - -const CURRENT_COMMIT_COMPONENTS = [ - {name: "input", type: "uint256"}, - {name: "output", type: "uint256"}, - {name: "outstanding", type: "uint120"}, - {name: "epoch", type: "uint64"}, - {name: "linkToNextCommit", type: "uint56"}, - {name: "priced", type: "bool"}, - {name: "fragmented", type: "bool"}, -] as const; - -export const STAKING_COMMIT_VIEWS_CURRENT_ABI = [ - { - name: "delegatorDeposit", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_delegator", type: "address"}, - {name: "_validator", type: "address"}, - {name: "_index", type: "uint256"}, - ], - outputs: [ - {name: "claim_", type: "tuple", components: CURRENT_CLAIM_COMPONENTS}, - {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, - ], - }, - { - name: "delegatorWithdrawal", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_delegator", type: "address"}, - {name: "_validator", type: "address"}, - {name: "_index", type: "uint256"}, - ], - outputs: [ - {name: "claim_", type: "tuple", components: CURRENT_CLAIM_COMPONENTS}, - {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, - ], - }, - { - name: "validatorDeposit", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_validator", type: "address"}, - {name: "_index", type: "uint256"}, - ], - outputs: [ - {name: "epoch_", type: "uint256"}, - {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, - ], - }, - { - name: "validatorWithdrawal", - type: "function", - stateMutability: "view", - inputs: [ - {name: "_validator", type: "address"}, - {name: "_index", type: "uint256"}, - ], - outputs: [ - {name: "epoch_", type: "uint256"}, - {name: "commit_", type: "tuple", components: CURRENT_COMMIT_COMPONENTS}, - ], - }, -] as const; diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 93a8f35..d1edcc8 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -26,9 +26,10 @@ import { SimulateWriteContractResult, StudioExecutionFeeReport, StudioFeeAccounting, + ConsensusRoundData, + ConsensusLastRoundData, } from "@/types"; import {fromHex, toHex, zeroAddress, encodeFunctionData, PublicClient, parseEventLogs, type Abi} from "viem"; -import {TransactionHash} from "@/types/transactions"; import {toJsonSafeDeep, b64ToArray, arrayToB64} from "@/utils/jsonifier"; import { CALL_KEY_WILDCARD, @@ -38,6 +39,7 @@ import { normalizeTransactionFees, NormalizedTransactionFees, } from "@/transactions/fees"; +import {CONSENSUS_DATA_TRAIN_ABI, ROUNDS_STORAGE_TRAIN_READ_ABI} from "@/abi/consensusTrain"; const prefixHex = (hex: string): `0x${string}` => { return (hex.startsWith("0x") ? hex : `0x${hex}`) as `0x${string}`; @@ -599,32 +601,15 @@ export const contractActions = (client: GenLayerClient, publicCli observed, }; }, - /** Calculates the minimum bond required to appeal a transaction. */ + /** Returns the full authoritative appeal charge (bond plus appeal funding). */ + getAppealCharge: async (args: {txId: `0x${string}`}): Promise => { + const context = await _readAppealContext({client, publicClient, txId: args.txId}); + return context.requiredValue; + }, + /** @deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. */ getMinAppealBond: async (args: {txId: `0x${string}`}): Promise => { - const {txId} = args; - - if (!client.chain.feeManagerContract?.address || !client.chain.roundsStorageContract?.address) { - throw new Error("Appeal bond calculation not supported on this chain (missing feeManagerContract/roundsStorageContract)"); - } - - const roundNumber = await publicClient.readContract({ - address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getRoundNumber", - args: [txId], - }) as bigint; - - const transaction = await client.getTransaction({hash: txId as TransactionHash}); - const txStatus = Number(transaction.status); - - const minBond = await publicClient.readContract({ - address: client.chain.feeManagerContract.address as `0x${string}`, - abi: client.chain.feeManagerContract.abi as Abi, - functionName: "calculateMinAppealBond", - args: [txId, roundNumber, txStatus], - }) as bigint; - - return minBond; + const context = await _readAppealContext({client, publicClient, txId: args.txId}); + return context.requiredValue; }, /** Returns the current consensus round number for a transaction. */ getRoundNumber: async (args: {txId: `0x${string}`}): Promise => { @@ -633,7 +618,7 @@ export const contractActions = (client: GenLayerClient, publicCli } return publicClient.readContract({ address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, functionName: "getRoundNumber", args: [args.txId], }) as Promise; @@ -643,11 +628,13 @@ export const contractActions = (client: GenLayerClient, publicCli if (!client.chain.roundsStorageContract?.address) { throw new Error("getRoundData not supported on this chain (missing roundsStorageContract)"); } - return publicClient.readContract({ + const snapshot = await publicClient.getBlock(); + return _readRoundDataSnapshot({ + publicClient, address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getRoundData", - args: [args.txId, args.round], + txId: args.txId, + round: args.round, + blockNumber: snapshot.number, }); }, /** Returns the current round number and its data for a transaction. */ @@ -655,23 +642,40 @@ export const contractActions = (client: GenLayerClient, publicCli if (!client.chain.roundsStorageContract?.address) { throw new Error("getLastRoundData not supported on this chain (missing roundsStorageContract)"); } - return publicClient.readContract({ - address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getLastRoundData", + const snapshot = await publicClient.getBlock(); + const address = client.chain.roundsStorageContract.address as `0x${string}`; + const round = await publicClient.readContract({ + address, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName: "getRoundNumber", args: [args.txId], + blockNumber: snapshot.number, + }) as bigint; + const roundData = await _readRoundDataSnapshot({ + publicClient, + address, + txId: args.txId, + round, + blockNumber: snapshot.number, }); + return Object.assign( + [round, roundData] as [bigint, ConsensusRoundData], + {round, roundData}, + ) as ConsensusLastRoundData; }, /** Checks if a transaction can be appealed. */ canAppeal: async (args: {txId: `0x${string}`}): Promise => { if (!client.chain.appealsContract?.address) { throw new Error("canAppeal not supported on this chain (missing appealsContract)"); } + const context = await _readLifecycleIdentity({client, publicClient, txId: args.txId}); + if (!context.decisionActive) return false; return publicClient.readContract({ address: client.chain.appealsContract.address as `0x${string}`, - abi: client.chain.appealsContract.abi as Abi, + abi: APPEALS_TRAIN_ABI, functionName: "canAppeal", - args: [args.txId], + args: [args.txId, context.decisionId], + blockNumber: context.blockNumber, }) as Promise; }, /** Returns a developer's NFT reward record, or null when no NFT is registered. */ @@ -799,10 +803,19 @@ export const contractActions = (client: GenLayerClient, publicCli value?: bigint; }) => { const {account, txId} = args; - const value = await _resolveAppealValue({client, publicClient, txId, value: args.value}); + const context = await _readAppealContext({ + client, + publicClient, + txId, + includeQuote: args.value === undefined, + }); + const value = args.value ?? context.requiredValue; const senderAccount = account || client.account; - const encodedData = _encodeSubmitAppealData({client, txId}); + const encodedData = _encodeSubmitAppealData({ + txId, + expectedDecisionId: context.decisionId, + }); // Appeals don't go through _sendTransaction because submitAppeal emits // AppealStarted/TransactionActivated events, not NewTransaction/CreatedTransaction. // The appeal operates on the same GenLayer txId, so we return it directly. @@ -850,10 +863,20 @@ export const contractActions = (client: GenLayerClient, publicCli value?: bigint; }): Promise<`0x${string}`> => { const {account, txId, distribution} = args; - const value = await _resolveAppealValue({client, publicClient, txId, value: args.value}); + const context = await _readAppealContext({ + client, + publicClient, + txId, + includeQuote: args.value === undefined, + }); + const value = args.value ?? context.requiredValue; const senderAccount = account || client.account; - const encodedData = _encodeTopUpAndSubmitAppealData({txId, distribution}); + const encodedData = _encodeTopUpAndSubmitAppealData({ + txId, + expectedDecisionId: context.decisionId, + distribution, + }); await _sendConsensusCall({ client, publicClient, @@ -870,11 +893,15 @@ export const contractActions = (client: GenLayerClient, publicCli txId: `0x${string}`; }): Promise<`0x${string}`> => { const {account, txId} = args; + const identity = await _readLifecycleIdentity({client, publicClient, txId}); + if (!identity.decisionActive) { + throw new Error(`Transaction ${txId} has no active decision to finalize`); + } const senderAccount = account || client.account; const encodedData = encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, + abi: CONSENSUS_FINALIZATION_TRAIN_ABI, functionName: "finalizeTransaction", - args: [txId], + args: [txId, identity.decisionId], }); return _sendConsensusCall({ client, @@ -884,27 +911,91 @@ export const contractActions = (client: GenLayerClient, publicCli operationName: "Finalize", }); }, - /** Batch-finalizes idle GenLayer transactions (those stuck without progressing). Returns the EVM transaction hash. */ + /** + * @deprecated The train separates attempt-bound resolution from + * decision-bound finalization. Use resolveTransactions or + * finalizeDecisions after classifying the lifecycle action. + */ finalizeIdlenessTxs: async (args: { account?: Account; txIds: readonly `0x${string}`[]; }): Promise<`0x${string}`> => { - const {account, txIds} = args; - if (txIds.length === 0) { - throw new Error("finalizeIdlenessTxs requires at least one txId."); + throw new Error( + `finalizeIdlenessTxs(${args.txIds.length} transaction(s)) is unavailable on the train: ` + + "use resolveTransactions for attempt-bound lifecycle actions or finalizeDecisions for active decisions.", + ); + }, + /** Resolves a batch of attempt-bound lifecycle actions. */ + resolveTransactions: async (args: { + account?: Account; + txIds: readonly `0x${string}`[]; + }): Promise<`0x${string}`> => { + if (args.txIds.length === 0) { + throw new Error("resolveTransactions requires at least one txId."); } - const senderAccount = account || client.account; + const snapshot = await publicClient.getBlock(); + const identities = await Promise.all(args.txIds.map(txId => + _readLifecycleIdentity({ + client, + publicClient, + txId, + blockNumber: snapshot.number, + blockTimestamp: snapshot.timestamp, + }), + )); const encodedData = encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, - functionName: "finalizeIdlenessTxs", - args: [txIds], + abi: CONSENSUS_FINALIZATION_TRAIN_ABI, + functionName: "resolveTransactions", + args: [args.txIds.map((txId, index) => ({ + txId, + expectedAttemptId: identities[index].attemptId, + }))], }); return _sendConsensusCall({ client, publicClient, encodedData, - senderAccount, - operationName: "Finalize idleness", + senderAccount: args.account || client.account, + operationName: "Resolve transactions", + }); + }, + /** Finalizes a batch of active, decision-bound transactions. */ + finalizeDecisions: async (args: { + account?: Account; + txIds: readonly `0x${string}`[]; + }): Promise<`0x${string}`> => { + if (args.txIds.length === 0) { + throw new Error("finalizeDecisions requires at least one txId."); + } + const snapshot = await publicClient.getBlock(); + const identities = await Promise.all(args.txIds.map(txId => + _readLifecycleIdentity({ + client, + publicClient, + txId, + blockNumber: snapshot.number, + blockTimestamp: snapshot.timestamp, + }), + )); + identities.forEach((identity, index) => { + if (!identity.decisionActive) { + throw new Error(`Transaction ${args.txIds[index]} has no active decision to finalize`); + } + }); + const encodedData = encodeFunctionData({ + abi: CONSENSUS_FINALIZATION_TRAIN_ABI, + functionName: "finalizeDecisions", + args: [args.txIds.map((txId, index) => ({ + txId, + expectedDecisionId: identities[index].decisionId, + }))], + }); + return _sendConsensusCall({ + client, + publicClient, + encodedData, + senderAccount: args.account || client.account, + operationName: "Finalize decisions", }); }, }; @@ -931,39 +1022,6 @@ const CREATED_TRANSACTION_EVENT_ABI = [ }, ] as const; -const ADD_TRANSACTION_ABI_V5 = [ - { - type: "function", - name: "addTransaction", - stateMutability: "nonpayable", - inputs: [ - {name: "_sender", type: "address"}, - {name: "_recipient", type: "address"}, - {name: "_numOfInitialValidators", type: "uint256"}, - {name: "_maxRotations", type: "uint256"}, - {name: "_txData", type: "bytes"}, - ], - outputs: [], - }, -] as const; - -const ADD_TRANSACTION_ABI_V6 = [ - { - type: "function", - name: "addTransaction", - stateMutability: "payable", - inputs: [ - {name: "_sender", type: "address"}, - {name: "_recipient", type: "address"}, - {name: "_numOfInitialValidators", type: "uint256"}, - {name: "_maxRotations", type: "uint256"}, - {name: "_txData", type: "bytes"}, - {name: "_validUntil", type: "uint256"}, - ], - outputs: [], - }, -] as const; - const FEES_DISTRIBUTION_COMPONENTS = [ {name: "leaderTimeunitsAllocation", type: "uint256"}, {name: "validatorTimeunitsAllocation", type: "uint256"}, @@ -1029,12 +1087,80 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ stateMutability: "payable", inputs: [ {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, ], outputs: [], }, ] as const; +const CONSENSUS_APPEAL_TRAIN_ABI = [ + { + type: "function", + name: "submitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, +] as const; + +const CONSENSUS_FINALIZATION_TRAIN_ABI = [ + { + type: "function", + name: "finalizeTransaction", + stateMutability: "nonpayable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, + { + type: "function", + name: "resolveTransactions", + stateMutability: "nonpayable", + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedAttemptId", type: "bytes32"}, + ], + }], + outputs: [], + }, + { + type: "function", + name: "finalizeDecisions", + stateMutability: "nonpayable", + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedDecisionId", type: "uint256"}, + ], + }], + outputs: [], + }, +] as const; + +const APPEALS_TRAIN_ABI = [ + { + type: "function", + name: "canAppeal", + stateMutability: "view", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [{name: "", type: "bool"}], + }, +] as const; + const FEE_MANAGER_CALCULATE_ROUND_FEES_ABI = [ { type: "function", @@ -1077,34 +1203,6 @@ const FEE_MANAGER_CALCULATE_ROUND_FEES_ABI = [ }, ] as const; -type AddTransactionAbiVersion = "fees" | "v6" | "v5"; - -const getAddTransactionAbiVersion = (abi: readonly unknown[] | undefined): AddTransactionAbiVersion => { - if (!abi || !Array.isArray(abi)) { - return "v5"; - } - - const addTransactionFunction = abi.find(item => { - if (!item || typeof item !== "object") { - return false; - } - - const candidate = item as {type?: string; name?: string}; - return candidate.type === "function" && candidate.name === "addTransaction"; - }) as {inputs?: readonly {type?: string; components?: readonly unknown[]}[]} | undefined; - - const inputs = addTransactionFunction?.inputs; - if (!Array.isArray(inputs)) { - return "v5"; - } - - if (inputs.length === 1 && inputs[0]?.type === "tuple") { - return "fees"; - } - - return inputs.length >= 6 ? "v6" : "v5"; -}; - type EncodedTransactionVariant = { encodedData: `0x${string}`; value: bigint; @@ -1860,129 +1958,235 @@ const _encodeAddTransactionData = ({ const txValidUntil = toUInt(validUntil, "validUntil", getDefaultValidUntil()); const feeValue = transactionFees.feeValue ?? 0n; - const addTransactionArgs: [ - Address, - `0x${string}`, - number, - number, - `0x${string}`, - ] = [ - validatedSenderAccount.address, - txRecipient, - client.chain.defaultNumberOfInitialValidators, - consensusMaxRotations, + const params = { + sender: validatedSenderAccount.address, + recipient: txRecipient, + numOfInitialValidators: BigInt(client.chain.defaultNumberOfInitialValidators), + maxRotations: BigInt(consensusMaxRotations), + validUntil: txValidUntil, + saltNonce: 0n, + userValue, + feesDistribution: transactionFees.distribution, txCalldata, - ]; - - const buildVariant = (abiVersion: AddTransactionAbiVersion): EncodedTransactionVariant => { - if (abiVersion === "fees") { - const params = { - sender: validatedSenderAccount.address, - recipient: txRecipient, - numOfInitialValidators: BigInt(client.chain.defaultNumberOfInitialValidators), - maxRotations: BigInt(consensusMaxRotations), - validUntil: txValidUntil, - saltNonce: 0n, - userValue, - feesDistribution: transactionFees.distribution, - txCalldata, - messageAllocations: transactionFees.messageAllocations, - }; + messageAllocations: transactionFees.messageAllocations, + }; - return { - encodedData: encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_WITH_FEES as any, - functionName: "addTransaction", - args: [params], - }), - value: userValue + feeValue, - }; - } + return [{ + encodedData: encodeFunctionData({ + abi: ADD_TRANSACTION_ABI_WITH_FEES as any, + functionName: "addTransaction", + args: [params], + }), + value: userValue + feeValue, + }]; +}; - if (abiVersion === "v6") { - return { - encodedData: encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V6 as any, - functionName: "addTransaction", - args: [...addTransactionArgs, txValidUntil], - }), - value: userValue, - }; - } +const _encodeSubmitAppealData = ({ + txId, + expectedDecisionId, +}: { + txId: `0x${string}`; + expectedDecisionId: bigint; +}): `0x${string}` => { + return encodeFunctionData({ + abi: CONSENSUS_APPEAL_TRAIN_ABI, + functionName: "submitAppeal", + args: [txId, expectedDecisionId], + }); +}; - return { - encodedData: encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V5 as any, - functionName: "addTransaction", - args: addTransactionArgs, - }), - value: userValue, - }; - }; +const ROUND_PAGE_SIZE = 64n; + +const _unpackRoundValidatorPage = ( + value: any, +): {validators: readonly Address[]; total: bigint} => ({ + validators: value.validators ?? value[0], + total: BigInt(value.total ?? value[1]), +}); - if (transactionFees.requiresFeeAwareTransaction) { - return [buildVariant("fees")]; +/** Rebuilds the legacy RoundData shape without the aggregate four-array getter. */ +const _readRoundDataSnapshot = async ({ + publicClient, + address, + txId, + round, + blockNumber, +}: { + publicClient: PublicClient; + address: Address; + txId: `0x${string}`; + round: bigint; + blockNumber: bigint; +}): Promise => { + const read = (functionName: string, args: readonly unknown[]) => publicClient.readContract({ + address, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName, + args, + blockNumber, + } as any) as Promise; + + const [ + leaderIndex, + votesCommitted, + votesRevealed, + appealBond, + rotationsLeft, + result, + validatorVotes, + validatorVotesHash, + validatorResultHash, + firstPageRaw, + ] = await Promise.all([ + read("getLeaderIndex", [txId, round]), + read("getVotesCommitted", [txId, round]), + read("getVotesRevealed", [txId, round]), + read("getAppealBond", [txId, round]), + read("getRotationsLeft", [txId, round]), + read("getResult", [txId, round]), + read("getValidatorVotes", [txId, round]), + read("getValidatorVotesHash", [txId, round]), + read("getValidatorResultHash", [txId, round]), + read("getRoundValidatorsPage", [txId, round, 0n, ROUND_PAGE_SIZE]), + ]); + + const firstPage = _unpackRoundValidatorPage(firstPageRaw); + const offsets: bigint[] = []; + for (let offset = ROUND_PAGE_SIZE; offset < firstPage.total; offset += ROUND_PAGE_SIZE) { + offsets.push(offset); + } + const remainingPages = await Promise.all( + offsets.map(offset => read("getRoundValidatorsPage", [txId, round, offset, ROUND_PAGE_SIZE])), + ); + const pages = [firstPage, ...remainingPages.map(_unpackRoundValidatorPage)]; + if (pages.some(page => page.total !== firstPage.total)) { + throw new Error("Round validator page total changed within a fixed block snapshot"); + } + const roundValidators = pages.flatMap(page => [...page.validators]); + const expected = Number(firstPage.total); + if (roundValidators.length !== expected) { + throw new Error(`Incomplete round validator pages: expected ${expected}, received ${roundValidators.length}`); + } + for (const [name, values] of [ + ["validator votes", validatorVotes], + ["validator vote hashes", validatorVotesHash], + ["validator result hashes", validatorResultHash], + ] as const) { + if (values.length !== expected) { + throw new Error(`Incomplete ${name}: expected ${expected}, received ${values.length}`); + } } - const detectedVersion = getAddTransactionAbiVersion(client.chain.consensusMainContract?.abi); - const orderByDetectedVersion: Record = { - fees: ["fees", "v6", "v5"], - v6: ["v6", "v5", "fees"], - v5: ["v5", "v6", "fees"], + return { + round, + leaderIndex, + votesCommitted, + votesRevealed, + appealBond, + rotationsLeft, + result: Number(result), + roundValidators, + validatorVotes: [...validatorVotes].map(Number), + validatorVotesHash: [...validatorVotesHash], + validatorResultHash: [...validatorResultHash], }; +}; - return orderByDetectedVersion[detectedVersion].map(buildVariant); +type LifecycleIdentity = { + blockNumber: bigint; + blockTimestamp: bigint; + resolutionAction: number; + attemptId: `0x${string}`; + decisionActive: boolean; + decisionId: bigint; }; -const _encodeSubmitAppealData = ({ +const _readLifecycleIdentity = async ({ client, + publicClient, txId, + blockNumber, + blockTimestamp, }: { client: GenLayerClient; + publicClient: PublicClient; txId: `0x${string}`; -}): `0x${string}` => { - return encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, - functionName: "submitAppeal", - args: [txId], - }); + blockNumber?: bigint; + blockTimestamp?: bigint; +}): Promise => { + const consensusDataAddress = client.chain.consensusDataContract?.address as Address | undefined; + if (!consensusDataAddress || consensusDataAddress === zeroAddress) { + throw new Error("ConsensusData contract is not configured for this chain"); + } + + let snapshotNumber = blockNumber; + let snapshotTimestamp = blockTimestamp; + if (snapshotNumber === undefined || snapshotTimestamp === undefined) { + const snapshot = await publicClient.getBlock(); + snapshotNumber = snapshot.number; + snapshotTimestamp = snapshot.timestamp; + } + + const lifecycle = await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + args: [txId, snapshotTimestamp], + blockNumber: snapshotNumber, + }) as any; + const resolution = lifecycle.resolution ?? lifecycle[1]; + const latestDecision = lifecycle.latestDecision ?? lifecycle[2]; + const decisionActive = Boolean(lifecycle.decisionActive ?? lifecycle[3]); + + return { + blockNumber: snapshotNumber, + blockTimestamp: snapshotTimestamp, + resolutionAction: Number(resolution.action ?? resolution[3]), + attemptId: (resolution.attemptId ?? resolution[15]) as `0x${string}`, + decisionActive, + decisionId: decisionActive + ? BigInt(latestDecision.decisionId ?? latestDecision[1]) + : 0n, + }; }; -const _resolveAppealValue = async ({ +const _readAppealContext = async ({ client, publicClient, txId, - value, + includeQuote = true, }: { client: GenLayerClient; publicClient: PublicClient; txId: `0x${string}`; - value?: bigint; -}): Promise => { - if (value !== undefined) { - return value; + includeQuote?: boolean; +}): Promise => { + const identity = await _readLifecycleIdentity({client, publicClient, txId}); + if (!identity.decisionActive) { + throw new Error(`Transaction ${txId} has no active decision to appeal`); } - if (!client.chain.feeManagerContract?.address || !client.chain.roundsStorageContract?.address) { - return 0n; + if (!includeQuote) { + return {...identity, requiredValue: 0n}; } - const roundNumber = await publicClient.readContract({ - address: client.chain.roundsStorageContract.address as `0x${string}`, - abi: client.chain.roundsStorageContract.abi as Abi, - functionName: "getRoundNumber", + const consensusDataAddress = client.chain.consensusDataContract!.address as Address; + const quote = await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "estimateLatestAppealCharge", args: [txId], - }) as bigint; - - const transaction = await client.getTransaction({hash: txId as TransactionHash}); - const txStatus = Number(transaction.status); - - return publicClient.readContract({ - address: client.chain.feeManagerContract.address as `0x${string}`, - abi: client.chain.feeManagerContract.abi as Abi, - functionName: "calculateMinAppealBond", - args: [txId, roundNumber, txStatus], - }) as Promise; + blockNumber: identity.blockNumber, + }) as any; + const quoteDecisionId = BigInt(quote.decisionId ?? quote[0]); + if (quoteDecisionId !== identity.decisionId) { + throw new Error( + `Appeal decision changed while reading ${txId}: expected ${identity.decisionId}, received ${quoteDecisionId}`, + ); + } + const bond = BigInt(quote.bond ?? quote[1]); + const funding = BigInt(quote.funding ?? quote[2]); + return {...identity, requiredValue: bond + funding}; }; const _encodeTopUpFeesData = ({ @@ -2001,15 +2205,17 @@ const _encodeTopUpFeesData = ({ const _encodeTopUpAndSubmitAppealData = ({ txId, + expectedDecisionId, distribution, }: { txId: `0x${string}`; + expectedDecisionId: bigint; distribution: FeesDistributionInput; }): `0x${string}` => { return encodeFunctionData({ abi: CONSENSUS_FEE_MANAGEMENT_ABI, functionName: "topUpAndSubmitAppeal", - args: [txId, createFeesDistribution(distribution)], + args: [txId, expectedDecisionId, createFeesDistribution(distribution)], }); }; @@ -2186,46 +2392,6 @@ const _sendConsensusCall = async ({ return evmHash; }; -const isAddTransactionAbiMismatchError = (error: unknown): boolean => { - const seen = new WeakSet(); - const serializedError = - typeof error === "object" && error !== null - ? JSON.stringify(error, (_key, value) => { - if (typeof value === "bigint") { - return value.toString(); - } - - if (typeof value === "object" && value !== null) { - if (seen.has(value as object)) { - return "[Circular]"; - } - seen.add(value as object); - } - - return value; - }) - : ""; - const errorObject = error as {shortMessage?: string; details?: string; message?: string}; - const errorMessage = [ - errorObject?.shortMessage, - errorObject?.details, - errorObject?.message, - serializedError, - String(error ?? ""), - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - - return ( - errorMessage.includes("invalid pointer in tuple") || - errorMessage.includes("invalid pointer") || - errorMessage.includes("could not decode") || - errorMessage.includes("invalid arrayify value") || - errorMessage.includes("types/value length mismatch") - ); -}; - /** * Extracts the GenLayer txId from receipt logs by checking for either * NewTransaction (immediately activated) or CreatedTransaction (queued) events. @@ -2454,15 +2620,8 @@ const _sendTransaction = async ({ return externalTxId; }; - for (let i = 0; i < transactionVariants.length; i++) { - try { - return await sendWithEncodedData(transactionVariants[i]); - } catch (error) { - if (i === transactionVariants.length - 1 || !isAddTransactionAbiMismatchError(error)) { - throw error; - } - } + if (transactionVariants.length !== 1) { + throw new Error(`Train transaction encoding expected one variant, received ${transactionVariants.length}`); } - - throw new Error("Unable to send transaction."); + return sendWithEncodedData(transactionVariants[0]); }; diff --git a/src/staking/actions.ts b/src/staking/actions.ts index 9126462..0047f32 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,6 +1,6 @@ import {getContract, decodeEventLog, PublicClient, Client, Transport, Chain, Account, Address as ViemAddress, GetContractReturnType, toHex, encodeFunctionData, BaseError, ContractFunctionRevertedError, decodeErrorResult, RawContractError, zeroAddress} from "viem"; import {GenLayerClient, GenLayerChain, Address} from "@/types"; -import {STAKING_ABI, VALIDATOR_WALLET_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "@/abi/staking"; +import {STAKING_ABI, VALIDATOR_WALLET_ABI} from "@/abi/staking"; import {ADDRESS_MANAGER_ABI, CONSENSUS_ADDRESS_MANAGER_ABI} from "@/abi/vesting"; import {parseStakingAmount, formatStakingAmount} from "./utils"; @@ -288,58 +288,6 @@ export const stakingActions = ( }; }; - /** - * Which Claim/Commit layout the deployed staking contract uses. - * - * CON-715 widened both structs without renaming anything, and static tuples - * decode positionally, so the wrong shape does not fail — it silently returns - * neighbouring words (commit.input picks up claim.commit, i.e. an index where - * an amount belongs). Both shapes are deployed in the wild, so the layout is - * resolved from the chain rather than assumed, then cached for the client: - * getStakeInfo loops over every pending entry and must not re-probe each time. - * - * The probe only works in one direction. Reading the OLD layout with the - * CURRENT shape throws, because the response is shorter than the decoder - * expects; reading the CURRENT layout with the OLD shape succeeds and lies. - * So the current shape is always attempted first, and a decode failure — not - * a success — is what identifies a legacy chain. - */ - let commitLayout: "current" | "legacy" | null = null; - - const readCommitView = async ( - functionName: "delegatorDeposit" | "delegatorWithdrawal" | "validatorDeposit" | "validatorWithdrawal", - args: readonly unknown[], - ): Promise => { - const read = (layout: "current" | "legacy") => - publicClient.readContract({ - address: getStakingAddress(), - abi: (layout === "current" ? STAKING_COMMIT_VIEWS_CURRENT_ABI : STAKING_ABI) as any, - functionName, - args: args as any, - }); - - if (commitLayout) { - return read(commitLayout); - } - - try { - const result = await read("current"); - commitLayout = "current"; - return result; - } catch (currentError) { - // Could be a legacy layout, or a genuine failure (bad index, RPC error). - // Only a successful legacy decode distinguishes them; otherwise surface - // the original error, which describes the current-shape attempt. - try { - const result = await read("legacy"); - commitLayout = "legacy"; - return result; - } catch { - throw currentError; - } - } - }; - /** * Rotation is verified by the wallet, not the factory, so the registrar is the * wallet's own address. The owner is read from the wallet rather than assumed @@ -472,21 +420,12 @@ export const stakingActions = ( return executeWrite({to: getStakingAddress(), data}); }, - /** - * Sets the operator address for a validator wallet in one call. - * - * Removed from consensus by CON-715 in favour of the two-step rotation - * below; against a deployment that dropped it this reverts with no reason, - * because the selector simply does not exist. Prefer - * initiateOperatorTransfer + completeOperatorTransfer. - */ + /** @deprecated Use initiateOperatorTransfer followed by completeOperatorTransfer. */ setOperator: async (options: SetOperatorOptions): Promise => { - const data = encodeFunctionData({ - abi: VALIDATOR_WALLET_ABI, - functionName: "setOperator", - args: [options.operator as ViemAddress], - }); - return executeWrite({to: options.validator as ViemAddress, data}); + throw new Error( + `setOperator cannot rotate ${options.validator} to ${options.operator} on the train: ` + + "create an operator possession proof, then call initiateOperatorTransfer and completeOperatorTransfer.", + ); }, getOperatorTransferContext, @@ -624,7 +563,7 @@ export const stakingActions = ( return executeWrite({to: getStakingAddress(), data}); }, - /** Checks if an address is an active validator. */ + /** Checks whether an address is a registered/joined validator wallet. */ isValidator: async (address: Address): Promise => { const contract = getReadOnlyStakingContract(); return contract.read.isValidator([address as ViemAddress]) as Promise; @@ -647,13 +586,14 @@ export const stakingActions = ( }); // Fetch all data in parallel - const [view, owner, operator, identityRaw, currentEpoch, validatorMinStake] = await Promise.all([ + const [view, owner, operator, identityRaw, currentEpoch, validatorMinStake, banned] = await Promise.all([ contract.read.validatorView([validator as ViemAddress]) as Promise, walletContract.read.owner() as Promise
, walletContract.read.operator() as Promise
, walletContract.read.getIdentity().catch(() => null) as Promise, contract.read.epoch() as Promise, contract.read.validatorMinStake() as Promise, + contract.read.isValidatorBanned([validator as ViemAddress]) as Promise, ]); // Parse identity if available @@ -680,10 +620,7 @@ export const stakingActions = ( const pendingDeposits: PendingDeposit[] = []; for (let i = 0n; i < depositLen; i++) { - const [epoch, commit] = (await readCommitView("validatorDeposit", [validator as ViemAddress, i])) as [ - bigint, - {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, - ]; + const [epoch, commit] = await contract.read.validatorDeposit([validator as ViemAddress, i]); pendingDeposits.push({ epoch, stake: formatStakingAmount(commit.input), @@ -697,10 +634,7 @@ export const stakingActions = ( const pendingWithdrawals: PendingWithdrawal[] = []; for (let i = 0n; i < withdrawalLen; i++) { - const [epoch, commit] = (await readCommitView("validatorWithdrawal", [validator as ViemAddress, i])) as [ - bigint, - {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, - ]; + const [epoch, commit] = await contract.read.validatorWithdrawal([validator as ViemAddress, i]); pendingWithdrawals.push({ epoch, shares: commit.input, @@ -725,8 +659,8 @@ export const stakingActions = ( vWithdrawalRaw: view.vWithdrawal, ePrimed: view.ePrimed, live: view.live, - banned: view.eBanned > 0n, - bannedEpoch: view.eBanned > 0n ? view.eBanned : undefined, + banned, + bannedEpoch: banned ? view.eBanned : undefined, needsPriming, currentEpoch, validatorMinStake: formatStakingAmount(validatorMinStake), @@ -773,14 +707,11 @@ export const stakingActions = ( const pendingDeposits: PendingDeposit[] = []; for (let i = 0n; i < depositLen; i++) { - const [claim, commit] = (await readCommitView("delegatorDeposit", [ + const [claim, commit] = await contract.read.delegatorDeposit([ delegator as ViemAddress, validator as ViemAddress, i, - ])) as [ - {quantity: bigint; commit: bigint}, - {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, - ]; + ]); pendingDeposits.push({ epoch: commit.epoch, stake: formatStakingAmount(commit.input), @@ -797,14 +728,11 @@ export const stakingActions = ( const pendingWithdrawals: PendingWithdrawal[] = []; for (let i = 0n; i < withdrawalLen; i++) { - const [claim, commit] = (await readCommitView("delegatorWithdrawal", [ + const [claim, commit] = await contract.read.delegatorWithdrawal([ delegator as ViemAddress, validator as ViemAddress, i, - ])) as [ - {quantity: bigint; commit: bigint}, - {input: bigint; output: bigint; epoch: bigint; linkToNextCommit: bigint}, - ]; + ]); pendingWithdrawals.push({ epoch: commit.epoch, shares: claim.quantity, @@ -841,7 +769,7 @@ export const stakingActions = ( ] = await Promise.all([ contract.read.epoch() as Promise, contract.read.finalized() as Promise, - contract.read.validatorsJoinedCount() as Promise, + contract.read.selectableValidatorsCount() as Promise, contract.read.epochMinDuration() as Promise, contract.read.epochZeroMinDuration() as Promise, contract.read.epochOdd() as Promise, @@ -926,15 +854,21 @@ export const stakingActions = ( }; }, - /** Returns addresses of all currently active validators. */ + /** Returns validators currently eligible for consensus duties. */ getActiveValidators: async (): Promise => { const contract = getReadOnlyStakingContract(); + return contract.read.selectableValidators() as Promise; + }, - // Read a page at a time. Committee capacity is 1,543, and an address[] - // that long overruns the return-size limit, so the joined registry is - // only reachable in slices. The count is read first so a registry 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 it settled. + /** Returns the count of validators currently eligible for consensus duties. */ + getActiveValidatorsCount: async (): Promise => { + const contract = getReadOnlyStakingContract(); + return contract.read.selectableValidatorsCount() as Promise; + }, + + /** Returns every validator identity in the append-only joined registry. */ + getJoinedValidators: async (): Promise => { + const contract = getReadOnlyStakingContract(); const total = (await contract.read.validatorsJoinedCount()) as bigint; const validators: Address[] = []; @@ -947,8 +881,8 @@ export const stakingActions = ( return validators.filter(v => v !== "0x0000000000000000000000000000000000000000"); }, - /** Returns the count of active validators. */ - getActiveValidatorsCount: async (): Promise => { + /** Returns the size of the append-only joined validator registry. */ + getJoinedValidatorsCount: async (): Promise => { const contract = getReadOnlyStakingContract(); return contract.read.validatorsJoinedCount() as Promise; }, diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 81c33cb..83d40ed 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -11,12 +11,51 @@ import { isDecidedState, DebugTraceResult, TransactionReceiptWaitUntil, + transactionResolutionActionNumberToName, } from "../types/transactions"; import {transactionsConfig} from "../config/transactions"; import {sleep} from "../utils/async"; import {GenLayerChain} from "@/types"; -import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes} from "viem"; +import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes, zeroAddress} from "viem"; import {decodeLocalnetTransaction, decodeTransaction, simplifyTransactionReceipt} from "./decoders"; +import { + ADDRESS_MANAGER_TRAIN_ABI, + CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + CONSENSUS_DATA_TRAIN_ABI, + ROUNDS_STORAGE_TRAIN_READ_ABI, + TRANSACTION_MANAGER_TRAIN_READ_ABI, +} from "../abi/consensusTrain"; + +const TRANSACTION_PAGE_SIZE = 64n; + +const resolvedAddress = (name: string, address: Address): Address => { + if (address === zeroAddress) { + throw new Error(`${name} is not registered in AddressManager`); + } + return address; +}; + +type AddressPage = readonly [readonly Address[], bigint] | {page: readonly Address[]; total: bigint}; + +const unpackAddressPage = (result: AddressPage): {page: readonly Address[]; total: bigint} => + "page" in result ? result : {page: result[0], total: result[1]}; + +const readAddressPages = async ( + total: bigint, + readPage: (offset: bigint) => Promise, +): Promise => { + const offsets: bigint[] = []; + for (let offset = 0n; offset < total; offset += TRANSACTION_PAGE_SIZE) offsets.push(offset); + const pages = (await Promise.all(offsets.map(readPage))).map(unpackAddressPage); + if (pages.some(page => page.total !== total)) { + throw new Error("Address page total changed within a fixed block snapshot"); + } + const items = pages.flatMap(({page}) => [...page]); + if (BigInt(items.length) !== total) { + throw new Error(`Incomplete address pages: expected ${total}, received ${items.length}`); + } + return items; +}; let didWarnWaitForTransactionReceiptStatus = false; @@ -140,7 +179,10 @@ export const receiptActions = (client: GenLayerClient, publicClie }); export const transactionActions = (client: GenLayerClient, publicClient: PublicClient) => ({ - /** Fetches transaction data including status, execution result, and consensus details. */ + /** + * Fetches the train transaction snapshot, including projected/stored status, + * resolution action, authoritative finalization readiness, and split round data. + */ getTransaction: async ({hash}: {hash: TransactionHash}): Promise => { if (client.chain.isStudio) { const transaction = await client.getTransaction({hash}); @@ -151,44 +193,168 @@ export const transactionActions = (client: GenLayerClient, public transaction.statusName = localnetStatus as TransactionStatus; return decodeLocalnetTransaction(transaction as unknown as GenLayerTransaction); } - const contractAddress = client.chain.consensusDataContract?.address as Address; - const contractAbi = client.chain.consensusDataContract?.abi as Abi; - - // getTransactionData(txId, timestamp) answered with a projection evaluated at - // a caller-supplied clock. The resolution-kernel train splits that apart: the - // stored record is getStoredTransactionData(txId), and the projection lives - // behind getTransactionLifecycle. Chains are upgraded independently, so pick - // whichever read the chain's own ABI actually offers rather than assuming. - const hasStoredRead = (contractAbi as readonly {name?: string}[]).some( - entry => entry?.name === "getStoredTransactionData", + const consensusDataAddress = client.chain.consensusDataContract?.address as Address; + if (!consensusDataAddress || consensusDataAddress === zeroAddress) { + throw new Error("ConsensusData contract is not configured for this chain"); + } + + // Freeze every lifecycle and round read to one chain snapshot. A local + // wall-clock projection mixed with moving latest-state reads can report a + // status and finalization action that never coexisted. + const snapshot = await publicClient.getBlock(); + const blockNumber = snapshot.number; + const observedAt = snapshot.timestamp; + const addressManagerAddress = resolvedAddress( + "AddressManager", + await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "addressManager", + blockNumber, + }) as Address, ); - const dataRead = hasStoredRead - ? {functionName: "getStoredTransactionData", args: [hash] as const} - : {functionName: "getTransactionData", args: [hash, Math.round(new Date().getTime() / 1000)] as const}; - const [txDataRaw, allDataRaw] = await Promise.all([ + const [bigRoundsAddressRaw, roundsStorageAddressRaw, transactionManagerAddressRaw] = await Promise.all([ + publicClient.readContract({ + address: addressManagerAddress, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["ConsensusDataBigRounds"], + blockNumber, + }), + publicClient.readContract({ + address: addressManagerAddress, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["RoundsStorage"], + blockNumber, + }), publicClient.readContract({ - address: contractAddress, - abi: contractAbi, - functionName: dataRead.functionName, - args: dataRead.args as unknown as readonly unknown[], - }) as Promise, + address: addressManagerAddress, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["TransactionManager"], + blockNumber, + }), + ]) as [Address, Address, Address]; + const bigRoundsAddress = resolvedAddress("ConsensusDataBigRounds", bigRoundsAddressRaw); + const roundsStorageAddress = resolvedAddress("RoundsStorage", roundsStorageAddressRaw); + const transactionManagerAddress = resolvedAddress("TransactionManager", transactionManagerAddressRaw); + + const [txData, lifecycle] = await Promise.all([ publicClient.readContract({ - address: contractAddress, - abi: contractAbi, - functionName: "getTransactionAllData", + address: bigRoundsAddress, + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getStoredTransactionDataLight", args: [hash], - }) as Promise<[any, any[]]>, - ]); - - const txData = txDataRaw as unknown as GenLayerRawTransaction; - const [txAllData, _roundsData] = allDataRaw as unknown as [any, any[]]; + blockNumber, + }), + publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + args: [hash, observedAt], + blockNumber, + }), + ]) as [any, any]; + + const round = txData.lastRound.round; + const [roundValidators, consumedValidators, validatorVotes, validatorVotesHash, validatorResultHash, txExecutionResult, numOfInitialValidators] = + await Promise.all([ + readAddressPages(txData.lastRound.validatorsCount, offset => publicClient.readContract({ + address: bigRoundsAddress, + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getRoundValidatorsPaged", + args: [hash, round, offset, TRANSACTION_PAGE_SIZE], + blockNumber, + }) as Promise), + readAddressPages(txData.consumedValidatorsCount, offset => publicClient.readContract({ + address: bigRoundsAddress, + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getConsumedValidatorsPaged", + args: [hash, offset, TRANSACTION_PAGE_SIZE], + blockNumber, + }) as Promise), + publicClient.readContract({ + address: roundsStorageAddress, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName: "getValidatorVotes", + args: [hash, round], + blockNumber, + }), + publicClient.readContract({ + address: roundsStorageAddress, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName: "getValidatorVotesHash", + args: [hash, round], + blockNumber, + }), + publicClient.readContract({ + address: roundsStorageAddress, + abi: ROUNDS_STORAGE_TRAIN_READ_ABI, + functionName: "getValidatorResultHash", + args: [hash, round], + blockNumber, + }), + publicClient.readContract({ + address: transactionManagerAddress, + abi: TRANSACTION_MANAGER_TRAIN_READ_ABI, + functionName: "getTxExecutionResult", + args: [hash], + blockNumber, + }), + publicClient.readContract({ + address: transactionManagerAddress, + abi: TRANSACTION_MANAGER_TRAIN_READ_ABI, + functionName: "getNumOfInitialValidators", + args: [hash], + blockNumber, + }), + ]) as [Address[], Address[], readonly number[], readonly `0x${string}`[], readonly `0x${string}`[], number, bigint]; + + const projectedStatus = Number(lifecycle.resolution.projectedStatus); + const storedStatus = Number(lifecycle.storedStatus); + const resolutionAction = Number(lifecycle.resolution.action); + const decisionId = lifecycle.decisionActive + ? BigInt(lifecycle.latestDecision.decisionId) + : 0n; + const finalizeRead = await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "canFinalize", + args: [hash, observedAt, decisionId], + blockNumber, + }) as any; + const canFinalize = Boolean(finalizeRead.ready ?? finalizeRead[0]); const transaction = { ...txData, - txExecutionResult: Number(txAllData.txExecutionResult), - } as GenLayerRawTransaction; - return decodeTransaction(transaction); + numOfInitialValidators, + status: projectedStatus, + txExecutionResult: Number(txExecutionResult), + consumedValidators, + lastRound: { + ...txData.lastRound, + roundValidators, + validatorVotes: [...validatorVotes].map(Number), + validatorVotesHash: [...validatorVotesHash], + validatorResultHash: [...validatorResultHash], + }, + } as unknown as GenLayerRawTransaction; + const decoded = decodeTransaction(transaction); + const resolutionActionName = + transactionResolutionActionNumberToName[ + String(resolutionAction) as keyof typeof transactionResolutionActionNumberToName + ]; + return { + ...decoded, + storedStatus, + storedStatusName: + transactionsStatusNumberToName[String(storedStatus) as keyof typeof transactionsStatusNumberToName], + resolutionAction, + resolutionActionName, + canFinalize, + }; }, /** Returns transaction IDs of child transactions created from emitted messages. */ getTriggeredTransactionIds: async ({hash}: {hash: TransactionHash}): Promise => { diff --git a/src/transactions/decoders.ts b/src/transactions/decoders.ts index 34176a6..9fb6350 100644 --- a/src/transactions/decoders.ts +++ b/src/transactions/decoders.ts @@ -74,23 +74,17 @@ export const decodeInputData = ( }; export const decodeTransaction = (tx: GenLayerRawTransaction): GenLayerTransaction => { - // Normalize field names across chain ABIs (Bradbury uses different names) - const txData = tx.txData ?? (tx as any).txCalldata; - const numOfInitialValidators = tx.numOfInitialValidators ?? (tx as any).initialRotations; - // The train's stored record calls this observedAt; older chains say - // currentTimestamp. Same value, and it is read unconditionally below, so an - // unrecognised name is a crash rather than a missing field. - const currentTimestamp = tx.currentTimestamp ?? (tx as any).observedAt; - - const txDataDecoded = decodeInputData(txData, tx.recipient); + const txDataDecoded = decodeInputData(tx.txCalldata, tx.recipient); const decodedTx = { ...tx, - txData: txData, + // Preserve the public SDK field names while decoding one canonical train + // wire layout. Deployments with the old tuple must use the old SDK. + txData: tx.txCalldata, txDataDecoded: txDataDecoded, - currentTimestamp: currentTimestamp?.toString() ?? "0", - numOfInitialValidators: numOfInitialValidators?.toString() ?? "0", + currentTimestamp: tx.observedAt.toString(), + numOfInitialValidators: tx.numOfInitialValidators.toString(), txSlot: tx.txSlot.toString(), createdTimestamp: tx.createdTimestamp.toString(), lastVoteTimestamp: tx.lastVoteTimestamp.toString(), @@ -288,4 +282,4 @@ export const decodeLocalnetTransaction = (tx: GenLayerTransaction): GenLayerTran console.error("Error in decodeLocalnetTransaction:", e); } return tx; -}; \ No newline at end of file +}; diff --git a/src/types/clients.ts b/src/types/clients.ts index 449975c..56211e6 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -15,6 +15,8 @@ import { BigNumberish, FeesDistributionInput, SimulateWriteContractResult, + ConsensusRoundData, + ConsensusLastRoundData, } from "./transactions"; import {GenLayerChain} from "./chains"; import {Address, Account} from "./accounts"; @@ -142,8 +144,8 @@ export type GenLayerClient = Omit< getTransactionQueuePosition: (args: {hash: TransactionHash}) => Promise; cancelTransaction: (args: {hash: TransactionHash}) => Promise<{transaction_hash: string; status: string}>; getRoundNumber: (args: {txId: `0x${string}`}) => Promise; - getRoundData: (args: {txId: `0x${string}`; round: bigint}) => Promise; - getLastRoundData: (args: {txId: `0x${string}`}) => Promise; + getRoundData: (args: {txId: `0x${string}`; round: bigint}) => Promise; + getLastRoundData: (args: {txId: `0x${string}`}) => Promise; canAppeal: (args: {txId: `0x${string}`}) => Promise; getDeveloperNft: (args: {developer: Address}) => Promise; getClaimableRewardsFromFees: (args: {nftId: BigNumberish}) => Promise; @@ -185,6 +187,16 @@ export type GenLayerClient = Omit< account?: Account; txIds: readonly `0x${string}`[]; }) => Promise<`0x${string}`>; + resolveTransactions: (args: { + account?: Account; + txIds: readonly `0x${string}`[]; + }) => Promise<`0x${string}`>; + finalizeDecisions: (args: { + account?: Account; + txIds: readonly `0x${string}`[]; + }) => Promise<`0x${string}`>; + getAppealCharge: (args: {txId: `0x${string}`}) => Promise; + /** @deprecated Use getAppealCharge. This alias returns bond plus appeal funding. */ getMinAppealBond: (args: {txId: `0x${string}`}) => Promise; getCurrentFeePolicy: () => Promise; estimateFeesDistribution: (args?: FeeEstimateOptions) => Promise; diff --git a/src/types/staking.ts b/src/types/staking.ts index 0af016b..de53c51 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -17,9 +17,6 @@ export type StakingContract = GetContractReturnType< >; export interface ValidatorView { - left: Address; - right: Address; - parent: Address; eBanned: bigint; ePrimed: bigint; vStake: bigint; @@ -74,8 +71,11 @@ export interface ValidatorInfo { export interface WithdrawalCommit { input: bigint; output: bigint; + outstanding: bigint; epoch: bigint; linkToNextCommit: bigint; + priced: boolean; + fragmented: boolean; } export interface PendingDeposit { @@ -237,6 +237,8 @@ export interface DelegatorClaimOptions { export interface StakingActions { validatorJoin: (options: ValidatorJoinOptions) => Promise; getValidatorRegistrationContext: () => Promise; + /** @deprecated Use initiateOperatorTransfer followed by completeOperatorTransfer. */ + setOperator: (options: SetOperatorOptions) => Promise; getOperatorTransferContext: (validator: Address) => Promise; initiateOperatorTransfer: (options: InitiateOperatorTransferOptions) => Promise; completeOperatorTransfer: (options: CompleteOperatorTransferOptions) => Promise; @@ -248,6 +250,7 @@ export interface StakingActions { delegatorJoin: (options: DelegatorJoinOptions) => Promise; delegatorExit: (options: DelegatorExitOptions) => Promise; delegatorClaim: (options: DelegatorClaimOptions) => Promise; + /** Checks whether the address is a registered/joined validator wallet. */ isValidator: (address: Address) => Promise; getValidatorInfo: (validator: Address) => Promise; getStakeInfo: (delegator: Address, validator: Address) => Promise; @@ -255,6 +258,8 @@ export interface StakingActions { getEpochData: (epochNumber: bigint) => Promise; getActiveValidators: () => Promise; getActiveValidatorsCount: () => Promise; + getJoinedValidators: () => Promise; + getJoinedValidatorsCount: () => Promise; getStakingContract: () => StakingContract; parseStakingAmount: (amount: string | bigint) => bigint; formatStakingAmount: (amount: bigint) => string; diff --git a/src/types/transactions.ts b/src/types/transactions.ts index 2fff7ec..abd6d52 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -18,12 +18,31 @@ export enum TransactionStatus { CANCELED = "CANCELED", APPEAL_REVEALING = "APPEAL_REVEALING", APPEAL_COMMITTING = "APPEAL_COMMITTING", - READY_TO_FINALIZE = "READY_TO_FINALIZE", VALIDATORS_TIMEOUT = "VALIDATORS_TIMEOUT", LEADER_TIMEOUT = "LEADER_TIMEOUT", LEADER_REVEALING = "LEADER_REVEALING", } +export enum TransactionResolutionAction { + NO_OP = "NO_OP", + CANCEL = "CANCEL", + REPLACE_ACTOR = "REPLACE_ACTOR", + ROTATE_LEADER = "ROTATE_LEADER", + RESOLVE_APPEAL = "RESOLVE_APPEAL", + MATERIALIZE_DECISION = "MATERIALIZE_DECISION", + FINALIZE = "FINALIZE", +} + +export const transactionResolutionActionNumberToName = { + "0": TransactionResolutionAction.NO_OP, + "1": TransactionResolutionAction.CANCEL, + "2": TransactionResolutionAction.REPLACE_ACTOR, + "3": TransactionResolutionAction.ROTATE_LEADER, + "4": TransactionResolutionAction.RESOLVE_APPEAL, + "5": TransactionResolutionAction.MATERIALIZE_DECISION, + "6": TransactionResolutionAction.FINALIZE, +}; + export enum TransactionResult { SUCCESS = "SUCCESS", FAILURE = "FAILURE", @@ -41,11 +60,8 @@ export enum TransactionResult { MAJORITY_TIMEOUT = "MAJORITY_TIMEOUT", } -// ReadyToFinalize is no longer one of these. It stopped being a status the -// chain stores -- readiness is the resolution kernel's verdict now -- and -// removing it at ordinal 11 shifted the three above it down. The name survives -// as a client-side state the node still reports, so TransactionStatus keeps the -// member; what changed is that no chain value decodes to it. +// ReadyToFinalize is no longer a transaction status. Readiness is exposed by +// the resolution kernel as ResolutionAction.Finalize / `canFinalize`. export const transactionsStatusNumberToName = { "0": TransactionStatus.UNINITIALIZED, "1": TransactionStatus.PENDING, @@ -63,7 +79,7 @@ export const transactionsStatusNumberToName = { "13": TransactionStatus.LEADER_REVEALING, }; -export const transactionsStatusNameToNumber: Partial> = { +export const transactionsStatusNameToNumber: Record = { [TransactionStatus.UNINITIALIZED]: "0", [TransactionStatus.PENDING]: "1", [TransactionStatus.PROPOSING]: "2", @@ -75,10 +91,6 @@ export const transactionsStatusNameToNumber: Partial; txData?: Hex; txDataDecoded?: DecodedDeployData | DecodedCallData; - // txReceipt: testnet - txReceipt?: Hash; + /** Authoritative execution hash retained by the train. */ + txExecutionHash?: Hash; + eqBlocksOutputs?: Hex; + /** Legacy receipt bytes; unavailable on the train. */ + txReceipt?: Hex; // messages: testnet messages?: unknown[]; + consumedValidators?: Address[]; // queueType: testnet queueType?: number; @@ -532,9 +576,22 @@ export type GenLayerTransaction = { lastLeader?: Address; // status: localnet: TransactionStatus // status: testnet: number + /** Lifecycle status projected at the block snapshot used for this read. */ status?: TransactionStatus | number; + /** Named form of the projected lifecycle status. */ statusName?: TransactionStatus; + /** Exact status currently persisted by the transaction manager. */ + storedStatus?: number; + /** Named form of the exact stored status. */ + storedStatusName?: TransactionStatus; + /** Resolution-kernel action currently available for the transaction. */ + resolutionAction?: number; + /** Named form of the available resolution action. */ + resolutionActionName?: TransactionResolutionAction; + /** Authoritative queue-head/deadline result from `ConsensusData.canFinalize`. */ + canFinalize?: boolean; + // hash: localnet // txId: testnet// hash: localnet // txId: testnet hash?: TransactionHash; txId?: TransactionHash; @@ -560,6 +617,7 @@ export type GenLayerTransaction = { result: number; roundValidators: Address[]; validatorVotesHash: Hash[]; + validatorResultHash: Hash[]; validatorVotes: number[]; validatorVotesName: VoteType[]; }; @@ -582,20 +640,22 @@ export type GenLayerTransaction = { }; export type GenLayerRawTransaction = { - currentTimestamp: bigint; + observedAt: bigint; sender: Address; recipient: Address; - numOfInitialValidators?: bigint; // undefined on Bradbury — use `initialRotations` instead - initialRotations?: bigint; // Bradbury equivalent of `numOfInitialValidators` + initialRotations: bigint; + numOfInitialValidators: bigint; txSlot: bigint; createdTimestamp: bigint; lastVoteTimestamp: bigint; randomSeed: Hash; result: number; txExecutionResult?: number; - txData: Hex | undefined | null; - txReceipt: Hash; + txExecutionHash: Hash; + txCalldata: Hex; + eqBlocksOutputs: Hex; messages: unknown[]; + consumedValidators: Address[]; queueType: number; queuePosition: bigint; activator: Address; @@ -618,6 +678,7 @@ export type GenLayerRawTransaction = { result: number; roundValidators: Address[]; validatorVotesHash: Hash[]; + validatorResultHash: Hash[]; validatorVotes: number[]; }; }; diff --git a/tests/client.test-d.ts b/tests/client.test-d.ts index 7b375d4..da6e192 100644 --- a/tests/client.test-d.ts +++ b/tests/client.test-d.ts @@ -46,6 +46,17 @@ test("type checks", () => { void client.getContractSchemaForCode("class SomeContract..."); + // Existing consumer-facing active-validator methods remain available, and + // the append-only joined registry is an explicitly named separate read. + void client.getActiveValidators(); + void client.getActiveValidatorsCount(); + void client.getJoinedValidators(); + void client.getJoinedValidatorsCount(); + + // Finalization readiness is an action, not a transaction status. + // @ts-expect-error READY_TO_FINALIZE was removed from the train status enum + void TransactionStatus.READY_TO_FINALIZE; + void client.waitForTransactionReceipt({ hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, }); diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 67791f2..6e155c1 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -20,8 +20,11 @@ const SENDER_ADDRESS = "0x0000000000000000000000000000000000000002"; const RECIPIENT_ADDRESS = "0x0000000000000000000000000000000000000003"; const ADDRESS_MANAGER_ADDRESS = "0x0000000000000000000000000000000000000004"; const NFT_MINTER_ADDRESS = "0x0000000000000000000000000000000000000005"; +const CONSENSUS_DATA_ADDRESS = "0x0000000000000000000000000000000000000006"; const MOCK_GENLAYER_TX_ID = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const MOCK_EVM_TX_HASH = "0x1234000000000000000000000000000000000000000000000000000000001234"; +const MOCK_DECISION_ID = 42n; +const MOCK_ATTEMPT_ID = `0x${"bb".repeat(32)}` as const; const NEW_TRANSACTION_EVENT_ABI = { type: "event" as const, @@ -181,18 +184,6 @@ const ADD_TRANSACTION_ABI_WITH_FEES = [ }, ] as const; -const selectorForV5 = encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V5 as any, - functionName: "addTransaction", - args: [SENDER_ADDRESS, RECIPIENT_ADDRESS, 5, 3, "0x"], -}).slice(0, 10); - -const selectorForV6 = encodeFunctionData({ - abi: ADD_TRANSACTION_ABI_V6 as any, - functionName: "addTransaction", - args: [SENDER_ADDRESS, RECIPIENT_ADDRESS, 5, 3, "0x", 0n], -}).slice(0, 10); - const selectorForFees = encodeFunctionData({ abi: ADD_TRANSACTION_ABI_WITH_FEES as any, functionName: "addTransaction", @@ -646,7 +637,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(deriveExternalMessageCallKey("0x123456")).toBe(CALL_KEY_UNNAMED); }); - it("encodes addTransaction with 5 args when ABI has 5 inputs", async () => { + it("uses the train tuple layout even when embedded chain metadata is stale v5", async () => { const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V5, }); @@ -660,10 +651,10 @@ describe("contractActions addTransaction ABI compatibility", () => { ).rejects.toThrow("stop_after_encoding"); const encodedData = estimateTransactionGas.mock.calls[0][0].data as `0x${string}`; - expect(encodedData.slice(0, 10)).toBe(selectorForV5); + expect(encodedData.slice(0, 10)).toBe(selectorForFees); }); - it("encodes addTransaction with 6 args when ABI has 6 inputs", async () => { + it("uses the train tuple layout even when embedded chain metadata is stale v6", async () => { const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V6, }); @@ -677,7 +668,7 @@ describe("contractActions addTransaction ABI compatibility", () => { ).rejects.toThrow("stop_after_encoding"); const encodedData = estimateTransactionGas.mock.calls[0][0].data as `0x${string}`; - expect(encodedData.slice(0, 10)).toBe(selectorForV6); + expect(encodedData.slice(0, 10)).toBe(selectorForFees); }); it("encodes addTransaction with v0.6 fee params when ABI has tuple input", async () => { @@ -1490,11 +1481,10 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.feeValue).toBe(0n); }); - it("retries with v6 signature when v5 signature fails with ABI mismatch", async () => { + it("does not retry an obsolete selector when the exact train call fails", async () => { const signTransaction = vi .fn() - .mockRejectedValueOnce(new Error("Invalid pointer in tuple at location 128 in payload")) - .mockRejectedValueOnce(new Error("stop_after_retry")); + .mockRejectedValueOnce(new Error("Invalid pointer in tuple at location 128 in payload")); const {actions, estimateTransactionGas} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_V5, signTransactionMock: signTransaction, @@ -1506,68 +1496,12 @@ describe("contractActions addTransaction ABI compatibility", () => { functionName: "ping", value: 0n, }), - ).rejects.toThrow("stop_after_retry"); - - expect(signTransaction).toHaveBeenCalledTimes(2); - const firstEncodedData = signTransaction.mock.calls[0][0].data as `0x${string}`; - const secondEncodedData = signTransaction.mock.calls[1][0].data as `0x${string}`; - expect(firstEncodedData.slice(0, 10)).toBe(selectorForV5); - expect(secondEncodedData.slice(0, 10)).toBe(selectorForV6); - expect(estimateTransactionGas).toHaveBeenCalledTimes(2); - }); - - it("retries when ABI mismatch details are on error.details (viem InternalRpcError shape)", async () => { - const signTransaction = vi - .fn() - .mockRejectedValueOnce({ - shortMessage: "An internal error was received.", - details: "Invalid pointer in tuple at location 128 in payload", - }) - .mockRejectedValueOnce(new Error("stop_after_retry")); - const {actions} = setupWriteContractHarness({ - initialAbi: ADD_TRANSACTION_ABI_V5, - signTransactionMock: signTransaction as any, - }); - - await expect( - actions.writeContract({ - address: RECIPIENT_ADDRESS, - functionName: "ping", - value: 0n, - }), - ).rejects.toThrow("stop_after_retry"); - - expect(signTransaction).toHaveBeenCalledTimes(2); - const firstEncodedData = signTransaction.mock.calls[0][0].data as `0x${string}`; - const secondEncodedData = signTransaction.mock.calls[1][0].data as `0x${string}`; - expect(firstEncodedData.slice(0, 10)).toBe(selectorForV5); - expect(secondEncodedData.slice(0, 10)).toBe(selectorForV6); - }); - - it("retries with v5 signature when v6 signature fails with ABI mismatch", async () => { - const signTransaction = vi - .fn() - .mockRejectedValueOnce(new Error("Invalid pointer in tuple at location 128 in payload")) - .mockRejectedValueOnce(new Error("stop_after_retry")); - const {actions, estimateTransactionGas} = setupWriteContractHarness({ - initialAbi: ADD_TRANSACTION_ABI_V6, - signTransactionMock: signTransaction, - }); - - await expect( - actions.writeContract({ - address: RECIPIENT_ADDRESS, - functionName: "ping", - value: 0n, - }), - ).rejects.toThrow("stop_after_retry"); + ).rejects.toThrow("Invalid pointer in tuple"); - expect(signTransaction).toHaveBeenCalledTimes(2); + expect(signTransaction).toHaveBeenCalledTimes(1); const firstEncodedData = signTransaction.mock.calls[0][0].data as `0x${string}`; - const secondEncodedData = signTransaction.mock.calls[1][0].data as `0x${string}`; - expect(firstEncodedData.slice(0, 10)).toBe(selectorForV6); - expect(secondEncodedData.slice(0, 10)).toBe(selectorForV5); - expect(estimateTransactionGas).toHaveBeenCalledTimes(2); + expect(firstEncodedData.slice(0, 10)).toBe(selectorForFees); + expect(estimateTransactionGas).toHaveBeenCalledTimes(1); }); it("uses direct eth_sendTransaction for non-local accounts without prepareTransactionRequest", async () => { @@ -1635,7 +1569,7 @@ describe("contractActions addTransaction ABI compatibility", () => { }); }); - it("retries alternate ABI for injected-wallet errors with nested invalid pointer details", async () => { + it("does not retry an obsolete selector for injected-wallet errors", async () => { const sentPayloads: `0x${string}`[] = []; const request = vi.fn().mockImplementation(async ({method, params}: {method: string; params?: any[]}) => { if (method === "eth_gasPrice") { @@ -1646,19 +1580,7 @@ describe("contractActions addTransaction ABI compatibility", () => { const payload = params?.[0]; sentPayloads.push(payload?.data); - if (sentPayloads.length === 1) { - throw { - code: -32603, - message: "Internal JSON-RPC error.", - data: { - originalError: { - message: "Invalid pointer in tuple at location 128 in payload", - }, - }, - }; - } - - return MOCK_EVM_TX_HASH; + throw new Error("Internal JSON-RPC error: invalid tuple pointer"); } throw new Error(`Unexpected RPC method: ${method}`); @@ -1688,17 +1610,12 @@ describe("contractActions addTransaction ABI compatibility", () => { }; const actions = contractActions(client as any, mockPublicClient as any); - const txHash = await actions.writeContract({ - address: RECIPIENT_ADDRESS, - functionName: "ping", - value: 0n, - }); + await expect( + actions.writeContract({address: RECIPIENT_ADDRESS, functionName: "ping", value: 0n}), + ).rejects.toThrow("invalid tuple pointer"); - // Should return GenLayer txId, not the EVM hash - expect(txHash).toBe(MOCK_GENLAYER_TX_ID); - expect(sentPayloads).toHaveLength(2); - expect(sentPayloads[0].slice(0, 10)).toBe(selectorForV5); - expect(sentPayloads[1].slice(0, 10)).toBe(selectorForV6); + expect(sentPayloads).toHaveLength(1); + expect(sentPayloads[0].slice(0, 10)).toBe(selectorForFees); }); it("throws when external wallet transaction is reverted", async () => { @@ -1797,14 +1714,38 @@ const FINALIZE_TX_ABI = [ type: "function" as const, name: "finalizeTransaction", stateMutability: "nonpayable" as const, - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, { type: "function" as const, - name: "finalizeIdlenessTxs", + name: "resolveTransactions", stateMutability: "nonpayable" as const, - inputs: [{name: "_txIds", type: "bytes32[]"}], + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedAttemptId", type: "bytes32"}, + ], + }], + outputs: [], + }, + { + type: "function" as const, + name: "finalizeDecisions", + stateMutability: "nonpayable" as const, + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedDecisionId", type: "uint256"}, + ], + }], outputs: [], }, ]; @@ -1826,22 +1767,42 @@ const FEE_MANAGEMENT_ABI = [ stateMutability: "payable" as const, inputs: [ {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, ], outputs: [], }, ] as const; +const APPEAL_TRAIN_ABI = [ + { + type: "function" as const, + name: "submitAppeal", + stateMutability: "payable" as const, + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, +] as const; + const finalizeTransactionSelector = encodeFunctionData({ abi: FINALIZE_TX_ABI as any, functionName: "finalizeTransaction", - args: [MOCK_GENLAYER_TX_ID], + args: [MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID], }).slice(0, 10); -const finalizeIdlenessSelector = encodeFunctionData({ +const resolveTransactionsSelector = encodeFunctionData({ abi: FINALIZE_TX_ABI as any, - functionName: "finalizeIdlenessTxs", - args: [[MOCK_GENLAYER_TX_ID]], + functionName: "resolveTransactions", + args: [[{txId: MOCK_GENLAYER_TX_ID, expectedAttemptId: MOCK_ATTEMPT_ID}]], +}).slice(0, 10); + +const finalizeDecisionsSelector = encodeFunctionData({ + abi: FINALIZE_TX_ABI as any, + functionName: "finalizeDecisions", + args: [[{txId: MOCK_GENLAYER_TX_ID, expectedDecisionId: MOCK_DECISION_ID}]], }).slice(0, 10); const topUpFeesSelector = encodeFunctionData({ @@ -1869,6 +1830,7 @@ const topUpAndSubmitAppealSelector = encodeFunctionData({ functionName: "topUpAndSubmitAppeal", args: [ MOCK_GENLAYER_TX_ID, + MOCK_DECISION_ID, { leaderTimeunitsAllocation: 0n, validatorTimeunitsAllocation: 0n, @@ -1884,6 +1846,20 @@ const topUpAndSubmitAppealSelector = encodeFunctionData({ ], }).slice(0, 10); +const trainLifecycle = ({action = 6, decisionActive = true}: {action?: number; decisionActive?: boolean} = {}) => ({ + storedStatus: 5, + resolution: { + projectedStatus: 5, + action, + attemptId: MOCK_ATTEMPT_ID, + }, + latestDecision: { + exists: decisionActive, + decisionId: decisionActive ? MOCK_DECISION_ID : 0n, + }, + decisionActive, +}); + const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: string} = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); @@ -1898,6 +1874,11 @@ const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: stri abi: FINALIZE_TX_ABI, bytecode: "0x", }, + consensusDataContract: { + address: CONSENSUS_DATA_ADDRESS, + abi: [], + bytecode: "0x", + }, }, account: { address: SENDER_ADDRESS, @@ -1913,18 +1894,28 @@ const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: stri }), }; - const publicClient = {waitForTransactionReceipt}; + const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "getTransactionLifecycle") return trainLifecycle(); + throw new Error(`Unexpected contract read: ${functionName}`); + }); + const publicClient = { + waitForTransactionReceipt, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + readContract, + }; const actions = contractActions(client as any, publicClient as any); - return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client}; + return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client, publicClient}; }; const setupFeeManagementHarness = ({ receiptStatus = "success", isStudio = false, + decisionActive = true, }: { receiptStatus?: string; isStudio?: boolean; + decisionActive?: boolean; } = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); @@ -1940,6 +1931,21 @@ const setupFeeManagementHarness = ({ abi: FEE_MANAGEMENT_ABI, bytecode: "0x", }, + consensusDataContract: { + address: CONSENSUS_DATA_ADDRESS, + abi: [], + bytecode: "0x", + }, + appealsContract: { + address: RECIPIENT_ADDRESS, + abi: [], + bytecode: "0x", + }, + feeManagerContract: { + address: ADDRESS_MANAGER_ADDRESS, + abi: [], + bytecode: "0x", + }, }, account: { address: SENDER_ADDRESS, @@ -1955,10 +1961,22 @@ const setupFeeManagementHarness = ({ }), }; - const publicClient = {waitForTransactionReceipt}; + const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "getTransactionLifecycle") return trainLifecycle({decisionActive}); + if (functionName === "estimateLatestAppealCharge") { + return [MOCK_DECISION_ID, 1000n, 234n, 999n]; + } + if (functionName === "canAppeal") return true; + throw new Error(`Unexpected contract read: ${functionName}`); + }); + const publicClient = { + waitForTransactionReceipt, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + readContract, + }; const actions = contractActions(client as any, publicClient as any); - return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client}; + return {actions, signTransaction, sendRawTransaction, waitForTransactionReceipt, estimateTransactionGas, client, publicClient}; }; describe("contractActions getContractCode", () => { @@ -2091,7 +2109,109 @@ describe("contractActions getContractSchemaForCode", () => { }); }); +describe("contractActions bounded round reads", () => { + const validators = Array.from( + {length: 65}, + (_, index) => `0x${(index + 10).toString(16).padStart(40, "0")}`, + ); + const voteHashes = validators.map((_, index) => + `0x${(index + 1).toString(16).padStart(64, "0")}`, + ); + const resultHashes = validators.map((_, index) => + `0x${(index + 101).toString(16).padStart(64, "0")}`, + ); + + const setup = () => { + const readContract = vi.fn().mockImplementation(async ({functionName, args}: any) => { + if (functionName === "getRoundNumber") return 3n; + if (functionName === "getLeaderIndex") return 2n; + if (functionName === "getVotesCommitted") return 65n; + if (functionName === "getVotesRevealed") return 64n; + if (functionName === "getAppealBond") return 99n; + if (functionName === "getRotationsLeft") return 4n; + if (functionName === "getResult") return 1; + if (functionName === "getValidatorVotes") return validators.map(() => 1); + if (functionName === "getValidatorVotesHash") return voteHashes; + if (functionName === "getValidatorResultHash") return resultHashes; + if (functionName === "getRoundValidatorsPage") { + const offset = Number(args[2]); + const limit = Number(args[3]); + return [validators.slice(offset, offset + limit), BigInt(validators.length)]; + } + throw new Error(`Unexpected read ${functionName}`); + }); + const publicClient = { + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + readContract, + }; + const client = { + chain: { + roundsStorageContract: {address: ADDRESS_MANAGER_ADDRESS, abi: []}, + }, + }; + return {actions: contractActions(client as any, publicClient as any), readContract}; + }; + + it("reconstructs getRoundData through fixed-block pages and split arrays", async () => { + const {actions, readContract} = setup(); + + const roundData = await actions.getRoundData({txId: MOCK_GENLAYER_TX_ID, round: 3n}); + + expect(roundData.roundValidators).toEqual(validators); + expect(roundData.validatorVotes).toHaveLength(65); + expect(roundData.validatorVotesHash).toEqual(voteHashes); + expect(roundData.validatorResultHash).toEqual(resultHashes); + expect(readContract.mock.calls.filter(([call]) => call.functionName === "getRoundValidatorsPage")) + .toHaveLength(2); + expect(readContract.mock.calls.every(([call]) => call.blockNumber === 123n)).toBe(true); + expect(readContract.mock.calls.some(([call]) => call.functionName === "getRoundData")).toBe(false); + }); + + it("preserves the legacy getLastRoundData tuple shape without the aggregate getter", async () => { + const {actions, readContract} = setup(); + + const lastRound = await actions.getLastRoundData({txId: MOCK_GENLAYER_TX_ID}); + + expect(lastRound[0]).toBe(3n); + expect(lastRound[1].roundValidators).toEqual(validators); + expect(lastRound.round).toBe(3n); + expect(lastRound.roundData).toBe(lastRound[1]); + expect(readContract.mock.calls.some(([call]) => call.functionName === "getLastRoundData")) + .toBe(false); + }); +}); + describe("contractActions fee management", () => { + it("quotes the full authoritative appeal charge", async () => { + const {actions, client} = setupFeeManagementHarness(); + delete (client.chain as any).feeManagerContract; + + await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); + await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); + }); + + it("encodes submitAppeal with the active decision id and quoted funding", async () => { + const {actions, signTransaction} = setupFeeManagementHarness(); + + await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( + MOCK_GENLAYER_TX_ID, + ); + + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.value).toBe(1234n); + const decoded = decodeFunctionData({abi: APPEAL_TRAIN_ABI, data: txRequest.data}); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + }); + + it("returns false from canAppeal when no decision is active", async () => { + const {actions, publicClient} = setupFeeManagementHarness({decisionActive: false}); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(false); + expect(publicClient.readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "canAppeal"}), + ); + }); + it("encodes topUpFees(bytes32, FeesDistribution) and returns the EVM tx hash", async () => { const {actions, signTransaction, sendRawTransaction} = setupFeeManagementHarness(); @@ -2135,7 +2255,7 @@ describe("contractActions fee management", () => { expect(distribution.receiptFeeMaxGasPrice).toBe(36n); }); - it("encodes topUpAndSubmitAppeal(bytes32, FeesDistribution) and returns the GenLayer tx id", async () => { + it("encodes topUpAndSubmitAppeal with the active decision id", async () => { const {actions, signTransaction, sendRawTransaction} = setupFeeManagementHarness(); const txId = await actions.topUpAndSubmitAppeal({ @@ -2158,8 +2278,9 @@ describe("contractActions fee management", () => { abi: FEE_MANAGEMENT_ABI as any, data: txRequest.data, }); - const [decodedTxId, distribution] = decoded.args as any[]; + const [decodedTxId, expectedDecisionId, distribution] = decoded.args as any[]; expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); + expect(expectedDecisionId).toBe(MOCK_DECISION_ID); expect(distribution.appealRounds).toBe(1n); expect(distribution.rotations).toEqual([0n, 1n]); }); @@ -2231,7 +2352,7 @@ describe("contractActions fee management", () => { }); describe("contractActions finalizeTransaction", () => { - it("encodes finalizeTransaction(bytes32) and returns EVM tx hash", async () => { + it("encodes finalizeTransaction with the active decision id", async () => { const {actions, signTransaction, sendRawTransaction} = setupFinalizeHarness(); const evmHash = await actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID}); @@ -2241,6 +2362,8 @@ describe("contractActions finalizeTransaction", () => { const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); expect(txRequest.data.slice(0, 10)).toBe(finalizeTransactionSelector); + const decoded = decodeFunctionData({abi: FINALIZE_TX_ABI as any, data: txRequest.data}); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); expect(txRequest.value).toBe(0n); }); @@ -2253,22 +2376,41 @@ describe("contractActions finalizeTransaction", () => { }); describe("contractActions finalizeIdlenessTxs", () => { - it("encodes finalizeIdlenessTxs(bytes32[]) and returns EVM tx hash", async () => { - const {actions, signTransaction, sendRawTransaction} = setupFinalizeHarness(); + it("fails with actionable train replacements and never broadcasts", async () => { + const {actions, signTransaction} = setupFinalizeHarness(); + await expect( + actions.finalizeIdlenessTxs({txIds: [MOCK_GENLAYER_TX_ID]}), + ).rejects.toThrow(/resolveTransactions.*finalizeDecisions/); + expect(signTransaction).not.toHaveBeenCalled(); + }); +}); + +describe("contractActions train lifecycle batches", () => { + it("encodes resolveTransactions with fixed-block attempt identities", async () => { + const {actions, signTransaction} = setupFinalizeHarness(); - const evmHash = await actions.finalizeIdlenessTxs({txIds: [MOCK_GENLAYER_TX_ID]}); + await actions.resolveTransactions({txIds: [MOCK_GENLAYER_TX_ID]}); - expect(evmHash).toBe(MOCK_EVM_TX_HASH); - expect(sendRawTransaction).toHaveBeenCalledTimes(1); const txRequest = signTransaction.mock.calls[0][0]; - expect(txRequest.data.slice(0, 10)).toBe(finalizeIdlenessSelector); + expect(txRequest.data.slice(0, 10)).toBe(resolveTransactionsSelector); + const decoded = decodeFunctionData({abi: FINALIZE_TX_ABI as any, data: txRequest.data}); + expect(decoded.args?.[0]).toEqual([{ + txId: MOCK_GENLAYER_TX_ID, + expectedAttemptId: MOCK_ATTEMPT_ID, + }]); }); - it("rejects an empty batch upfront", async () => { + it("encodes finalizeDecisions with fixed-block decision identities", async () => { const {actions, signTransaction} = setupFinalizeHarness(); - await expect( - actions.finalizeIdlenessTxs({txIds: []}), - ).rejects.toThrow(/at least one txId/); - expect(signTransaction).not.toHaveBeenCalled(); + + await actions.finalizeDecisions({txIds: [MOCK_GENLAYER_TX_ID]}); + + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.data.slice(0, 10)).toBe(finalizeDecisionsSelector); + const decoded = decodeFunctionData({abi: FINALIZE_TX_ABI as any, data: txRequest.data}); + expect(decoded.args?.[0]).toEqual([{ + txId: MOCK_GENLAYER_TX_ID, + expectedDecisionId: MOCK_DECISION_ID, + }]); }); }); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts index 6465852..1ba12a1 100644 --- a/tests/staking-actions.test.ts +++ b/tests/staking-actions.test.ts @@ -56,6 +56,12 @@ const readRegistrationContract = vi.fn().mockImplementation(async ({functionName throw new Error(`Unexpected read: ${functionName}`); }); +const makeStakingReadHarness = (readContract: ReturnType) => { + const client = {chain: baseChain}; + const publicClient = {readContract}; + return {actions: stakingActions(client as any, publicClient as any), publicClient}; +}; + // Local-key harness (byte-for-byte regression anchor for the sign+sendRaw lane). const makeLocalHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -252,3 +258,124 @@ describe("stakingActions provider lane (Address-only)", () => { expect(sentTxParams(request).gasPrice).toBeUndefined(); }); }); + +describe("stakingActions validator reads", () => { + it("uses the authoritative ban predicate instead of a stale nonzero ban epoch", async () => { + const readContract = vi.fn().mockImplementation(async ({functionName}: any) => { + if (functionName === "isValidator") return true; + if (functionName === "validatorView") { + return { + eBanned: 5n, + ePrimed: 9n, + vStake: parseEther("2"), + vShares: 2n, + dStake: 0n, + dShares: 0n, + vDeposit: 0n, + vWithdrawal: 0n, + live: true, + }; + } + if (functionName === "owner") return ACCOUNT_ADDRESS; + if (functionName === "operator") return OPERATOR_ADDRESS; + if (functionName === "getIdentity") return null; + if (functionName === "epoch") return 10n; + if (functionName === "validatorMinStake") return parseEther("1"); + if (functionName === "isValidatorBanned") return false; + if (functionName === "validatorDepositLen") return 0n; + if (functionName === "validatorWithdrawalLen") return 0n; + throw new Error(`Unexpected read: ${functionName}`); + }); + const {actions} = makeStakingReadHarness(readContract); + + const info = await actions.getValidatorInfo(VALIDATOR_WALLET_ADDRESS); + + expect(info.banned).toBe(false); + expect(info.bannedEpoch).toBeUndefined(); + expect(readContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: "isValidatorBanned", + args: [VALIDATOR_WALLET_ADDRESS], + })); + }); +}); + +describe("stakingActions validator-set reads", () => { + it("keeps setOperator source-compatible with an actionable train migration error", async () => { + const readContract = vi.fn(); + const {actions} = makeStakingReadHarness(readContract); + + await expect(actions.setOperator({ + validator: VALIDATOR_WALLET_ADDRESS, + operator: ACCOUNT_ADDRESS, + })).rejects.toThrow("initiateOperatorTransfer and completeOperatorTransfer"); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("keeps active-validator APIs bound to the selectable duty set", async () => { + const selectable = [ACCOUNT_ADDRESS, VALIDATOR_WALLET_ADDRESS]; + const readContract = vi.fn().mockImplementation(async ({functionName}: any) => { + if (functionName === "selectableValidators") return selectable; + if (functionName === "selectableValidatorsCount") return 2n; + throw new Error(`Unexpected read: ${functionName}`); + }); + const {actions} = makeStakingReadHarness(readContract); + + await expect(actions.getActiveValidators()).resolves.toEqual(selectable); + await expect(actions.getActiveValidatorsCount()).resolves.toBe(2n); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "validatorsJoinedCount"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getValidatorsJoined"}), + ); + }); + + it("exposes the joined registry separately and reads it in bounded pages", async () => { + const joined = Array.from( + {length: 66}, + (_, index) => `0x${(index + 1).toString(16).padStart(40, "0")}`, + ); + const readContract = vi.fn().mockImplementation(async ({functionName, args}: any) => { + if (functionName === "validatorsJoinedCount") return 66n; + if (functionName === "getValidatorsJoined") { + const [start, size] = args as [bigint, bigint]; + return joined.slice(Number(start), Number(start + size)); + } + throw new Error(`Unexpected read: ${functionName}`); + }); + const {actions} = makeStakingReadHarness(readContract); + + await expect(actions.getJoinedValidatorsCount()).resolves.toBe(66n); + await expect(actions.getJoinedValidators()).resolves.toEqual(joined); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({functionName: "getValidatorsJoined", args: [0n, 64n]}), + ); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({functionName: "getValidatorsJoined", args: [64n, 64n]}), + ); + }); + + it("reports the selectable count as active in epoch information", async () => { + const epochData = [10n, 0n, 0n, 0n, 0n, 0n, 3n, 0n, 0n, 0n, 0n]; + const responses: Record = { + epoch: 4n, + finalized: 3n, + selectableValidatorsCount: 2n, + epochMinDuration: 60n, + epochZeroMinDuration: 120n, + epochOdd: epochData, + epochEven: epochData, + validatorMinStake: 1n, + delegatorMinStake: 1n, + }; + const readContract = vi.fn().mockImplementation(async ({functionName}: any) => responses[functionName]); + const {actions} = makeStakingReadHarness(readContract); + + const info = await actions.getEpochInfo(); + + expect(info.activeValidatorsCount).toBe(2n); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({functionName: "selectableValidatorsCount"}), + ); + }); +}); diff --git a/tests/staking-commit-layout.test.ts b/tests/staking-commit-layout.test.ts index 0c2aede..fc72fa5 100644 --- a/tests/staking-commit-layout.test.ts +++ b/tests/staking-commit-layout.test.ts @@ -1,64 +1,128 @@ -/** - * The Claim/Commit layout probe rests on one asymmetry, so pin it. - * - * CON-715 widened both structs without renaming the functions. Decoding a - * post-CON-715 response with the older shape does NOT fail — it returns - * neighbouring words — so a successful decode cannot identify the layout. - * Only the reverse throws. That is why stakingActions tries the current shape - * first and treats a decode failure, rather than a success, as the signal. - * - * If this asymmetry ever stops holding, the probe silently starts reporting - * wrong balances again, which is exactly the bug it exists to prevent. - */ import {describe, expect, it} from "vitest"; import {decodeFunctionResult, encodeAbiParameters} from "viem"; -import {STAKING_ABI, STAKING_COMMIT_VIEWS_CURRENT_ABI} from "../src/abi/staking"; +import {STAKING_ABI} from "../src/abi/staking"; -const STAKE = 100000000000000000n; // 0.1 GEN -const CLAIM_COMMIT_INDEX = 2n; +const STAKE = 100000000000000000n; +const CLAIM_COMPONENTS = [ + {name: "quantity", type: "uint120"}, + {name: "offset", type: "uint120"}, + {name: "commit", type: "uint256"}, +]; +const COMMIT_COMPONENTS = [ + {name: "input", type: "uint256"}, + {name: "output", type: "uint256"}, + {name: "outstanding", type: "uint120"}, + {name: "epoch", type: "uint64"}, + {name: "linkToNextCommit", type: "uint56"}, + {name: "priced", type: "bool"}, + {name: "fragmented", type: "bool"}, +]; +const VALIDATOR_VIEW_COMPONENTS = [ + {name: "eBanned", type: "uint256"}, + {name: "ePrimed", type: "uint256"}, + {name: "vStake", type: "uint256"}, + {name: "vShares", type: "uint256"}, + {name: "dStake", type: "uint256"}, + {name: "dShares", type: "uint256"}, + {name: "vDeposit", type: "uint256"}, + {name: "vWithdrawal", type: "uint256"}, + {name: "live", type: "bool"}, +]; -const outputsOf = (abi: readonly any[], name: string) => - abi.find((e: any) => e.type === "function" && e.name === name)!.outputs; +const view = (name: string) => + (STAKING_ABI as readonly any[]).find( + entry => entry.type === "function" && entry.name === name && entry.stateMutability === "view", + )!; -const legacyOutputs = outputsOf(STAKING_ABI as any, "delegatorDeposit"); -const currentOutputs = outputsOf(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, "delegatorDeposit"); +describe("staking Claim/Commit train layout", () => { + it("does not ship duplicate function signatures", () => { + const signatures = (STAKING_ABI as readonly any[]) + .filter(entry => entry.type === "function") + .map(entry => `${entry.name}(${entry.inputs.map((input: any) => input.type).join(",")})`); + expect(new Set(signatures).size).toBe(signatures.length); + }); + + it("uses the canonical train tuple in every commit read", () => { + for (const name of [ + "delegatorDeposit", + "delegatorWithdrawal", + "delegatorDepositByEpoch", + "delegatorWithdrawalByEpoch", + "validatorDeposit", + "validatorWithdrawal", + "validatorDepositByEpoch", + "validatorWithdrawalByEpoch", + ]) { + const commit = view(name).outputs.at(-1); + expect(commit.components, name).toEqual(COMMIT_COMPONENTS); + } -const legacyResponse = encodeAbiParameters(legacyOutputs, [ - {quantity: 7n, commit: CLAIM_COMMIT_INDEX}, - {input: STAKE, output: 5n, epoch: 3n, linkToNextCommit: 0n}, -] as any); + expect(view("delegatorDeposit").outputs[0].components).toEqual(CLAIM_COMPONENTS); + expect(view("delegatorWithdrawal").outputs[0].components).toEqual(CLAIM_COMPONENTS); + }); -const currentResponse = encodeAbiParameters(currentOutputs, [ - {quantity: 7n, offset: 0n, commit: CLAIM_COMMIT_INDEX}, - { - input: STAKE, - output: 5n, - outstanding: 9n, - epoch: 3n, - linkToNextCommit: 0n, - priced: true, - fragmented: false, - }, -] as any); + it("decodes raw train delegator bytes without a layout probe", () => { + const outputs = view("delegatorDeposit").outputs; + const encoded = encodeAbiParameters(outputs, [ + {quantity: 7n, offset: 2n, commit: 3n}, + { + input: STAKE, + output: 5n, + outstanding: 9n, + epoch: 4n, + linkToNextCommit: 0n, + priced: true, + fragmented: false, + }, + ] as any); -const decodeWith = (abi: readonly any[], data: `0x${string}`) => - decodeFunctionResult({abi: abi as any, functionName: "delegatorDeposit", data}) as any; + const [claim, commit] = decodeFunctionResult({ + abi: STAKING_ABI, + functionName: "delegatorDeposit", + data: encoded, + }) as any; -describe("staking Claim/Commit layout", () => { - it("reads the amount when the shape matches the response", () => { - expect(decodeWith(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, currentResponse)[1].input).toBe(STAKE); - expect(decodeWith(STAKING_ABI as any, legacyResponse)[1].input).toBe(STAKE); + expect(claim).toMatchObject({quantity: 7n, offset: 2n, commit: 3n}); + expect(commit).toMatchObject({input: STAKE, outstanding: 9n, epoch: 4n, priced: true}); }); +}); - it("throws when the current shape meets a legacy response — this is the probe", () => { - expect(() => decodeWith(STAKING_COMMIT_VIEWS_CURRENT_ABI as any, legacyResponse)).toThrow(); +describe("staking ValidatorView train layout", () => { + it("uses the canonical nine-field tuple in every validator view", () => { + for (const name of ["validatorView", "validatorViewPrimed", "validatorViewPrePrimed"]) { + expect(view(name).outputs[0].components, name).toEqual(VALIDATOR_VIEW_COMPONENTS); + } }); - it("silently misreads when the legacy shape meets a current response", () => { - // Not a throw: claim.commit lands where commit.input is expected, which is - // how pending deposits came back as small indices instead of amounts. - const misread = decodeWith(STAKING_ABI as any, currentResponse)[1].input; - expect(misread).not.toBe(STAKE); - expect(misread).toBe(CLAIM_COMMIT_INDEX); + it("decodes raw train validator bytes without removed tree links", () => { + const output = view("validatorView").outputs[0]; + const encoded = encodeAbiParameters([output], [{ + eBanned: 1n, + ePrimed: 2n, + vStake: 3n, + vShares: 4n, + dStake: 5n, + dShares: 6n, + vDeposit: 7n, + vWithdrawal: 8n, + live: true, + }] as any); + + const decoded = decodeFunctionResult({ + abi: STAKING_ABI, + functionName: "validatorView", + data: encoded, + }) as any; + + expect(decoded).toMatchObject({ + eBanned: 1n, + ePrimed: 2n, + vStake: 3n, + dStake: 5n, + live: true, + }); + expect(decoded).not.toHaveProperty("left"); + expect(decoded).not.toHaveProperty("right"); + expect(decoded).not.toHaveProperty("parent"); }); }); diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 06dcfda..1519173 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -6,14 +6,20 @@ import { DECIDED_STATES, isDecidedState, transactionsStatusNumberToName, + transactionsStatusNameToNumber, + TransactionResolutionAction, + transactionResolutionActionNumberToName, transactionResultNumberToName, executionResultNumberToName, + VoteType, + voteTypeNumberToName, } from "../src/types/transactions"; import { receiptActions, transactionActions, isSuccessful } from "../src/transactions/actions"; import { decodeTransaction, simplifyTransactionReceipt } from "../src/transactions/decoders"; import { localnet } from "../src/chains/localnet"; import type { GenLayerRawTransaction } from "../src/types/transactions"; -import {keccak256, stringToBytes} from "viem"; +import {decodeFunctionResult, encodeFunctionResult, keccak256, stringToBytes} from "viem"; +import {CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, CONSENSUS_DATA_TRAIN_ABI} from "../src/abi/consensusTrain"; const mockFetch = vi.fn(); vi.stubGlobal("fetch", mockFetch); @@ -66,9 +72,36 @@ describe("transaction enum maps", () => { expect(transactionsStatusNumberToName["13"]).toBe(TransactionStatus.LEADER_REVEALING); expect(transactionsStatusNumberToName["11"]).toBe(TransactionStatus.VALIDATORS_TIMEOUT); expect(transactionsStatusNumberToName["12"]).toBe(TransactionStatus.LEADER_TIMEOUT); + expect("READY_TO_FINALIZE" in TransactionStatus).toBe(false); + expect(Object.keys(transactionsStatusNameToNumber)).not.toContain("READY_TO_FINALIZE"); + expect(transactionResolutionActionNumberToName).toEqual({ + "0": TransactionResolutionAction.NO_OP, + "1": TransactionResolutionAction.CANCEL, + "2": TransactionResolutionAction.REPLACE_ACTOR, + "3": TransactionResolutionAction.ROTATE_LEADER, + "4": TransactionResolutionAction.RESOLVE_APPEAL, + "5": TransactionResolutionAction.MATERIALIZE_DECISION, + "6": TransactionResolutionAction.FINALIZE, + }); expect(isDecidedState("13")).toBe(false); expect(executionResultNumberToName["3"]).toBe(ExecutionResult.TIMEOUT); expect(executionResultNumberToName["4"]).toBe(ExecutionResult.NONDET_DISAGREE); + expect(executionResultNumberToName).toEqual({ + "0": ExecutionResult.NOT_VOTED, + "1": ExecutionResult.FINISHED_WITH_RETURN, + "2": ExecutionResult.FINISHED_WITH_ERROR, + "3": ExecutionResult.TIMEOUT, + "4": ExecutionResult.NONDET_DISAGREE, + "5": ExecutionResult.DETERMINISTIC_VIOLATION, + }); + expect(voteTypeNumberToName).toEqual({ + "0": VoteType.NOT_VOTED, + "1": VoteType.FINISHED_WITH_RETURN, + "2": VoteType.FINISHED_WITH_ERROR, + "3": VoteType.TIMEOUT, + "4": VoteType.NONDET_DISAGREE, + "5": VoteType.DETERMINISTIC_VIOLATION, + }); expect(transactionResultNumberToName).toEqual({ "0": TransactionResult.IDLE, "1": TransactionResult.MAJORITY_AGREE, @@ -78,6 +111,34 @@ describe("transaction enum maps", () => { "5": TransactionResult.NO_MAJORITY, }); }); + + it("decodes the train lifecycle ABI from raw return bytes", () => { + const bytes32 = `0x${"00".repeat(32)}` as const; + const resolution = [ + bytes32, 5, 6, 6, 1, bytes32, 0, 0n, 0n, bytes32, bytes32, 0n, 0n, + 0n, 0, bytes32, 100n, 101n, 102n, 10n, 110n, false, true, false, + ] as const; + const latestDecision = [ + true, 1n, 0n, 0, 0, bytes32, 5, 6, 0n, 0n, bytes32, bytes32, 0n, + 0n, 1, bytes32, 100n, 101n, 110n, + ] as const; + const encoded = encodeFunctionResult({ + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + result: [5, resolution, latestDecision, true] as any, + }); + + const decoded = decodeFunctionResult({ + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + data: encoded, + }) as any; + + expect(decoded.storedStatus).toBe(5); + expect(decoded.resolution.projectedStatus).toBe(6); + expect(decoded.resolution.action).toBe(6); + expect(decoded.decisionActive).toBe(true); + }); }); describe("isSuccessful", () => { @@ -322,21 +383,24 @@ describe("cancelTransaction", () => { }); }); -// ─── decodeTransaction field normalization ────────────────────────────────── +// ─── decodeTransaction train layout ───────────────────────────────────────── const makeRawTx = (overrides: Record = {}): GenLayerRawTransaction => ({ - currentTimestamp: 1000n, + observedAt: 1000n, sender: "0x0000000000000000000000000000000000000001" as any, recipient: "0x0000000000000000000000000000000000000002" as any, - numOfInitialValidators: 3n, + initialRotations: 3n, + numOfInitialValidators: 5n, txSlot: 5n, createdTimestamp: 900n, lastVoteTimestamp: 950n, randomSeed: "0x" + "ab".repeat(32) as any, result: 1, - txData: "0x" as any, - txReceipt: "0x" + "00".repeat(32) as any, + txCalldata: "0x" as any, + txExecutionHash: "0x" + "00".repeat(32) as any, + eqBlocksOutputs: "0x", messages: [], + consumedValidators: [], queueType: 0, queuePosition: 0n, activator: "0x0000000000000000000000000000000000000003" as any, @@ -359,11 +423,144 @@ const makeRawTx = (overrides: Record = {}): GenLayerRawTransact result: 1, roundValidators: [], validatorVotesHash: [], + validatorResultHash: [], validatorVotes: [1, 1, 1], }, ...overrides, }); +const makeLightTx = (overrides: Record = {}) => { + const raw = makeRawTx(); + return { + observedAt: raw.observedAt, + sender: raw.sender, + recipient: raw.recipient, + initialRotations: raw.initialRotations, + txSlot: raw.txSlot, + createdTimestamp: raw.createdTimestamp, + lastVoteTimestamp: raw.lastVoteTimestamp, + randomSeed: raw.randomSeed, + result: raw.result, + txExecutionHash: raw.txExecutionHash, + txCalldata: raw.txCalldata, + eqBlocksOutputs: raw.eqBlocksOutputs, + messages: raw.messages, + queueType: raw.queueType, + queuePosition: raw.queuePosition, + activator: raw.activator, + lastLeader: raw.lastLeader, + status: raw.status, + txId: raw.txId, + readStateBlockRange: raw.readStateBlockRange, + numOfRounds: raw.numOfRounds, + lastRound: { + round: raw.lastRound.round, + leaderIndex: raw.lastRound.leaderIndex, + votesCommitted: raw.lastRound.votesCommitted, + votesRevealed: raw.lastRound.votesRevealed, + appealBond: raw.lastRound.appealBond, + rotationsLeft: raw.lastRound.rotationsLeft, + result: raw.lastRound.result, + validatorsCount: 0n, + }, + consumedValidatorsCount: 0n, + ...overrides, + }; +}; + +describe("train light transaction ABI", () => { + it("decodes the exact nested tuple from raw return bytes", () => { + const light = makeLightTx({ + messages: [{ + messageType: 1, + recipient: "0x0000000000000000000000000000000000000005", + value: 7n, + data: "0x1234", + onAcceptance: true, + saltNonce: 8n, + feeParams: "0xabcd", + declaredBudget: 9n, + allocationSubtree: "0x5678", + callKey: `0x${"11".repeat(32)}`, + useBalance: false, + }], + }); + const encoded = encodeFunctionResult({ + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getStoredTransactionDataLight", + result: light as any, + }); + const decoded = decodeFunctionResult({ + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getStoredTransactionDataLight", + data: encoded, + }) as any; + + expect(decoded.txId).toBe(light.txId); + expect(decoded.txExecutionHash).toBe(light.txExecutionHash); + expect(decoded.lastRound.validatorsCount).toBe(0n); + expect(decoded.messages[0]).toMatchObject({ + messageType: 1, + recipient: "0x0000000000000000000000000000000000000005", + declaredBudget: 9n, + useBalance: false, + }); + }); +}); + +const ADDRESS_MANAGER = "0x0000000000000000000000000000000000000011"; +const BIG_ROUNDS = "0x0000000000000000000000000000000000000012"; +const ROUNDS_STORAGE = "0x0000000000000000000000000000000000000013"; +const TRANSACTION_MANAGER = "0x0000000000000000000000000000000000000014"; + +const trainLifecycle = (overrides: Record = {}) => ({ + storedStatus: 5, + resolution: {projectedStatus: 5, action: 0}, + latestDecision: {}, + decisionActive: false, + ...overrides, +}); + +const trainReadContract = ({ + light = makeLightTx(), + lifecycle = trainLifecycle(), + canFinalize = false, + roundValidators = [] as string[], + consumedValidators = [] as string[], +}: { + light?: ReturnType; + lifecycle?: ReturnType; + canFinalize?: boolean; + roundValidators?: string[]; + consumedValidators?: string[]; +} = {}) => vi.fn().mockImplementation(async ({functionName, args}: any) => { + if (functionName === "addressManager") return ADDRESS_MANAGER; + if (functionName === "getAddress") { + if (args[0] === "ConsensusDataBigRounds") return BIG_ROUNDS; + if (args[0] === "RoundsStorage") return ROUNDS_STORAGE; + if (args[0] === "TransactionManager") return TRANSACTION_MANAGER; + } + if (functionName === "getStoredTransactionDataLight") return light; + if (functionName === "getTransactionLifecycle") return lifecycle; + if (functionName === "canFinalize") return [canFinalize, 456n, 400n]; + if (functionName === "getRoundValidatorsPaged") { + const offset = Number(args[2]); + const size = Number(args[3]); + return [roundValidators.slice(offset, offset + size), BigInt(roundValidators.length)]; + } + if (functionName === "getConsumedValidatorsPaged") { + const offset = Number(args[1]); + const size = Number(args[2]); + return [consumedValidators.slice(offset, offset + size), BigInt(consumedValidators.length)]; + } + if (functionName === "getValidatorVotes") return roundValidators.map(() => 1); + if (functionName === "getValidatorVotesHash") return roundValidators.map(() => `0x${"01".repeat(32)}`); + if (functionName === "getValidatorResultHash") return roundValidators.map(() => `0x${"02".repeat(32)}`); + if (functionName === "getTxExecutionResult") return 1; + if (functionName === "getNumOfInitialValidators") return 5n; + throw new Error(`Unexpected read: ${functionName}`); +}); + describe("getTriggeredTransactionIds", () => { it("finds child transaction IDs in the parent decision receipt", async () => { const parentHash = ("0x" + "11".repeat(32)) as any; @@ -373,10 +570,11 @@ describe("getTriggeredTransactionIds", () => { const internalMessageTopic = keccak256( stringToBytes("InternalMessageProcessed(bytes32,address,address)"), ); - const readContract = vi - .fn() - .mockResolvedValueOnce(makeRawTx({readStateBlockRange: {proposalBlock: 100n}})) - .mockResolvedValueOnce([{txExecutionResult: 1n}, []]); + const readContract = trainReadContract({ + light: makeLightTx({ + readStateBlockRange: {activationBlock: 0n, processingBlock: 0n, proposalBlock: 100n}, + }), + }); const getLogs = vi.fn().mockResolvedValue([{transactionHash: decisionHash}]); const getTransactionReceipt = vi.fn().mockResolvedValue({ logs: [ @@ -388,6 +586,7 @@ describe("getTriggeredTransactionIds", () => { }); const publicClient = { readContract, + getBlock: vi.fn().mockResolvedValue({number: 150n, timestamp: 1000n}), getBlockNumber: vi.fn().mockResolvedValue(200n), getLogs, getTransactionReceipt, @@ -411,44 +610,128 @@ describe("getTriggeredTransactionIds", () => { }); }); -describe("decodeTransaction", () => { - it("should decode standard field names (localnet/asimov)", () => { - const tx = makeRawTx(); - const decoded = decodeTransaction(tx); - expect(decoded.numOfInitialValidators).toBe("3"); - expect(decoded.txSlot).toBe("5"); - expect(decoded.statusName).toBe("ACCEPTED"); - expect(decoded.resultName).toBe("MAJORITY_AGREE"); - }); +describe("getTransaction train lifecycle", () => { + it("returns projected and stored state from one block snapshot", async () => { + const validators = Array.from( + {length: 65}, + (_, index) => `0x${(index + 1).toString(16).padStart(40, "0")}`, + ); + const consumed = validators.slice(0, 2); + const light = makeLightTx({ + status: 5, + lastRound: {...makeLightTx().lastRound, validatorsCount: 65n}, + consumedValidatorsCount: 2n, + }); + const readContract = trainReadContract({ + light, + lifecycle: trainLifecycle({ + storedStatus: 5, + resolution: {projectedStatus: 6, action: 6}, + latestDecision: {decisionId: 42n}, + decisionActive: true, + }), + canFinalize: true, + roundValidators: validators, + consumedValidators: consumed, + }); + const publicClient = { + readContract, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + } as any; + const client = { + chain: { + isStudio: false, + consensusDataContract: { + address: "0x0000000000000000000000000000000000000010", + abi: [], + }, + }, + } as any; - it("should handle Bradbury field: initialRotations instead of numOfInitialValidators", () => { - const tx = makeRawTx({ numOfInitialValidators: undefined }); - (tx as any).initialRotations = 5n; - const decoded = decodeTransaction(tx); - expect(decoded.numOfInitialValidators).toBe("5"); - }); + const transaction = await transactionActions(client, publicClient).getTransaction({ + hash: light.txId as any, + }); - it("should handle Bradbury field: txCalldata instead of txData", () => { - const tx = makeRawTx({ txData: undefined }); - (tx as any).txCalldata = "0xdeadbeef"; - const decoded = decodeTransaction(tx); - expect(decoded.txData).toBe("0xdeadbeef"); + expect(transaction.status).toBe(6); + expect(transaction.statusName).toBe(TransactionStatus.UNDETERMINED); + expect(transaction.storedStatus).toBe(5); + expect(transaction.storedStatusName).toBe(TransactionStatus.ACCEPTED); + expect(transaction.resolutionAction).toBe(6); + expect(transaction.resolutionActionName).toBe("FINALIZE"); + expect(transaction.canFinalize).toBe(true); + expect(transaction.lastRound?.roundValidators).toEqual(validators); + expect(transaction.lastRound?.validatorResultHash).toHaveLength(validators.length); + expect(transaction.consumedValidators).toEqual(consumed); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "getTransactionLifecycle", + args: [light.txId, 456n], + blockNumber: 123n, + }), + ); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "canFinalize", + args: [light.txId, 456n, 42n], + blockNumber: 123n, + }), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getTransactionAllData"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getStoredTransactionData"}), + ); }); - it("should handle both Bradbury fields missing (defaults gracefully)", () => { - const tx = makeRawTx({ numOfInitialValidators: undefined, txData: undefined }); - const decoded = decodeTransaction(tx); - expect(decoded.numOfInitialValidators).toBe("0"); - expect(decoded.txData).toBeUndefined(); + it("does not infer queue-head readiness from a FINALIZE action", async () => { + const light = makeLightTx(); + const readContract = trainReadContract({ + light, + lifecycle: trainLifecycle({ + storedStatus: 5, + resolution: {projectedStatus: 5, action: 6}, + latestDecision: {decisionId: 7n}, + decisionActive: true, + }), + canFinalize: false, + }); + const publicClient = { + readContract, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + } as any; + const client = { + chain: { + isStudio: false, + consensusDataContract: { + address: "0x0000000000000000000000000000000000000010", + abi: [], + }, + }, + } as any; + + const transaction = await transactionActions(client, publicClient).getTransaction({ + hash: light.txId as any, + }); + + expect(transaction.resolutionActionName).toBe("FINALIZE"); + expect(transaction.canFinalize).toBe(false); }); +}); - it("should prefer standard fields over Bradbury aliases when both present", () => { - const tx = makeRawTx({ numOfInitialValidators: 3n, txData: "0xaa" as any }); - (tx as any).initialRotations = 99n; - (tx as any).txCalldata = "0xbb"; +describe("decodeTransaction", () => { + it("decodes the canonical train field layout into stable public names", () => { + const tx = makeRawTx(); const decoded = decodeTransaction(tx); - expect(decoded.numOfInitialValidators).toBe("3"); - expect(decoded.txData).toBe("0xaa"); + expect(decoded.numOfInitialValidators).toBe("5"); + expect(decoded.initialRotations).toBe(3n); + expect(decoded.currentTimestamp).toBe("1000"); + expect(decoded.txData).toBe("0x"); + expect(decoded.txExecutionHash).toBe(tx.txExecutionHash); + expect(decoded.txReceipt).toBeUndefined(); + expect(decoded.txSlot).toBe("5"); + expect(decoded.statusName).toBe("ACCEPTED"); + expect(decoded.resultName).toBe("MAJORITY_AGREE"); }); it("should decode readStateBlockRange fields to strings", () => { @@ -467,8 +750,12 @@ describe("decodeTransaction", () => { it("should map validator votes to vote type names", () => { const decoded = decodeTransaction(makeRawTx()); - const names = (decoded.lastRound as any)?.validatorVotesName; - expect(names).toEqual(["AGREE", "AGREE", "AGREE"]); + const names = decoded.lastRound?.validatorVotesName; + expect(names).toEqual([ + "FINISHED_WITH_RETURN", + "FINISHED_WITH_RETURN", + "FINISHED_WITH_RETURN", + ]); }); }); From 04a0bdea1097fbb9a14052eab4ae26ad314e2bb1 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Thu, 27 Aug 2026 23:31:31 +0200 Subject: [PATCH 04/12] fix: route studio chains around the train lifecycle reads The train adaptation made the appeal and finalization actions read getTransactionLifecycle from ConsensusData unconditionally. Studio chains (localnet, studionet) run the studio-embedded consensus, which predates the train: that ConsensusData exposes no getTransactionLifecycle and no estimateLatestAppealCharge, so the read decodes a short tuple and viem raises "Position 63 is out of bounds (0 < position < 32)". This failed the studio suites of e2e run 33112369501, including 051_fee_lifecycle.feature ("submitAppeal records an appeal bond without expanding the primary fee budget"). Guard each affected action on client.chain.isStudio before any train read, mirroring transactionActions.getTransaction, and reinstate the pre-train call shape recovered from the v2-dev base: - appealTransaction encodes submitAppeal(bytes32) against the chain's own ConsensusMain ABI, with the caller's value defaulting to zero. The base resolved the value through FeeManager/RoundsStorage, both of which carry no address on studio, so it already degraded to zero there. - topUpAndSubmitAppeal encodes topUpAndSubmitAppeal(bytes32, FeesDistribution) without a decision id. - finalizeTransaction encodes finalizeTransaction(bytes32). - getAppealCharge and getMinAppealBond report the missing quote surface, which is the error the base already raised on studio. - resolveTransactions and finalizeDecisions are train-only additions with no base equivalent, so they report the gap and point at finalizeTransaction. canAppeal and the rounds-storage reads already returned their base error on studio: their missing-contract guards run before any train read, so they are left alone. finalizeIdlenessTxs keeps its migration error; studio's ConsensusMain ABI never carried that entrypoint, so the base did not support it there either. The train path is unchanged for non-studio chains. --- src/contracts/actions.ts | 126 ++++++++++++++++++- tests/contracts-actions.test.ts | 208 ++++++++++++++++++++++++++++++++ 2 files changed, 328 insertions(+), 6 deletions(-) diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index d1edcc8..ac6cff5 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -603,11 +603,17 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** Returns the full authoritative appeal charge (bond plus appeal funding). */ getAppealCharge: async (args: {txId: `0x${string}`}): Promise => { + if (client.chain.isStudio) { + throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); + } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, /** @deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. */ getMinAppealBond: async (args: {txId: `0x${string}`}): Promise => { + if (client.chain.isStudio) { + throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); + } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, @@ -803,6 +809,23 @@ export const contractActions = (client: GenLayerClient, publicCli value?: bigint; }) => { const {account, txId} = args; + const senderAccount = account || client.account; + + // Appeals don't go through _sendTransaction because submitAppeal emits + // AppealStarted/TransactionActivated events, not NewTransaction/CreatedTransaction. + // The appeal operates on the same GenLayer txId, so we return it directly. + if (client.chain.isStudio) { + await _sendConsensusCall({ + client, + publicClient, + encodedData: _encodeStudioSubmitAppealData({client, txId}), + senderAccount, + value: args.value ?? 0n, + operationName: "Appeal", + }); + return txId; + } + const context = await _readAppealContext({ client, publicClient, @@ -811,14 +834,10 @@ export const contractActions = (client: GenLayerClient, publicCli }); const value = args.value ?? context.requiredValue; - const senderAccount = account || client.account; const encodedData = _encodeSubmitAppealData({ txId, expectedDecisionId: context.decisionId, }); - // Appeals don't go through _sendTransaction because submitAppeal emits - // AppealStarted/TransactionActivated events, not NewTransaction/CreatedTransaction. - // The appeal operates on the same GenLayer txId, so we return it directly. await _sendConsensusCall({ client, publicClient, @@ -863,6 +882,20 @@ export const contractActions = (client: GenLayerClient, publicCli value?: bigint; }): Promise<`0x${string}`> => { const {account, txId, distribution} = args; + const senderAccount = account || client.account; + + if (client.chain.isStudio) { + await _sendConsensusCall({ + client, + publicClient, + encodedData: _encodeStudioTopUpAndSubmitAppealData({txId, distribution}), + senderAccount, + value: args.value ?? 0n, + operationName: "Top up and submit appeal", + }); + return txId; + } + const context = await _readAppealContext({ client, publicClient, @@ -871,7 +904,6 @@ export const contractActions = (client: GenLayerClient, publicCli }); const value = args.value ?? context.requiredValue; - const senderAccount = account || client.account; const encodedData = _encodeTopUpAndSubmitAppealData({ txId, expectedDecisionId: context.decisionId, @@ -893,11 +925,26 @@ export const contractActions = (client: GenLayerClient, publicCli txId: `0x${string}`; }): Promise<`0x${string}`> => { const {account, txId} = args; + const senderAccount = account || client.account; + + if (client.chain.isStudio) { + return _sendConsensusCall({ + client, + publicClient, + encodedData: encodeFunctionData({ + abi: client.chain.consensusMainContract?.abi as any, + functionName: "finalizeTransaction", + args: [txId], + }), + senderAccount, + operationName: "Finalize", + }); + } + const identity = await _readLifecycleIdentity({client, publicClient, txId}); if (!identity.decisionActive) { throw new Error(`Transaction ${txId} has no active decision to finalize`); } - const senderAccount = account || client.account; const encodedData = encodeFunctionData({ abi: CONSENSUS_FINALIZATION_TRAIN_ABI, functionName: "finalizeTransaction", @@ -930,6 +977,9 @@ export const contractActions = (client: GenLayerClient, publicCli account?: Account; txIds: readonly `0x${string}`[]; }): Promise<`0x${string}`> => { + if (client.chain.isStudio) { + throw _studioTrainBatchError("resolveTransactions"); + } if (args.txIds.length === 0) { throw new Error("resolveTransactions requires at least one txId."); } @@ -964,6 +1014,9 @@ export const contractActions = (client: GenLayerClient, publicCli account?: Account; txIds: readonly `0x${string}`[]; }): Promise<`0x${string}`> => { + if (client.chain.isStudio) { + throw _studioTrainBatchError("finalizeDecisions"); + } if (args.txIds.length === 0) { throw new Error("finalizeDecisions requires at least one txId."); } @@ -1094,6 +1147,23 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ }, ] as const; +/** + * Pre-train fee-management appeal entrypoint, kept for the studio-embedded + * consensus: it has no decision identity to bind the appeal to. + */ +const CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI = [ + { + type: "function", + name: "topUpAndSubmitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, +] as const; + const CONSENSUS_APPEAL_TRAIN_ABI = [ { type: "function", @@ -1995,6 +2065,50 @@ const _encodeSubmitAppealData = ({ }); }; +/** + * Studio chains run the studio-embedded consensus, which predates the + * resolution-kernel train: ConsensusData exposes no getTransactionLifecycle and + * no estimateLatestAppealCharge, and the chain config carries no fee manager, + * rounds storage, or appeals contract. Appeal and finalization calls therefore + * keep their pre-train, decision-free shape there. + */ +const STUDIO_APPEAL_QUOTE_UNSUPPORTED = + "Appeal bond calculation not supported on this chain (missing feeManagerContract/roundsStorageContract)"; + +const _studioTrainBatchError = (action: string): Error => + new Error( + `${action} not supported on this chain (studio consensus predates the resolution-kernel train): ` + + "use finalizeTransaction for a studio transaction.", + ); + +const _encodeStudioSubmitAppealData = ({ + client, + txId, +}: { + client: GenLayerClient; + txId: `0x${string}`; +}): `0x${string}` => { + return encodeFunctionData({ + abi: client.chain.consensusMainContract?.abi as any, + functionName: "submitAppeal", + args: [txId], + }); +}; + +const _encodeStudioTopUpAndSubmitAppealData = ({ + txId, + distribution, +}: { + txId: `0x${string}`; + distribution: FeesDistributionInput; +}): `0x${string}` => { + return encodeFunctionData({ + abi: CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI, + functionName: "topUpAndSubmitAppeal", + args: [txId, createFeesDistribution(distribution)], + }); +}; + const ROUND_PAGE_SIZE = 64n; const _unpackRoundValidatorPage = ( diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 6e155c1..3cd2f97 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -2414,3 +2414,211 @@ describe("contractActions train lifecycle batches", () => { }]); }); }); + +/** Pre-train entrypoints as the studio-embedded consensus exposes them. */ +const CONSENSUS_MAIN_STUDIO_ABI = [ + { + type: "function" as const, + name: "submitAppeal", + stateMutability: "payable" as const, + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, + { + type: "function" as const, + name: "finalizeTransaction", + stateMutability: "nonpayable" as const, + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, +] as const; + +const FEE_MANAGEMENT_STUDIO_ABI = [ + { + type: "function" as const, + name: "topUpAndSubmitAppeal", + stateMutability: "payable" as const, + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, +] as const; + +/** + * Mirrors the localnet/studionet chain config: ConsensusMain and ConsensusData + * resolve, but the fee manager, rounds storage, and appeals contracts carry no + * address, and ConsensusData answers no train lifecycle read. + */ +const setupStudioLifecycleHarness = () => { + const signTransaction = vi.fn().mockResolvedValue("0xsigned"); + const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); + const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: "success", logs: []}); + + const client = { + chain: { + id: 61_999, + isStudio: true, + consensusMainContract: { + address: MAIN_CONTRACT_ADDRESS, + abi: CONSENSUS_MAIN_STUDIO_ABI, + bytecode: "0x", + }, + consensusDataContract: { + address: CONSENSUS_DATA_ADDRESS, + abi: [], + bytecode: "0x", + }, + appealsContract: {address: undefined, abi: [], bytecode: "0x"}, + feeManagerContract: {address: undefined, abi: [], bytecode: "0x"}, + roundsStorageContract: {address: undefined, abi: [], bytecode: "0x"}, + }, + account: { + address: SENDER_ADDRESS, + type: "local", + signTransaction, + }, + getCurrentNonce: vi.fn().mockResolvedValue(0n), + estimateTransactionGas: vi.fn().mockResolvedValue(21_000n), + sendRawTransaction, + request: vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`Unexpected RPC method: ${method}`); + }), + }; + + // Any consensus read here is the regression: the pre-train deployment answers + // getTransactionLifecycle with a short tuple, which viem reports as + // "Position 63 is out of bounds (0 < position < 32)". + const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + throw new Error(`Unexpected consensus read on Studio: ${functionName}`); + }); + const publicClient = { + waitForTransactionReceipt, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + readContract, + }; + + return { + actions: contractActions(client as any, publicClient as any), + signTransaction, + sendRawTransaction, + waitForTransactionReceipt, + readContract, + client, + publicClient, + }; +}; + +describe("contractActions Studio lifecycle compatibility", () => { + it("encodes submitAppeal(bytes32) without reading the train lifecycle", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).resolves.toBe( + MOCK_GENLAYER_TX_ID, + ); + + expect(readContract).not.toHaveBeenCalled(); + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); + expect(txRequest.value).toBe(500n); + const decoded = decodeFunctionData({abi: CONSENSUS_MAIN_STUDIO_ABI, data: txRequest.data}); + expect(decoded.functionName).toBe("submitAppeal"); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + }); + + it("defaults the Studio appeal value to zero when the caller omits it", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + await actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID}); + + expect(readContract).not.toHaveBeenCalled(); + expect(signTransaction.mock.calls[0][0].value).toBe(0n); + }); + + it("encodes topUpAndSubmitAppeal without a decision id", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + const txId = await actions.topUpAndSubmitAppeal({ + txId: MOCK_GENLAYER_TX_ID, + value: 1234n, + distribution: {appealRounds: 1n, rotations: [0n, 1n]}, + }); + + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(readContract).not.toHaveBeenCalled(); + const txRequest = signTransaction.mock.calls[0][0]; + expect(txRequest.value).toBe(1234n); + const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_STUDIO_ABI as any, data: txRequest.data}); + const [decodedTxId, distribution] = decoded.args as any[]; + expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); + expect(distribution.appealRounds).toBe(1n); + expect(distribution.rotations).toEqual([0n, 1n]); + }); + + it("encodes finalizeTransaction(bytes32) without a decision id", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( + MOCK_EVM_TX_HASH, + ); + + expect(readContract).not.toHaveBeenCalled(); + const decoded = decodeFunctionData({ + abi: CONSENSUS_MAIN_STUDIO_ABI, + data: signTransaction.mock.calls[0][0].data, + }); + expect(decoded.functionName).toBe("finalizeTransaction"); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + }); + + it("reports the missing appeal quote surface instead of decoding a train lifecycle", async () => { + const {actions, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing feeManagerContract\/roundsStorageContract/, + ); + await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing feeManagerContract\/roundsStorageContract/, + ); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("reports the missing appeals contract from canAppeal", async () => { + const {actions, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing appealsContract/, + ); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("rejects the train-only batch actions with an actionable message and never broadcasts", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.resolveTransactions({txIds: [MOCK_GENLAYER_TX_ID]})).rejects.toThrow( + /resolveTransactions not supported on this chain.*finalizeTransaction/s, + ); + await expect(actions.finalizeDecisions({txIds: [MOCK_GENLAYER_TX_ID]})).rejects.toThrow( + /finalizeDecisions not supported on this chain.*finalizeTransaction/s, + ); + expect(readContract).not.toHaveBeenCalled(); + expect(signTransaction).not.toHaveBeenCalled(); + }); + + it("still threads the decision id through the train path on non-Studio chains", async () => { + const {actions, signTransaction, publicClient} = setupFeeManagementHarness(); + + await actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID}); + + expect(publicClient.readContract).toHaveBeenCalledWith( + expect.objectContaining({functionName: "getTransactionLifecycle"}), + ); + const decoded = decodeFunctionData({ + abi: APPEAL_TRAIN_ABI, + data: signTransaction.mock.calls[0][0].data, + }); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + }); +}); From c3389faf69df1d3324a08120a1298bc7297b8981 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 23:30:55 +0100 Subject: [PATCH 05/12] feat(transactions)!: layer consumer and protocol lifecycles Keep the default transaction model on materialized state with a small discriminated lifecycle and stored-state wait helpers. Move projection, resolution source/action, and active decision identity behind the advanced namespace using one normalized schema on both Studio and contract networks.\n\nRemove ReadyToFinalize and duplicate canFinalize exposure. Finalize remains a protocol action.\n\nValidation: npm test -- --run; npm run lint; npm run build; npm run docs; git diff --check. --- README.md | 26 ++- docs/api-references/index.md | 26 ++- docs/api-references/transactions.md | 50 ++++- src/abi/consensusTrain.ts | 1 - src/transactions/actions.ts | 254 ++++++++++++++++++++------ src/transactions/decoders.ts | 3 +- src/types/clients.ts | 19 ++ src/types/transactions.ts | 221 +++++++++++++++++++--- tests/client.test-d.ts | 49 +++++ tests/transactions.test.ts | 273 ++++++++++++++++++++++++---- 10 files changed, 787 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index aafb63f..3b52867 100644 --- a/README.md +++ b/README.md @@ -267,11 +267,10 @@ transaction hash; on Studio/localnet it is the target GenLayer transaction id. A transaction can be finalized by consensus but still have a failed execution. Always check `txExecutionResult` before reading contract state: ```typescript -import { ExecutionResult, TransactionStatus } from "genlayer-js/types"; +import { ExecutionResult } from "genlayer-js/types"; -const receipt = await client.waitForTransactionReceipt({ +const receipt = await client.waitForFinalization({ hash: txHash, - status: TransactionStatus.FINALIZED, }); if (receipt.txExecutionResultName === ExecutionResult.FINISHED_WITH_RETURN) { @@ -297,12 +296,21 @@ Transactions can emit messages to other contracts. These messages create new chi ```typescript const tx = await client.getTransaction({ hash: txHash }); -// Projected lifecycle state, exact stored state, and queue-head readiness are -// intentionally separate. -console.log(tx.statusName); -console.log(tx.storedStatusName); -console.log(tx.resolutionActionName); -console.log(tx.canFinalize); +// The default model is derived from the exact stored state. Processing entries +// have a phase; decided entries have an outcome. +console.log(tx.lifecycle); +// {state: "processing", phase: "revealing"} +// {state: "decided", outcome: "accepted"} + +// Advanced protocol consumers can explicitly request timestamp projection and +// the exact resolution action/source. The same shape works on Studio and train. +const protocolLifecycle = await client.advanced.getTransactionLifecycle({ hash: txHash }); +console.log(protocolLifecycle.storedStatus); +console.log(protocolLifecycle.projectedStatus); +console.log(protocolLifecycle.resolutionSource); +if (protocolLifecycle.resolutionAction === "Finalize") { + // Finalize is the protocol's current action/capability, not a transaction status. +} // The train retains the authoritative execution hash, not the old receipt // bytes. `txReceipt` is therefore unavailable on train transactions. diff --git a/docs/api-references/index.md b/docs/api-references/index.md index 753e632..0f3a53a 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -263,11 +263,10 @@ transaction hash; on Studio/localnet it is the target GenLayer transaction id. A transaction can be finalized by consensus but still have a failed execution. Always check `txExecutionResult` before reading contract state: ```typescript -import { ExecutionResult, TransactionStatus } from "genlayer-js/types"; +import { ExecutionResult } from "genlayer-js/types"; -const receipt = await client.waitForTransactionReceipt({ +const receipt = await client.waitForFinalization({ hash: txHash, - status: TransactionStatus.FINALIZED, }); if (receipt.txExecutionResultName === ExecutionResult.FINISHED_WITH_RETURN) { @@ -293,12 +292,21 @@ Transactions can emit messages to other contracts. These messages create new chi ```typescript const tx = await client.getTransaction({ hash: txHash }); -// Projected lifecycle state, exact stored state, and queue-head readiness are -// intentionally separate. -console.log(tx.statusName); -console.log(tx.storedStatusName); -console.log(tx.resolutionActionName); -console.log(tx.canFinalize); +// The default model is derived from the exact stored state. Processing entries +// have a phase; decided entries have an outcome. +console.log(tx.lifecycle); +// {state: "processing", phase: "revealing"} +// {state: "decided", outcome: "accepted"} + +// Advanced protocol consumers can explicitly request timestamp projection and +// the exact resolution action/source. The same shape works on Studio and train. +const protocolLifecycle = await client.advanced.getTransactionLifecycle({ hash: txHash }); +console.log(protocolLifecycle.storedStatus); +console.log(protocolLifecycle.projectedStatus); +console.log(protocolLifecycle.resolutionSource); +if (protocolLifecycle.resolutionAction === "Finalize") { + // Finalize is the protocol's current action/capability, not a transaction status. +} // The train retains the authoritative execution hash, not the old receipt // bytes. `txReceipt` is therefore unavailable on train transactions. diff --git a/docs/api-references/transactions.md b/docs/api-references/transactions.md index 70ba31a..a197734 100644 --- a/docs/api-references/transactions.md +++ b/docs/api-references/transactions.md @@ -19,10 +19,56 @@ Polls until a transaction reaches the specified status. Returns the transaction --- +### waitForDecision + +Polls until the stored transaction state contains a materialized decision. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | +| interval | `number` | no | | +| retries | `number` | no | | +| fullTransaction | `boolean` | no | | + +**Returns:** `GenLayerTransaction` + +--- + +### waitForFinalization + +Polls until the stored transaction state is finalized. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | +| interval | `number` | no | | +| retries | `number` | no | | +| fullTransaction | `boolean` | no | | + +**Returns:** `GenLayerTransaction` + +--- + +### getTransactionLifecycle + +`advanced.getTransactionLifecycle` exposes stored/projected status, +resolution action/source, and active decision identity. Studio uses the +node RPC; contract networks use one fixed-block lifecycle read. `Finalize` +is an action, not a status or separate readiness field. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | +| timestamp | `number` | no | | + +**Returns:** `TransactionProtocolLifecycle` + +--- + ### getTransaction -Fetches the train transaction snapshot, including projected/stored status, -resolution action, authoritative finalization readiness, and split round data. +Fetches a transaction with a simple stored lifecycle and split round data. +Use advanced.getTransactionLifecycle for protocol projection/action details. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| diff --git a/src/abi/consensusTrain.ts b/src/abi/consensusTrain.ts index 55f363c..38b6a95 100644 --- a/src/abi/consensusTrain.ts +++ b/src/abi/consensusTrain.ts @@ -8,7 +8,6 @@ const parseTrainAbi = parseAbi as (signatures: readonly string[]) => Abi; export const CONSENSUS_DATA_TRAIN_ABI = parseTrainAbi([ "function addressManager() view returns (address)", "function getTransactionLifecycle(bytes32 _txId, uint256 _timestamp) view returns ((uint8 storedStatus, (bytes32 txId, uint8 storedStatus, uint8 projectedStatus, uint8 action, uint8 result, bytes32 resultHash, uint8 source, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint256 basisDecisionId, uint8 context, bytes32 attemptId, uint256 boundaryAt, uint256 evaluatedAt, uint256 snapshotBlock, uint256 decisionWindow, uint256 appealDeadline, bool materializesDecision, bool actionOutcomeDeterministic, bool nonCurrentEvaluation) resolution, (bool exists, uint256 decisionId, uint256 basisDecisionId, uint8 context, uint8 source, bytes32 sourceAttemptId, uint8 sourceStatus, uint8 status, uint256 sourceRound, uint256 sourceGeneration, bytes32 sourceRoundContextHash, bytes32 roundPlanHash, uint256 resultRound, uint256 resultGeneration, uint8 result, bytes32 resultHash, uint256 effectiveAt, uint256 materializedAt, uint256 appealDeadline) latestDecision, bool decisionActive) lifecycle)", - "function canFinalize(bytes32 _txId, uint256 _currentTimestamp, uint256 _expectedDecisionId) view returns (bool ready, uint256 evaluatedAt, uint256 appealDeadline)", "function estimateLatestAppealCharge(bytes32 _txId) view returns (uint256 decisionId, uint256 bond, uint256 funding, uint256 appealDeadline)", ]); diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 83d40ed..13958e3 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -11,7 +11,11 @@ import { isDecidedState, DebugTraceResult, TransactionReceiptWaitUntil, + TransactionProtocolLifecycle, transactionResolutionActionNumberToName, + transactionProtocolStatusNumberToName, + transactionResolutionSourceNumberToName, + transactionLifecycleFromStoredStatus, } from "../types/transactions"; import {transactionsConfig} from "../config/transactions"; import {sleep} from "../utils/async"; @@ -28,6 +32,85 @@ import { const TRANSACTION_PAGE_SIZE = 64n; +type RawProtocolLifecycle = { + storedStatusCode: unknown; + projectedStatusCode: unknown; + resolutionActionCode: unknown; + resolutionSourceCode: unknown; + decisionId: unknown; + decisionActive: unknown; + evaluatedAt: unknown; +}; + +const protocolInteger = (value: unknown, label: string): number => { + if (value === null || value === undefined || value === "") { + throw new Error(`Missing protocol lifecycle ${label}`); + } + const numeric = Number(value); + if (!Number.isSafeInteger(numeric) || numeric < 0) { + throw new Error(`Invalid protocol lifecycle ${label}: ${String(value)}`); + } + return numeric; +}; + +const protocolName = ( + names: Record, + code: number, + label: string, +): T => { + const name = names[String(code)]; + if (!name) throw new Error(`Unknown protocol lifecycle ${label}: ${code}`); + return name; +}; + +const protocolDecisionId = (value: unknown, active: boolean): string | null => { + if (!active) return null; + if (typeof value === "number" && !Number.isSafeInteger(value)) { + throw new Error(`Invalid protocol lifecycle decisionId: ${String(value)}`); + } + const decimal = String(value); + if (!/^\d+$/.test(decimal)) { + throw new Error(`Invalid protocol lifecycle decisionId: ${decimal}`); + } + return BigInt(decimal).toString(); +}; + +const normalizeProtocolLifecycle = (raw: RawProtocolLifecycle): TransactionProtocolLifecycle => { + const storedStatusCode = protocolInteger(raw.storedStatusCode, "storedStatusCode"); + const projectedStatusCode = protocolInteger(raw.projectedStatusCode, "projectedStatusCode"); + const resolutionActionCode = protocolInteger(raw.resolutionActionCode, "resolutionActionCode"); + const resolutionSourceCode = protocolInteger(raw.resolutionSourceCode, "resolutionSourceCode"); + if (typeof raw.decisionActive !== "boolean") { + throw new Error(`Invalid protocol lifecycle decisionActive: ${String(raw.decisionActive)}`); + } + + return { + storedStatus: protocolName(transactionProtocolStatusNumberToName, storedStatusCode, "storedStatusCode"), + storedStatusCode, + projectedStatus: protocolName( + transactionProtocolStatusNumberToName, + projectedStatusCode, + "projectedStatusCode", + ), + projectedStatusCode, + resolutionAction: protocolName( + transactionResolutionActionNumberToName, + resolutionActionCode, + "resolutionActionCode", + ), + resolutionActionCode, + resolutionSource: protocolName( + transactionResolutionSourceNumberToName, + resolutionSourceCode, + "resolutionSourceCode", + ), + resolutionSourceCode, + decisionId: protocolDecisionId(raw.decisionId, raw.decisionActive), + decisionActive: raw.decisionActive, + evaluatedAt: protocolInteger(raw.evaluatedAt, "evaluatedAt"), + }; +}; + const resolvedAddress = (name: string, address: Address): Address => { if (address === zeroAddress) { throw new Error(`${name} is not registered in AddressManager`); @@ -91,9 +174,20 @@ const resolveWaitTarget = ( }; const hasReachedWaitTarget = ( - transactionStatusString: string, + transaction: GenLayerTransaction, target: ReturnType, ): boolean => { + const storedStatusName = transaction.statusName ?? ( + typeof transaction.status === "number" || /^\d+$/.test(String(transaction.status)) + ? transactionsStatusNumberToName[ + String(transaction.status) as keyof typeof transactionsStatusNumberToName + ] + : transaction.status as TransactionStatus | undefined + ); + const transactionStatusString = storedStatusName + ? transactionsStatusNameToNumber[storedStatusName] + : String(transaction.status); + if (target.waitUntil === "decided") { return isDecidedState(transactionStatusString); } @@ -151,7 +245,7 @@ export const receiptActions = (client: GenLayerClient, publicClie throw new Error(`Transaction not found: ${hash}`); } const transactionStatusString = String(transaction.status); - if (hasReachedWaitTarget(transactionStatusString, target)) { + if (hasReachedWaitTarget(transaction, target)) { let finalTransaction = transaction; if (client.chain.isStudio) { finalTransaction = decodeLocalnetTransaction(transaction as unknown as GenLayerTransaction); @@ -176,12 +270,100 @@ export const receiptActions = (client: GenLayerClient, publicClie fullTransaction, }); }, + /** Polls until the stored transaction state contains a materialized decision. */ + waitForDecision: async ({ + hash, + interval, + retries, + fullTransaction, + }: { + hash: TransactionHash; + interval?: number; + retries?: number; + fullTransaction?: boolean; + }): Promise => receiptActions(client, publicClient).waitForTransactionReceipt({ + hash, + waitUntil: "decided", + interval, + retries, + fullTransaction, + }), + /** Polls until the stored transaction state is finalized. */ + waitForFinalization: async ({ + hash, + interval, + retries, + fullTransaction, + }: { + hash: TransactionHash; + interval?: number; + retries?: number; + fullTransaction?: boolean; + }): Promise => receiptActions(client, publicClient).waitForTransactionReceipt({ + hash, + waitUntil: "finalized", + interval, + retries, + fullTransaction, + }), }); export const transactionActions = (client: GenLayerClient, publicClient: PublicClient) => ({ + advanced: { + /** + * `advanced.getTransactionLifecycle` exposes stored/projected status, + * resolution action/source, and active decision identity. Studio uses the + * node RPC; contract networks use one fixed-block lifecycle read. `Finalize` + * is an action, not a status or separate readiness field. + */ + getTransactionLifecycle: async ({ + hash, + timestamp, + }: { + hash: TransactionHash; + timestamp?: number; + }): Promise => { + if (client.chain.isStudio) { + const raw = await client.request({ + method: "gen_getTransactionLifecycle", + params: [{txId: hash, ...(timestamp === undefined ? {} : { + timestamp: protocolInteger(timestamp, "timestamp"), + })}], + }) as RawProtocolLifecycle; + return normalizeProtocolLifecycle(raw); + } + const consensusDataAddress = client.chain.consensusDataContract?.address as Address; + if (!consensusDataAddress || consensusDataAddress === zeroAddress) { + throw new Error("ConsensusData contract is not configured for this chain"); + } + + const snapshot = await publicClient.getBlock(); + const blockNumber = snapshot.number; + const evaluatedAt = timestamp === undefined + ? protocolInteger(snapshot.timestamp, "block timestamp") + : protocolInteger(timestamp, "timestamp"); + const lifecycle = await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "getTransactionLifecycle", + args: [hash, BigInt(evaluatedAt)], + blockNumber, + }) as any; + + return normalizeProtocolLifecycle({ + storedStatusCode: lifecycle.storedStatus, + projectedStatusCode: lifecycle.resolution.projectedStatus, + resolutionActionCode: lifecycle.resolution.action, + resolutionSourceCode: lifecycle.resolution.source, + decisionId: lifecycle.latestDecision.decisionId, + decisionActive: lifecycle.decisionActive, + evaluatedAt: lifecycle.resolution.evaluatedAt, + }); + }, + }, /** - * Fetches the train transaction snapshot, including projected/stored status, - * resolution action, authoritative finalization readiness, and split round data. + * Fetches a transaction with a simple stored lifecycle and split round data. + * Use advanced.getTransactionLifecycle for protocol projection/action details. */ getTransaction: async ({hash}: {hash: TransactionHash}): Promise => { if (client.chain.isStudio) { @@ -191,6 +373,10 @@ export const transactionActions = (client: GenLayerClient, public transaction.status = Number(transactionsStatusNameToNumber[localnetStatus as TransactionStatus]); transaction.statusName = localnetStatus as TransactionStatus; + transaction.lifecycle = transactionLifecycleFromStoredStatus( + localnetStatus as TransactionStatus, + transaction.result, + ); return decodeLocalnetTransaction(transaction as unknown as GenLayerTransaction); } const consensusDataAddress = client.chain.consensusDataContract?.address as Address; @@ -198,12 +384,10 @@ export const transactionActions = (client: GenLayerClient, public throw new Error("ConsensusData contract is not configured for this chain"); } - // Freeze every lifecycle and round read to one chain snapshot. A local - // wall-clock projection mixed with moving latest-state reads can report a - // status and finalization action that never coexisted. + // Freeze every transaction and round component to one chain snapshot so + // the assembled public transaction never mixes different stored states. const snapshot = await publicClient.getBlock(); const blockNumber = snapshot.number; - const observedAt = snapshot.timestamp; const addressManagerAddress = resolvedAddress( "AddressManager", await publicClient.readContract({ @@ -241,22 +425,13 @@ export const transactionActions = (client: GenLayerClient, public const roundsStorageAddress = resolvedAddress("RoundsStorage", roundsStorageAddressRaw); const transactionManagerAddress = resolvedAddress("TransactionManager", transactionManagerAddressRaw); - const [txData, lifecycle] = await Promise.all([ - publicClient.readContract({ - address: bigRoundsAddress, - abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, - functionName: "getStoredTransactionDataLight", - args: [hash], - blockNumber, - }), - publicClient.readContract({ - address: consensusDataAddress, - abi: CONSENSUS_DATA_TRAIN_ABI, - functionName: "getTransactionLifecycle", - args: [hash, observedAt], - blockNumber, - }), - ]) as [any, any]; + const txData = await publicClient.readContract({ + address: bigRoundsAddress, + abi: CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, + functionName: "getStoredTransactionDataLight", + args: [hash], + blockNumber, + }) as any; const round = txData.lastRound.round; const [roundValidators, consumedValidators, validatorVotes, validatorVotesHash, validatorResultHash, txExecutionResult, numOfInitialValidators] = @@ -312,25 +487,9 @@ export const transactionActions = (client: GenLayerClient, public }), ]) as [Address[], Address[], readonly number[], readonly `0x${string}`[], readonly `0x${string}`[], number, bigint]; - const projectedStatus = Number(lifecycle.resolution.projectedStatus); - const storedStatus = Number(lifecycle.storedStatus); - const resolutionAction = Number(lifecycle.resolution.action); - const decisionId = lifecycle.decisionActive - ? BigInt(lifecycle.latestDecision.decisionId) - : 0n; - const finalizeRead = await publicClient.readContract({ - address: consensusDataAddress, - abi: CONSENSUS_DATA_TRAIN_ABI, - functionName: "canFinalize", - args: [hash, observedAt, decisionId], - blockNumber, - }) as any; - const canFinalize = Boolean(finalizeRead.ready ?? finalizeRead[0]); - const transaction = { ...txData, numOfInitialValidators, - status: projectedStatus, txExecutionResult: Number(txExecutionResult), consumedValidators, lastRound: { @@ -341,20 +500,7 @@ export const transactionActions = (client: GenLayerClient, public validatorResultHash: [...validatorResultHash], }, } as unknown as GenLayerRawTransaction; - const decoded = decodeTransaction(transaction); - const resolutionActionName = - transactionResolutionActionNumberToName[ - String(resolutionAction) as keyof typeof transactionResolutionActionNumberToName - ]; - return { - ...decoded, - storedStatus, - storedStatusName: - transactionsStatusNumberToName[String(storedStatus) as keyof typeof transactionsStatusNumberToName], - resolutionAction, - resolutionActionName, - canFinalize, - }; + return decodeTransaction(transaction); }, /** Returns transaction IDs of child transactions created from emitted messages. */ getTriggeredTransactionIds: async ({hash}: {hash: TransactionHash}): Promise => { diff --git a/src/transactions/decoders.ts b/src/transactions/decoders.ts index 9fb6350..33e799f 100644 --- a/src/transactions/decoders.ts +++ b/src/transactions/decoders.ts @@ -1,4 +1,4 @@ -import {GenLayerTransaction, GenLayerRawTransaction, DecodedCallData, DecodedDeployData,transactionsStatusNumberToName, transactionResultNumberToName, executionResultNumberToName, voteTypeNumberToName, VoteType} from "../types/transactions"; +import {GenLayerTransaction, GenLayerRawTransaction, DecodedCallData, DecodedDeployData,transactionsStatusNumberToName, transactionResultNumberToName, executionResultNumberToName, voteTypeNumberToName, VoteType, transactionLifecycleFromStoredStatus} from "../types/transactions"; import {b64ToArray, calldataToUserFriendlyJson, resultToUserFriendlyJson} from "../utils/jsonifier"; import {fromRlp, fromHex, Hex, Address} from "viem"; import * as calldataAbi from "../abi/calldata"; @@ -100,6 +100,7 @@ export const decodeTransaction = (tx: GenLayerRawTransaction): GenLayerTransacti statusName: transactionsStatusNumberToName[String(tx.status) as keyof typeof transactionsStatusNumberToName], + lifecycle: transactionLifecycleFromStoredStatus(tx.status, tx.result), resultName: transactionResultNumberToName[String(tx.result) as keyof typeof transactionResultNumberToName], diff --git a/src/types/clients.ts b/src/types/clients.ts index 56211e6..d1663f1 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -17,6 +17,8 @@ import { SimulateWriteContractResult, ConsensusRoundData, ConsensusLastRoundData, + TransactionProtocolLifecycle, + TransactionProtocolLifecycleArgs, } from "./transactions"; import {GenLayerChain} from "./chains"; import {Address, Account} from "./accounts"; @@ -36,6 +38,7 @@ export type GenLayerMethod = | {method: "gen_getContractSchema"; params: [address: Address] | [{address: Address}] | [{code: string}]} | {method: "gen_getContractSchemaForCode"; params: [contractCode: string]} | {method: "gen_getContractCode"; params: [address: Address] | [{address: Address}]} + | {method: "gen_getTransactionLifecycle"; params: [{txId: TransactionHash; timestamp?: number}]} | {method: "sim_getTransactionsForAddress"; params: [address: Address, filter?: "all" | "from" | "to"]} | {method: "eth_getTransactionCount"; params: [address: Address, block: string]} | {method: "eth_estimateGas"; params: [transactionParams: any]} @@ -115,6 +118,10 @@ export type GenLayerClient = Omit< fees?: TransactionFeeOptions; }) => Promise<`0x${string}`>; getTransaction: (args: {hash: TransactionHash}) => Promise; + advanced: { + /** Protocol lifecycle read with projection and resolution action details. */ + getTransactionLifecycle: (args: TransactionProtocolLifecycleArgs) => Promise; + }; getCurrentNonce: (args: {address: Address}) => Promise; transfer: (args: {to: Address; value: bigint}) => Promise; estimateTransactionGas: (transactionParams: { @@ -132,6 +139,18 @@ export type GenLayerClient = Omit< retries?: number; fullTransaction?: boolean; }) => Promise; + waitForDecision: (args: { + hash: TransactionHash; + interval?: number; + retries?: number; + fullTransaction?: boolean; + }) => Promise; + waitForFinalization: (args: { + hash: TransactionHash; + interval?: number; + retries?: number; + fullTransaction?: boolean; + }) => Promise; getContractSchema: (address: Address) => Promise; getContractSchemaForCode: (contractCode: string | Uint8Array) => Promise; getContractCode: (address: Address) => Promise; diff --git a/src/types/transactions.ts b/src/types/transactions.ts index abd6d52..99e81db 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -24,13 +24,13 @@ export enum TransactionStatus { } export enum TransactionResolutionAction { - NO_OP = "NO_OP", - CANCEL = "CANCEL", - REPLACE_ACTOR = "REPLACE_ACTOR", - ROTATE_LEADER = "ROTATE_LEADER", - RESOLVE_APPEAL = "RESOLVE_APPEAL", - MATERIALIZE_DECISION = "MATERIALIZE_DECISION", - FINALIZE = "FINALIZE", + NO_OP = "NoOp", + CANCEL = "Cancel", + REPLACE_ACTOR = "ReplaceActor", + ROTATE_LEADER = "RotateLeader", + RESOLVE_APPEAL = "ResolveAppeal", + MATERIALIZE_DECISION = "MaterializeDecision", + FINALIZE = "Finalize", } export const transactionResolutionActionNumberToName = { @@ -43,6 +43,72 @@ export const transactionResolutionActionNumberToName = { "6": TransactionResolutionAction.FINALIZE, }; +/** Exact protocol status names used by the advanced lifecycle RPC. */ +export enum TransactionProtocolStatus { + UNINITIALIZED = "Uninitialized", + PENDING = "Pending", + PROPOSING = "Proposing", + COMMITTING = "Committing", + REVEALING = "Revealing", + ACCEPTED = "Accepted", + UNDETERMINED = "Undetermined", + FINALIZED = "Finalized", + CANCELED = "Canceled", + APPEAL_REVEALING = "AppealRevealing", + APPEAL_COMMITTING = "AppealCommitting", + VALIDATORS_TIMEOUT = "ValidatorsTimeout", + LEADER_TIMEOUT = "LeaderTimeout", + LEADER_REVEALING = "LeaderRevealing", +} + +export const transactionProtocolStatusNumberToName = { + "0": TransactionProtocolStatus.UNINITIALIZED, + "1": TransactionProtocolStatus.PENDING, + "2": TransactionProtocolStatus.PROPOSING, + "3": TransactionProtocolStatus.COMMITTING, + "4": TransactionProtocolStatus.REVEALING, + "5": TransactionProtocolStatus.ACCEPTED, + "6": TransactionProtocolStatus.UNDETERMINED, + "7": TransactionProtocolStatus.FINALIZED, + "8": TransactionProtocolStatus.CANCELED, + "9": TransactionProtocolStatus.APPEAL_REVEALING, + "10": TransactionProtocolStatus.APPEAL_COMMITTING, + "11": TransactionProtocolStatus.VALIDATORS_TIMEOUT, + "12": TransactionProtocolStatus.LEADER_TIMEOUT, + "13": TransactionProtocolStatus.LEADER_REVEALING, +}; + +/** Exact resolution-source names used by the advanced lifecycle RPC. */ +export enum TransactionResolutionSource { + UNSPECIFIED = "Unspecified", + ACTIVATION_INSUFFICIENT_VALIDATORS = "ActivationInsufficientValidators", + PROPOSAL_HANGING = "ProposalHanging", + LEADER_RECEIPT_TIMEOUT = "LeaderReceiptTimeout", + COMMIT_HANGING = "CommitHanging", + LEADER_REVEAL_HANGING = "LeaderRevealHanging", + FULL_REVEAL = "FullReveal", + REVEAL_DEADLINE = "RevealDeadline", + APPEAL_COMMIT_HANGING = "AppealCommitHanging", + APPEAL_FULL_REVEAL = "AppealFullReveal", + APPEAL_REVEAL_DEADLINE = "AppealRevealDeadline", + SELECTION_DEPLETED = "SelectionDepleted", +} + +export const transactionResolutionSourceNumberToName = { + "0": TransactionResolutionSource.UNSPECIFIED, + "1": TransactionResolutionSource.ACTIVATION_INSUFFICIENT_VALIDATORS, + "2": TransactionResolutionSource.PROPOSAL_HANGING, + "3": TransactionResolutionSource.LEADER_RECEIPT_TIMEOUT, + "4": TransactionResolutionSource.COMMIT_HANGING, + "5": TransactionResolutionSource.LEADER_REVEAL_HANGING, + "6": TransactionResolutionSource.FULL_REVEAL, + "7": TransactionResolutionSource.REVEAL_DEADLINE, + "8": TransactionResolutionSource.APPEAL_COMMIT_HANGING, + "9": TransactionResolutionSource.APPEAL_FULL_REVEAL, + "10": TransactionResolutionSource.APPEAL_REVEAL_DEADLINE, + "11": TransactionResolutionSource.SELECTION_DEPLETED, +}; + export enum TransactionResult { SUCCESS = "SUCCESS", FAILURE = "FAILURE", @@ -60,8 +126,8 @@ export enum TransactionResult { MAJORITY_TIMEOUT = "MAJORITY_TIMEOUT", } -// ReadyToFinalize is no longer a transaction status. Readiness is exposed by -// the resolution kernel as ResolutionAction.Finalize / `canFinalize`. +// ReadyToFinalize is no longer a transaction status. Advanced consumers derive +// the current finalization capability from resolutionAction === "Finalize". export const transactionsStatusNumberToName = { "0": TransactionStatus.UNINITIALIZED, "1": TransactionStatus.PENDING, @@ -183,6 +249,126 @@ export enum TransactionHashVariant { export type TransactionReceiptWaitUntil = "decided" | "finalized"; +export type TransactionProcessingPhase = + | "uninitialized" + | "pending" + | "proposing" + | "committing" + | "revealing" + | "appeal-revealing" + | "appeal-committing" + | "leader-revealing"; + +export type TransactionDecisionOutcome = + | "accepted" + | "undetermined" + | "validators-timeout" + | "leader-timeout"; + +/** Consumer-oriented lifecycle derived only from the persisted transaction state. */ +export type TransactionLifecycle = + | {state: "processing"; phase: TransactionProcessingPhase} + | {state: "decided"; outcome: TransactionDecisionOutcome} + /** Outcome is omitted when the retained result cannot prove the pre-finalized decision. */ + | {state: "finalized"; outcome?: TransactionDecisionOutcome} + | {state: "canceled"}; + +/** + * Arguments for the advanced protocol lifecycle read. `timestamp` is a Unix + * timestamp; omitting it evaluates the lifecycle at the node or block time. + */ +export type TransactionProtocolLifecycleArgs = { + hash: TransactionHash; + timestamp?: number; +}; + +/** + * Advanced protocol lifecycle normalized across Studio and contract networks. + * `resolutionAction === "Finalize"` is the protocol's finalization capability; + * finalization is not a transaction status or a separate readiness flag. + */ +export type TransactionProtocolLifecycle = { + storedStatus: TransactionProtocolStatus; + storedStatusCode: number; + projectedStatus: TransactionProtocolStatus; + projectedStatusCode: number; + resolutionAction: TransactionResolutionAction; + resolutionActionCode: number; + resolutionSource: TransactionResolutionSource; + resolutionSourceCode: number; + decisionId: string | null; + decisionActive: boolean; + evaluatedAt: number; +}; + +const finalizedOutcomeFromResult = ( + result: TransactionResult | number | undefined, +): TransactionDecisionOutcome | undefined => { + const resultName = typeof result === "number" + ? transactionResultNumberToName[String(result) as keyof typeof transactionResultNumberToName] + : result; + + switch (resultName) { + case TransactionResult.MAJORITY_AGREE: + return "accepted"; + case TransactionResult.MAJORITY_TIMEOUT: + return "validators-timeout"; + case TransactionResult.MAJORITY_DISAGREE: + case TransactionResult.DETERMINISTIC_VIOLATION: + case TransactionResult.NO_MAJORITY: + return "undetermined"; + default: + // IDLE does not distinguish a finalized leader timeout from every other + // result-less path, so the SDK must not invent an outcome for it. + return undefined; + } +}; + +/** Maps the exact stored protocol status to the non-projecting public lifecycle. */ +export const transactionLifecycleFromStoredStatus = ( + status: TransactionStatus | number, + result?: TransactionResult | number, +): TransactionLifecycle => { + const statusName = typeof status === "number" + ? transactionsStatusNumberToName[String(status) as keyof typeof transactionsStatusNumberToName] + : status; + + switch (statusName) { + case TransactionStatus.UNINITIALIZED: + return {state: "processing", phase: "uninitialized"}; + case TransactionStatus.PENDING: + return {state: "processing", phase: "pending"}; + case TransactionStatus.PROPOSING: + return {state: "processing", phase: "proposing"}; + case TransactionStatus.COMMITTING: + return {state: "processing", phase: "committing"}; + case TransactionStatus.REVEALING: + return {state: "processing", phase: "revealing"}; + case TransactionStatus.APPEAL_REVEALING: + return {state: "processing", phase: "appeal-revealing"}; + case TransactionStatus.APPEAL_COMMITTING: + return {state: "processing", phase: "appeal-committing"}; + case TransactionStatus.LEADER_REVEALING: + return {state: "processing", phase: "leader-revealing"}; + case TransactionStatus.ACCEPTED: + return {state: "decided", outcome: "accepted"}; + case TransactionStatus.UNDETERMINED: + return {state: "decided", outcome: "undetermined"}; + case TransactionStatus.VALIDATORS_TIMEOUT: + return {state: "decided", outcome: "validators-timeout"}; + case TransactionStatus.LEADER_TIMEOUT: + return {state: "decided", outcome: "leader-timeout"}; + case TransactionStatus.FINALIZED: { + const outcome = finalizedOutcomeFromResult(result); + return outcome ? {state: "finalized", outcome} : {state: "finalized"}; + } + case TransactionStatus.CANCELED: + return {state: "canceled"}; + default: + throw new Error(`Unknown stored transaction status: ${String(status)}`); + } +}; + /** Full public round shape reconstructed from bounded train reads. */ export interface ConsensusRoundData { round: bigint; @@ -576,21 +762,12 @@ export type GenLayerTransaction = { lastLeader?: Address; // status: localnet: TransactionStatus // status: testnet: number - /** Lifecycle status projected at the block snapshot used for this read. */ + /** Exact lifecycle status persisted by the transaction manager. */ status?: TransactionStatus | number; - /** Named form of the projected lifecycle status. */ + /** Named form of the exact persisted lifecycle status. */ statusName?: TransactionStatus; - - /** Exact status currently persisted by the transaction manager. */ - storedStatus?: number; - /** Named form of the exact stored status. */ - storedStatusName?: TransactionStatus; - /** Resolution-kernel action currently available for the transaction. */ - resolutionAction?: number; - /** Named form of the available resolution action. */ - resolutionActionName?: TransactionResolutionAction; - /** Authoritative queue-head/deadline result from `ConsensusData.canFinalize`. */ - canFinalize?: boolean; + /** Simple lifecycle derived from `status`; it never uses timestamp projection. */ + lifecycle: TransactionLifecycle; // hash: localnet // txId: testnet// hash: localnet // txId: testnet hash?: TransactionHash; diff --git a/tests/client.test-d.ts b/tests/client.test-d.ts index da6e192..acd1dfa 100644 --- a/tests/client.test-d.ts +++ b/tests/client.test-d.ts @@ -85,6 +85,55 @@ test("type checks", () => { retries: 10, }); + void client.waitForDecision({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + fullTransaction: true, + }); + + void client.waitForFinalization({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + retries: 10, + }); + + void client.advanced.getTransactionLifecycle({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + timestamp: 1_700_000_000, + }).then(lifecycle => { + void lifecycle.storedStatus; + void lifecycle.storedStatusCode; + void lifecycle.projectedStatus; + void lifecycle.projectedStatusCode; + void lifecycle.resolutionAction; + void lifecycle.resolutionActionCode; + void lifecycle.resolutionSource; + void lifecycle.resolutionSourceCode; + void lifecycle.decisionId; + void lifecycle.decisionActive; + void lifecycle.evaluatedAt; + // @ts-expect-error finalization is represented by resolutionAction === "Finalize" + void lifecycle.finalization; + // @ts-expect-error there is no separate readiness field + void lifecycle.canFinalize; + }); + + // @ts-expect-error raw lifecycle access is intentionally kept out of the primary client namespace + void client.getTransactionLifecycle; + + void client.getTransaction({ + hash: "0x1234567890123456789012345678901234567890123456789012345678901234" as TransactionHash, + }).then(transaction => { + if (transaction.lifecycle.state === "processing") { + void transaction.lifecycle.phase; + // @ts-expect-error processing transactions do not expose an outcome + void transaction.lifecycle.outcome; + } + // Protocol projection/action is intentionally absent from the primary model. + // @ts-expect-error use advanced.getTransactionLifecycle for raw resolution details + void transaction.resolutionAction; + // @ts-expect-error use advanced.getTransactionLifecycle for the resolution action + void transaction.canFinalize; + }); + // @ts-expect-error missing hash void client.waitForTransactionReceipt({ status: TransactionStatus.FINALIZED, diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 1519173..07b03d9 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -9,10 +9,15 @@ import { transactionsStatusNameToNumber, TransactionResolutionAction, transactionResolutionActionNumberToName, + TransactionProtocolStatus, + transactionProtocolStatusNumberToName, + TransactionResolutionSource, + transactionResolutionSourceNumberToName, transactionResultNumberToName, executionResultNumberToName, VoteType, voteTypeNumberToName, + transactionLifecycleFromStoredStatus, } from "../src/types/transactions"; import { receiptActions, transactionActions, isSuccessful } from "../src/transactions/actions"; import { decodeTransaction, simplifyTransactionReceipt } from "../src/transactions/decoders"; @@ -83,6 +88,23 @@ describe("transaction enum maps", () => { "5": TransactionResolutionAction.MATERIALIZE_DECISION, "6": TransactionResolutionAction.FINALIZE, }); + expect(transactionProtocolStatusNumberToName["5"]).toBe(TransactionProtocolStatus.ACCEPTED); + expect(transactionProtocolStatusNumberToName["13"]).toBe(TransactionProtocolStatus.LEADER_REVEALING); + expect(transactionResolutionSourceNumberToName).toEqual({ + "0": TransactionResolutionSource.UNSPECIFIED, + "1": TransactionResolutionSource.ACTIVATION_INSUFFICIENT_VALIDATORS, + "2": TransactionResolutionSource.PROPOSAL_HANGING, + "3": TransactionResolutionSource.LEADER_RECEIPT_TIMEOUT, + "4": TransactionResolutionSource.COMMIT_HANGING, + "5": TransactionResolutionSource.LEADER_REVEAL_HANGING, + "6": TransactionResolutionSource.FULL_REVEAL, + "7": TransactionResolutionSource.REVEAL_DEADLINE, + "8": TransactionResolutionSource.APPEAL_COMMIT_HANGING, + "9": TransactionResolutionSource.APPEAL_FULL_REVEAL, + "10": TransactionResolutionSource.APPEAL_REVEAL_DEADLINE, + "11": TransactionResolutionSource.SELECTION_DEPLETED, + }); + expect(CONSENSUS_DATA_TRAIN_ABI.some(item => item.type === "function" && item.name === "canFinalize")).toBe(false); expect(isDecidedState("13")).toBe(false); expect(executionResultNumberToName["3"]).toBe(ExecutionResult.TIMEOUT); expect(executionResultNumberToName["4"]).toBe(ExecutionResult.NONDET_DISAGREE); @@ -141,6 +163,43 @@ describe("transaction enum maps", () => { }); }); +describe("stored transaction lifecycle mapping", () => { + it.each([ + [0, {state: "processing", phase: "uninitialized"}], + [1, {state: "processing", phase: "pending"}], + [2, {state: "processing", phase: "proposing"}], + [3, {state: "processing", phase: "committing"}], + [4, {state: "processing", phase: "revealing"}], + [5, {state: "decided", outcome: "accepted"}], + [6, {state: "decided", outcome: "undetermined"}], + [8, {state: "canceled"}], + [9, {state: "processing", phase: "appeal-revealing"}], + [10, {state: "processing", phase: "appeal-committing"}], + [11, {state: "decided", outcome: "validators-timeout"}], + [12, {state: "decided", outcome: "leader-timeout"}], + [13, {state: "processing", phase: "leader-revealing"}], + ] as const)("maps stored status %i without projection", (status, expected) => { + expect(transactionLifecycleFromStoredStatus(status)).toEqual(expected); + }); + + it.each([ + [1, {state: "finalized", outcome: "accepted"}], + [2, {state: "finalized", outcome: "undetermined"}], + [3, {state: "finalized", outcome: "validators-timeout"}], + [4, {state: "finalized", outcome: "undetermined"}], + [5, {state: "finalized", outcome: "undetermined"}], + [0, {state: "finalized"}], + ] as const)("maps finalized result %i only when its outcome is unambiguous", (result, expected) => { + expect(transactionLifecycleFromStoredStatus(7, result)).toEqual(expected); + }); + + it("fails loudly for an unknown protocol status", () => { + expect(() => transactionLifecycleFromStoredStatus(14)).toThrow( + "Unknown stored transaction status: 14", + ); + }); +}); + describe("isSuccessful", () => { it("returns true only for accepted/finalized transactions that finished with return", () => { expect(isSuccessful({ @@ -303,6 +362,51 @@ describe("waitForTransactionReceipt with DECIDED_STATES", () => { expect(result).toEqual(mockTransaction); }); + + it("waitForFinalization ignores a projected final status", async () => { + const hash = "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6" as any; + const mockClient = { + chain: localnet, + getTransaction: vi.fn().mockResolvedValue({ + hash, + status: 5, + statusName: TransactionStatus.ACCEPTED, + lifecycle: {state: "decided", outcome: "accepted"}, + projectedStatus: 7, + }), + }; + + await expect(receiptActions(mockClient as any, {} as any).waitForFinalization({ + hash, + retries: 0, + })).rejects.toThrow('current status: 5'); + }); + + it("provides focused decision and finalization wait helpers", async () => { + const hash = "0x4b8037744adab7ea8335b4f839979d20031d83a8ccdf706e0ae61312930335f6" as any; + const decided = { + hash, + status: 6, + statusName: TransactionStatus.UNDETERMINED, + lifecycle: {state: "decided", outcome: "undetermined"}, + }; + const finalized = { + hash, + status: 7, + statusName: TransactionStatus.FINALIZED, + lifecycle: {state: "finalized", outcome: "undetermined"}, + }; + const mockClient = { + chain: localnet, + getTransaction: vi.fn() + .mockResolvedValueOnce(decided) + .mockResolvedValueOnce(finalized), + }; + const actions = receiptActions(mockClient as any, {} as any); + + await expect(actions.waitForDecision({hash, fullTransaction: true})).resolves.toEqual(decided); + await expect(actions.waitForFinalization({hash, fullTransaction: true})).resolves.toEqual(finalized); + }); }); describe("cancelTransaction", () => { @@ -515,7 +619,7 @@ const TRANSACTION_MANAGER = "0x0000000000000000000000000000000000000014"; const trainLifecycle = (overrides: Record = {}) => ({ storedStatus: 5, - resolution: {projectedStatus: 5, action: 0}, + resolution: {projectedStatus: 5, action: 0, source: 0, evaluatedAt: 456n}, latestDecision: {}, decisionActive: false, ...overrides, @@ -524,13 +628,11 @@ const trainLifecycle = (overrides: Record = {}) => ({ const trainReadContract = ({ light = makeLightTx(), lifecycle = trainLifecycle(), - canFinalize = false, roundValidators = [] as string[], consumedValidators = [] as string[], }: { light?: ReturnType; lifecycle?: ReturnType; - canFinalize?: boolean; roundValidators?: string[]; consumedValidators?: string[]; } = {}) => vi.fn().mockImplementation(async ({functionName, args}: any) => { @@ -542,7 +644,6 @@ const trainReadContract = ({ } if (functionName === "getStoredTransactionDataLight") return light; if (functionName === "getTransactionLifecycle") return lifecycle; - if (functionName === "canFinalize") return [canFinalize, 456n, 400n]; if (functionName === "getRoundValidatorsPaged") { const offset = Number(args[2]); const size = Number(args[3]); @@ -611,7 +712,7 @@ describe("getTriggeredTransactionIds", () => { }); describe("getTransaction train lifecycle", () => { - it("returns projected and stored state from one block snapshot", async () => { + it("returns only the stored state in the primary transaction model", async () => { const validators = Array.from( {length: 65}, (_, index) => `0x${(index + 1).toString(16).padStart(40, "0")}`, @@ -624,13 +725,6 @@ describe("getTransaction train lifecycle", () => { }); const readContract = trainReadContract({ light, - lifecycle: trainLifecycle({ - storedStatus: 5, - resolution: {projectedStatus: 6, action: 6}, - latestDecision: {decisionId: 42n}, - decisionActive: true, - }), - canFinalize: true, roundValidators: validators, consumedValidators: consumed, }); @@ -652,29 +746,20 @@ describe("getTransaction train lifecycle", () => { hash: light.txId as any, }); - expect(transaction.status).toBe(6); - expect(transaction.statusName).toBe(TransactionStatus.UNDETERMINED); - expect(transaction.storedStatus).toBe(5); - expect(transaction.storedStatusName).toBe(TransactionStatus.ACCEPTED); - expect(transaction.resolutionAction).toBe(6); - expect(transaction.resolutionActionName).toBe("FINALIZE"); - expect(transaction.canFinalize).toBe(true); + expect(transaction.status).toBe(5); + expect(transaction.statusName).toBe(TransactionStatus.ACCEPTED); + expect(transaction.lifecycle).toEqual({state: "decided", outcome: "accepted"}); + expect(transaction).not.toHaveProperty("storedStatus"); + expect(transaction).not.toHaveProperty("resolutionAction"); + expect(transaction).not.toHaveProperty("canFinalize"); expect(transaction.lastRound?.roundValidators).toEqual(validators); expect(transaction.lastRound?.validatorResultHash).toHaveLength(validators.length); expect(transaction.consumedValidators).toEqual(consumed); - expect(readContract).toHaveBeenCalledWith( - expect.objectContaining({ - functionName: "getTransactionLifecycle", - args: [light.txId, 456n], - blockNumber: 123n, - }), + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getTransactionLifecycle"}), ); - expect(readContract).toHaveBeenCalledWith( - expect.objectContaining({ - functionName: "canFinalize", - args: [light.txId, 456n, 42n], - blockNumber: 123n, - }), + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "canFinalize"}), ); expect(readContract).not.toHaveBeenCalledWith( expect.objectContaining({functionName: "getTransactionAllData"}), @@ -684,17 +769,16 @@ describe("getTransaction train lifecycle", () => { ); }); - it("does not infer queue-head readiness from a FINALIZE action", async () => { + it("normalizes the contract-backed advanced lifecycle from one fixed-block read", async () => { const light = makeLightTx(); const readContract = trainReadContract({ light, lifecycle: trainLifecycle({ storedStatus: 5, - resolution: {projectedStatus: 5, action: 6}, + resolution: {projectedStatus: 6, action: 6, source: 11, evaluatedAt: 500n}, latestDecision: {decisionId: 7n}, decisionActive: true, }), - canFinalize: false, }); const publicClient = { readContract, @@ -710,12 +794,126 @@ describe("getTransaction train lifecycle", () => { }, } as any; - const transaction = await transactionActions(client, publicClient).getTransaction({ + const lifecycle = await transactionActions(client, publicClient).advanced.getTransactionLifecycle({ hash: light.txId as any, + timestamp: 500, }); - expect(transaction.resolutionActionName).toBe("FINALIZE"); - expect(transaction.canFinalize).toBe(false); + expect(lifecycle).toEqual({ + storedStatus: "Accepted", + storedStatusCode: 5, + projectedStatus: "Undetermined", + projectedStatusCode: 6, + resolutionAction: "Finalize", + resolutionActionCode: 6, + resolutionSource: "SelectionDepleted", + resolutionSourceCode: 11, + decisionId: "7", + decisionActive: true, + evaluatedAt: 500, + }); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({ + functionName: "getTransactionLifecycle", + args: [light.txId, 500n], + blockNumber: 123n, + }), + ); + expect(readContract).toHaveBeenCalledTimes(1); + expect(lifecycle).not.toHaveProperty("finalization"); + expect(lifecycle).not.toHaveProperty("canFinalize"); + }); + + it("uses the advanced node RPC on Studio and returns the same normalized schema", async () => { + const hash = makeLightTx().txId as any; + const request = vi.fn().mockResolvedValue({ + storedStatus: "Accepted", + storedStatusCode: 5, + projectedStatus: "Undetermined", + projectedStatusCode: 6, + resolutionAction: "Finalize", + resolutionActionCode: 6, + resolutionSource: "SelectionDepleted", + resolutionSourceCode: 11, + decisionId: "0007", + decisionActive: true, + evaluatedAt: 500, + }); + const publicClient = { + getBlock: vi.fn(), + readContract: vi.fn(), + } as any; + const client = {chain: {isStudio: true}, request} as any; + + const lifecycle = await transactionActions(client, publicClient).advanced.getTransactionLifecycle({ + hash, + timestamp: 500, + }); + + expect(lifecycle).toEqual({ + storedStatus: "Accepted", + storedStatusCode: 5, + projectedStatus: "Undetermined", + projectedStatusCode: 6, + resolutionAction: "Finalize", + resolutionActionCode: 6, + resolutionSource: "SelectionDepleted", + resolutionSourceCode: 11, + decisionId: "7", + decisionActive: true, + evaluatedAt: 500, + }); + expect(request).toHaveBeenCalledWith({ + method: "gen_getTransactionLifecycle", + params: [{txId: hash, timestamp: 500}], + }); + expect(publicClient.getBlock).not.toHaveBeenCalled(); + expect(publicClient.readContract).not.toHaveBeenCalled(); + expect(lifecycle).not.toHaveProperty("finalization"); + expect(lifecycle).not.toHaveProperty("canFinalize"); + }); + + it("uses block time by default and nulls an inactive decision identity", async () => { + const light = makeLightTx(); + const readContract = trainReadContract({ + lifecycle: trainLifecycle({ + storedStatus: 1, + resolution: {projectedStatus: 1, action: 0, source: 0, evaluatedAt: 456n}, + latestDecision: {decisionId: 999n}, + decisionActive: false, + }), + }); + const publicClient = { + readContract, + getBlock: vi.fn().mockResolvedValue({number: 123n, timestamp: 456n}), + } as any; + const client = { + chain: { + isStudio: false, + consensusDataContract: {address: "0x0000000000000000000000000000000000000010", abi: []}, + }, + } as any; + + const lifecycle = await transactionActions(client, publicClient).advanced.getTransactionLifecycle({ + hash: light.txId as any, + }); + + expect(lifecycle).toEqual({ + storedStatus: "Pending", + storedStatusCode: 1, + projectedStatus: "Pending", + projectedStatusCode: 1, + resolutionAction: "NoOp", + resolutionActionCode: 0, + resolutionSource: "Unspecified", + resolutionSourceCode: 0, + decisionId: null, + decisionActive: false, + evaluatedAt: 456, + }); + expect(readContract).toHaveBeenCalledWith( + expect.objectContaining({args: [light.txId, 456n], blockNumber: 123n}), + ); }); }); @@ -731,6 +929,7 @@ describe("decodeTransaction", () => { expect(decoded.txReceipt).toBeUndefined(); expect(decoded.txSlot).toBe("5"); expect(decoded.statusName).toBe("ACCEPTED"); + expect(decoded.lifecycle).toEqual({state: "decided", outcome: "accepted"}); expect(decoded.resultName).toBe("MAJORITY_AGREE"); }); From 7161e8edc27ca6ae0e54a625dd8318f1252d18c9 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 23:41:33 +0100 Subject: [PATCH 06/12] fix(staking): restore complete public action surface --- src/staking/actions.ts | 2 ++ src/types/staking.ts | 11 +++++++++++ tests/smoke.test.ts | 4 ++-- tests/staking-actions.test.ts | 4 +++- 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/staking/actions.ts b/src/staking/actions.ts index 0047f32..337cb82 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -807,6 +807,8 @@ export const stakingActions = ( currentEpoch: epoch, lastFinalizedEpoch: finalized, activeValidatorsCount: activeCount, + totalWeight: currentEpochData.weight, + inflationRaw: currentEpochData.inflation, epochMinDuration, nextEpochEstimate, validatorMinStake: formatStakingAmount(valMinStake), diff --git a/src/types/staking.ts b/src/types/staking.ts index de53c51..0a32106 100644 --- a/src/types/staking.ts +++ b/src/types/staking.ts @@ -126,6 +126,10 @@ export interface EpochInfo { currentEpoch: bigint; lastFinalizedEpoch: bigint; activeValidatorsCount: bigint; + /** Current epoch's aggregate validator weight. */ + totalWeight: bigint; + /** Current epoch's inflation amount in base units. */ + inflationRaw: bigint; epochMinDuration: bigint; nextEpochEstimate: Date | null; validatorMinStake: string; @@ -237,6 +241,7 @@ export interface DelegatorClaimOptions { export interface StakingActions { validatorJoin: (options: ValidatorJoinOptions) => Promise; getValidatorRegistrationContext: () => Promise; + validatorPrime: (options: ValidatorPrimeOptions) => Promise; /** @deprecated Use initiateOperatorTransfer followed by completeOperatorTransfer. */ setOperator: (options: SetOperatorOptions) => Promise; getOperatorTransferContext: (validator: Address) => Promise; @@ -244,6 +249,7 @@ export interface StakingActions { completeOperatorTransfer: (options: CompleteOperatorTransferOptions) => Promise; cancelOperatorTransfer: (options: CancelOperatorTransferOptions) => Promise; getPendingOperator: (validator: Address) => Promise; + setIdentity: (options: SetIdentityOptions) => Promise; validatorDeposit: (options: ValidatorDepositOptions) => Promise; validatorExit: (options: ValidatorExitOptions) => Promise; validatorClaim: (options?: ValidatorClaimOptions) => Promise; @@ -252,6 +258,8 @@ export interface StakingActions { delegatorClaim: (options: DelegatorClaimOptions) => Promise; /** Checks whether the address is a registered/joined validator wallet. */ isValidator: (address: Address) => Promise; + getCurrentEpoch: () => Promise; + isValidatorBelowMin: (validator: Address) => Promise; getValidatorInfo: (validator: Address) => Promise; getStakeInfo: (delegator: Address, validator: Address) => Promise; getEpochInfo: () => Promise; @@ -260,6 +268,9 @@ export interface StakingActions { getActiveValidatorsCount: () => Promise; getJoinedValidators: () => Promise; getJoinedValidatorsCount: () => Promise; + getQuarantinedValidators: () => Promise; + getBannedValidators: (startIndex?: bigint, size?: bigint) => Promise; + getQuarantinedValidatorsDetailed: (startIndex?: bigint, size?: bigint) => Promise; getStakingContract: () => StakingContract; parseStakingAmount: (amount: string | bigint) => bigint; formatStakingAmount: (amount: bigint) => string; diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 843e3d7..0ed9207 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -126,12 +126,12 @@ describe(`Testnet ${name} - Staking (read-only)`, () => { it("getQuarantinedValidators returns array", async () => { // This calls getValidatorQuarantineList() — the v0.5 renamed function - const quarantined = await (client as any).getQuarantinedValidators(); + const quarantined = await client.getQuarantinedValidators(); expect(Array.isArray(quarantined)).toBe(true); }, TIMEOUT); it("getBannedValidators returns array", async () => { - const banned = await (client as any).getBannedValidators(); + const banned = await client.getBannedValidators(); expect(Array.isArray(banned)).toBe(true); for (const b of banned) { expect(b.validator).toMatch(/^0x[0-9a-fA-F]{40}$/); diff --git a/tests/staking-actions.test.ts b/tests/staking-actions.test.ts index 1ba12a1..0ad8136 100644 --- a/tests/staking-actions.test.ts +++ b/tests/staking-actions.test.ts @@ -356,7 +356,7 @@ describe("stakingActions validator-set reads", () => { }); it("reports the selectable count as active in epoch information", async () => { - const epochData = [10n, 0n, 0n, 0n, 0n, 0n, 3n, 0n, 0n, 0n, 0n]; + const epochData = [10n, 0n, 10n, 100n, 0n, 0n, 3n, 0n, 0n, 0n, 0n]; const responses: Record = { epoch: 4n, finalized: 3n, @@ -374,6 +374,8 @@ describe("stakingActions validator-set reads", () => { const info = await actions.getEpochInfo(); expect(info.activeValidatorsCount).toBe(2n); + expect(info.totalWeight).toBe(100n); + expect(info.inflationRaw).toBe(10n); expect(readContract).toHaveBeenCalledWith( expect.objectContaining({functionName: "selectableValidatorsCount"}), ); From d9c564ebb3294f4472fcc69638b0ab0b7624cf50 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 27 Aug 2026 23:52:12 +0100 Subject: [PATCH 07/12] fix(types): stop merging unrelated transaction results --- src/types/transactions.ts | 5 ----- tests/transactions.test.ts | 11 +++++++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/types/transactions.ts b/src/types/transactions.ts index 99e81db..bcdbcfa 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -109,11 +109,6 @@ export const transactionResolutionSourceNumberToName = { "11": TransactionResolutionSource.SELECTION_DEPLETED, }; -export enum TransactionResult { - SUCCESS = "SUCCESS", - FAILURE = "FAILURE", -} - export enum TransactionResult { IDLE = "IDLE", AGREE = "AGREE", diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 07b03d9..e9a0971 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -124,6 +124,17 @@ describe("transaction enum maps", () => { "4": VoteType.NONDET_DISAGREE, "5": VoteType.DETERMINISTIC_VIOLATION, }); + expect(TransactionResult).toEqual({ + IDLE: "IDLE", + AGREE: "AGREE", + DISAGREE: "DISAGREE", + TIMEOUT: "TIMEOUT", + DETERMINISTIC_VIOLATION: "DETERMINISTIC_VIOLATION", + NO_MAJORITY: "NO_MAJORITY", + MAJORITY_AGREE: "MAJORITY_AGREE", + MAJORITY_DISAGREE: "MAJORITY_DISAGREE", + MAJORITY_TIMEOUT: "MAJORITY_TIMEOUT", + }); expect(transactionResultNumberToName).toEqual({ "0": TransactionResult.IDLE, "1": TransactionResult.MAJORITY_AGREE, From 797d4351746cc50c915b70b2b9e8714454589ee7 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 01:20:55 +0100 Subject: [PATCH 08/12] fix(appeals): bind Studio calls to decisions --- src/chains/localnet.ts | 5 ++ src/chains/studionet.ts | 5 ++ src/contracts/actions.ts | 129 +++++++++++--------------------- src/types/clients.ts | 1 + tests/contracts-actions.test.ts | 77 +++++++++---------- 5 files changed, 91 insertions(+), 126 deletions(-) diff --git a/src/chains/localnet.ts b/src/chains/localnet.ts index 733a8d3..c81c1ef 100644 --- a/src/chains/localnet.ts +++ b/src/chains/localnet.ts @@ -1380,6 +1380,11 @@ const CONSENSUS_MAIN_CONTRACT = { name: "_txId", type: "bytes32", }, + { + internalType: "uint256", + name: "_expectedDecisionId", + type: "uint256", + }, ], name: "submitAppeal", outputs: [], diff --git a/src/chains/studionet.ts b/src/chains/studionet.ts index 4d9de5f..8e93053 100644 --- a/src/chains/studionet.ts +++ b/src/chains/studionet.ts @@ -1381,6 +1381,11 @@ const CONSENSUS_MAIN_CONTRACT = { name: "_txId", type: "bytes32", }, + { + internalType: "uint256", + name: "_expectedDecisionId", + type: "uint256", + }, ], name: "submitAppeal", outputs: [], diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index ac6cff5..942da3f 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -11,6 +11,7 @@ import { CalldataEncodable, Address, TransactionHashVariant, + TransactionHash, TransactionFeeOptions, TransactionFeeEstimate, FeeEstimateOptions, @@ -603,17 +604,11 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** Returns the full authoritative appeal charge (bond plus appeal funding). */ getAppealCharge: async (args: {txId: `0x${string}`}): Promise => { - if (client.chain.isStudio) { - throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); - } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, /** @deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. */ getMinAppealBond: async (args: {txId: `0x${string}`}): Promise => { - if (client.chain.isStudio) { - throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); - } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, @@ -814,18 +809,6 @@ export const contractActions = (client: GenLayerClient, publicCli // Appeals don't go through _sendTransaction because submitAppeal emits // AppealStarted/TransactionActivated events, not NewTransaction/CreatedTransaction. // The appeal operates on the same GenLayer txId, so we return it directly. - if (client.chain.isStudio) { - await _sendConsensusCall({ - client, - publicClient, - encodedData: _encodeStudioSubmitAppealData({client, txId}), - senderAccount, - value: args.value ?? 0n, - operationName: "Appeal", - }); - return txId; - } - const context = await _readAppealContext({ client, publicClient, @@ -884,18 +867,6 @@ export const contractActions = (client: GenLayerClient, publicCli const {account, txId, distribution} = args; const senderAccount = account || client.account; - if (client.chain.isStudio) { - await _sendConsensusCall({ - client, - publicClient, - encodedData: _encodeStudioTopUpAndSubmitAppealData({txId, distribution}), - senderAccount, - value: args.value ?? 0n, - operationName: "Top up and submit appeal", - }); - return txId; - } - const context = await _readAppealContext({ client, publicClient, @@ -1147,23 +1118,6 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ }, ] as const; -/** - * Pre-train fee-management appeal entrypoint, kept for the studio-embedded - * consensus: it has no decision identity to bind the appeal to. - */ -const CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI = [ - { - type: "function", - name: "topUpAndSubmitAppeal", - stateMutability: "payable", - inputs: [ - {name: "_txId", type: "bytes32"}, - {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, - ], - outputs: [], - }, -] as const; - const CONSENSUS_APPEAL_TRAIN_ABI = [ { type: "function", @@ -2065,50 +2019,12 @@ const _encodeSubmitAppealData = ({ }); }; -/** - * Studio chains run the studio-embedded consensus, which predates the - * resolution-kernel train: ConsensusData exposes no getTransactionLifecycle and - * no estimateLatestAppealCharge, and the chain config carries no fee manager, - * rounds storage, or appeals contract. Appeal and finalization calls therefore - * keep their pre-train, decision-free shape there. - */ -const STUDIO_APPEAL_QUOTE_UNSUPPORTED = - "Appeal bond calculation not supported on this chain (missing feeManagerContract/roundsStorageContract)"; - const _studioTrainBatchError = (action: string): Error => new Error( `${action} not supported on this chain (studio consensus predates the resolution-kernel train): ` + "use finalizeTransaction for a studio transaction.", ); -const _encodeStudioSubmitAppealData = ({ - client, - txId, -}: { - client: GenLayerClient; - txId: `0x${string}`; -}): `0x${string}` => { - return encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, - functionName: "submitAppeal", - args: [txId], - }); -}; - -const _encodeStudioTopUpAndSubmitAppealData = ({ - txId, - distribution, -}: { - txId: `0x${string}`; - distribution: FeesDistributionInput; -}): `0x${string}` => { - return encodeFunctionData({ - abi: CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI, - functionName: "topUpAndSubmitAppeal", - args: [txId, createFeesDistribution(distribution)], - }); -}; - const ROUND_PAGE_SIZE = 64n; const _unpackRoundValidatorPage = ( @@ -2228,6 +2144,32 @@ const _readLifecycleIdentity = async ({ blockNumber?: bigint; blockTimestamp?: bigint; }): Promise => { + if (client.chain.isStudio) { + const lifecycle = await client.request({ + method: "gen_getTransactionLifecycle", + params: [{txId: txId as TransactionHash}], + }) as { + resolutionActionCode: unknown; + decisionActive: unknown; + decisionId: unknown; + evaluatedAt: unknown; + }; + if (typeof lifecycle.decisionActive !== "boolean") { + throw new Error( + `Invalid Studio lifecycle decisionActive: ${String(lifecycle.decisionActive)}`, + ); + } + const decisionActive = lifecycle.decisionActive; + return { + blockNumber: 0n, + blockTimestamp: BigInt(lifecycle.evaluatedAt as string | number | bigint), + resolutionAction: Number(lifecycle.resolutionActionCode), + attemptId: `0x${"00".repeat(32)}`, + decisionActive, + decisionId: decisionActive ? BigInt(lifecycle.decisionId as string) : 0n, + }; + } + const consensusDataAddress = client.chain.consensusDataContract?.address as Address | undefined; if (!consensusDataAddress || consensusDataAddress === zeroAddress) { throw new Error("ConsensusData contract is not configured for this chain"); @@ -2284,6 +2226,23 @@ const _readAppealContext = async ({ return {...identity, requiredValue: 0n}; } + if (client.chain.isStudio) { + const quote = await client.request({ + method: "gen_estimateLatestAppealCharge", + params: [{txId: txId as TransactionHash}], + }) as {decisionId: unknown; bond: unknown; funding: unknown}; + const quoteDecisionId = BigInt(quote.decisionId as string); + if (quoteDecisionId !== identity.decisionId) { + throw new Error( + `Appeal decision changed while reading ${txId}: expected ${identity.decisionId}, received ${quoteDecisionId}`, + ); + } + return { + ...identity, + requiredValue: BigInt(quote.bond as string) + BigInt(quote.funding as string), + }; + } + const consensusDataAddress = client.chain.consensusDataContract!.address as Address; const quote = await publicClient.readContract({ address: consensusDataAddress, diff --git a/src/types/clients.ts b/src/types/clients.ts index d1663f1..8347d25 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -39,6 +39,7 @@ export type GenLayerMethod = | {method: "gen_getContractSchemaForCode"; params: [contractCode: string]} | {method: "gen_getContractCode"; params: [address: Address] | [{address: Address}]} | {method: "gen_getTransactionLifecycle"; params: [{txId: TransactionHash; timestamp?: number}]} + | {method: "gen_estimateLatestAppealCharge"; params: [{txId: TransactionHash}]} | {method: "sim_getTransactionsForAddress"; params: [address: Address, filter?: "all" | "from" | "to"]} | {method: "eth_getTransactionCount"; params: [address: Address, block: string]} | {method: "eth_estimateGas"; params: [transactionParams: any]} diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 3cd2f97..089cf0a 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -2415,15 +2415,8 @@ describe("contractActions train lifecycle batches", () => { }); }); -/** Pre-train entrypoints as the studio-embedded consensus exposes them. */ +/** Legacy finalization entrypoint still exposed by the studio-embedded consensus. */ const CONSENSUS_MAIN_STUDIO_ABI = [ - { - type: "function" as const, - name: "submitAppeal", - stateMutability: "payable" as const, - inputs: [{name: "_txId", type: "bytes32"}], - outputs: [], - }, { type: "function" as const, name: "finalizeTransaction", @@ -2433,23 +2426,10 @@ const CONSENSUS_MAIN_STUDIO_ABI = [ }, ] as const; -const FEE_MANAGEMENT_STUDIO_ABI = [ - { - type: "function" as const, - name: "topUpAndSubmitAppeal", - stateMutability: "payable" as const, - inputs: [ - {name: "_txId", type: "bytes32"}, - {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, - ], - outputs: [], - }, -] as const; - /** * Mirrors the localnet/studionet chain config: ConsensusMain and ConsensusData - * resolve, but the fee manager, rounds storage, and appeals contracts carry no - * address, and ConsensusData answers no train lifecycle read. + * resolve, while lifecycle identity and appeal quotes are exposed by Studio's + * train-compatible RPC projection rather than EVM reads. */ const setupStudioLifecycleHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -2484,13 +2464,31 @@ const setupStudioLifecycleHarness = () => { sendRawTransaction, request: vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; + if (method === "gen_getTransactionLifecycle") { + return { + storedStatusCode: 5, + projectedStatusCode: 5, + resolutionActionCode: 0, + resolutionSourceCode: 6, + decisionId: MOCK_DECISION_ID.toString(), + decisionActive: true, + evaluatedAt: "456", + }; + } + if (method === "gen_estimateLatestAppealCharge") { + return { + decisionId: MOCK_DECISION_ID.toString(), + bond: "1000", + funding: "234", + appealDeadline: "789", + }; + } throw new Error(`Unexpected RPC method: ${method}`); }), }; - // Any consensus read here is the regression: the pre-train deployment answers - // getTransactionLifecycle with a short tuple, which viem reports as - // "Position 63 is out of bounds (0 < position < 32)". + // Studio lifecycle reads must stay on its RPC projection rather than trying + // to decode the embedded pre-train ConsensusData tuple. const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { throw new Error(`Unexpected consensus read on Studio: ${functionName}`); }); @@ -2512,7 +2510,7 @@ const setupStudioLifecycleHarness = () => { }; describe("contractActions Studio lifecycle compatibility", () => { - it("encodes submitAppeal(bytes32) without reading the train lifecycle", async () => { + it("encodes submitAppeal with Studio's active decision id", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).resolves.toBe( @@ -2523,21 +2521,21 @@ describe("contractActions Studio lifecycle compatibility", () => { const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); expect(txRequest.value).toBe(500n); - const decoded = decodeFunctionData({abi: CONSENSUS_MAIN_STUDIO_ABI, data: txRequest.data}); + const decoded = decodeFunctionData({abi: APPEAL_TRAIN_ABI, data: txRequest.data}); expect(decoded.functionName).toBe("submitAppeal"); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); }); - it("defaults the Studio appeal value to zero when the caller omits it", async () => { + it("uses Studio's authoritative appeal quote when the caller omits value", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID}); expect(readContract).not.toHaveBeenCalled(); - expect(signTransaction.mock.calls[0][0].value).toBe(0n); + expect(signTransaction.mock.calls[0][0].value).toBe(1234n); }); - it("encodes topUpAndSubmitAppeal without a decision id", async () => { + it("encodes topUpAndSubmitAppeal with Studio's active decision id", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); const txId = await actions.topUpAndSubmitAppeal({ @@ -2550,9 +2548,10 @@ describe("contractActions Studio lifecycle compatibility", () => { expect(readContract).not.toHaveBeenCalled(); const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.value).toBe(1234n); - const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_STUDIO_ABI as any, data: txRequest.data}); - const [decodedTxId, distribution] = decoded.args as any[]; + const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_ABI as any, data: txRequest.data}); + const [decodedTxId, expectedDecisionId, distribution] = decoded.args as any[]; expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); + expect(expectedDecisionId).toBe(MOCK_DECISION_ID); expect(distribution.appealRounds).toBe(1n); expect(distribution.rotations).toEqual([0n, 1n]); }); @@ -2573,15 +2572,11 @@ describe("contractActions Studio lifecycle compatibility", () => { expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); }); - it("reports the missing appeal quote surface instead of decoding a train lifecycle", async () => { + it("reads appeal charges from Studio's authoritative RPC projection", async () => { const {actions, readContract} = setupStudioLifecycleHarness(); - await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( - /missing feeManagerContract\/roundsStorageContract/, - ); - await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( - /missing feeManagerContract\/roundsStorageContract/, - ); + await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); + await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); expect(readContract).not.toHaveBeenCalled(); }); From 95d513a8e64157a51e90c977bd9a63bda40badb6 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 04:08:56 +0100 Subject: [PATCH 09/12] fix(appeals): use train ABI on Studio networks --- .github/workflows/smoke.yml | 4 ++ src/abi/studioConsensusTrain.ts | 114 ++++++++++++++++++++++++++++++++ src/chains/localnet.ts | 8 ++- src/chains/studionet.ts | 8 ++- src/contracts/actions.ts | 41 +++++++----- tests/chains-actions.test.ts | 73 ++++++++++++++++++++ tests/contracts-actions.test.ts | 64 ++++++++++++------ 7 files changed, 272 insertions(+), 40 deletions(-) create mode 100644 src/abi/studioConsensusTrain.ts diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 6e681fd..6f8606f 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -9,6 +9,10 @@ on: jobs: smoke: name: Testnet Smoke Tests + # The v2 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 those testnets upgrade. + if: github.event_name != 'pull_request' || github.base_ref != 'v2-dev' runs-on: ubuntu-latest timeout-minutes: 5 continue-on-error: true diff --git a/src/abi/studioConsensusTrain.ts b/src/abi/studioConsensusTrain.ts new file mode 100644 index 0000000..b522d81 --- /dev/null +++ b/src/abi/studioConsensusTrain.ts @@ -0,0 +1,114 @@ +import type {Abi} from "viem"; + +const feesDistributionComponents = [ + {name: "leaderTimeunitsAllocation", type: "uint256"}, + {name: "validatorTimeunitsAllocation", type: "uint256"}, + {name: "appealRounds", type: "uint256"}, + {name: "executionBudgetPerRound", type: "uint256"}, + {name: "executionConsumed", type: "uint256"}, + {name: "totalMessageFees", type: "uint256"}, + {name: "rotations", type: "uint256[]"}, + {name: "maxPriceGenPerTimeUnit", type: "uint256"}, + {name: "storageFeeMaxGasPrice", type: "uint256"}, + {name: "receiptFeeMaxGasPrice", type: "uint256"}, +] as const; + +const messageAllocationComponents = [ + {name: "messageType", type: "uint8"}, + {name: "onAcceptance", type: "bool"}, + {name: "parentIndex", type: "uint256"}, + {name: "recipient", type: "address"}, + {name: "callKey", type: "bytes32"}, + {name: "budget", type: "uint256"}, + {name: "feeParams", type: "bytes"}, +] as const; + +const addTransactionParamsComponents = [ + {name: "sender", type: "address"}, + {name: "recipient", type: "address"}, + {name: "numOfInitialValidators", type: "uint256"}, + {name: "maxRotations", type: "uint256"}, + {name: "validUntil", type: "uint256"}, + {name: "saltNonce", type: "uint256"}, + {name: "userValue", type: "uint256"}, + {name: "feesDistribution", type: "tuple", components: feesDistributionComponents}, + {name: "txCalldata", type: "bytes"}, + {name: "messageAllocations", type: "tuple[]", components: messageAllocationComponents}, +] as const; + +/** + * Studio's v0.123 transaction surface. The bundled legacy ConsensusMain ABI + * is retained for unrelated reads/events, while these entries replace every + * transaction function whose v0.6 signature is authoritative at admission. + */ +export const studioConsensusMainTrainAbi = [ + { + type: "function", + name: "addTransaction", + stateMutability: "payable", + inputs: [{name: "_params", type: "tuple", components: addTransactionParamsComponents}], + outputs: [], + }, + { + type: "function", + name: "deploySalted", + stateMutability: "payable", + inputs: [{name: "_params", type: "tuple", components: addTransactionParamsComponents}], + outputs: [], + }, + { + type: "function", + name: "topUpFees", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: feesDistributionComponents}, + ], + outputs: [], + }, + { + type: "function", + name: "topUpAndSubmitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + {name: "_feesDistribution", type: "tuple", components: feesDistributionComponents}, + ], + outputs: [], + }, + { + type: "function", + name: "submitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, + { + type: "function", + name: "finalizeTransaction", + stateMutability: "nonpayable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], + outputs: [], + }, +] as const satisfies Abi; + +const overriddenNames = new Set( + studioConsensusMainTrainAbi.map((entry) => entry.name), +); + +export const withStudioConsensusMainTrainAbi = (base: readonly unknown[]): Abi => { + const retained = base.filter((rawEntry) => { + const entry = rawEntry as {type?: string; name?: string}; + return entry.type !== "function" || + entry.name === undefined || + !overriddenNames.has(entry.name as typeof studioConsensusMainTrainAbi[number]["name"]); + }); + return [...retained, ...studioConsensusMainTrainAbi] as Abi; +}; diff --git a/src/chains/localnet.ts b/src/chains/localnet.ts index c81c1ef..b18682c 100644 --- a/src/chains/localnet.ts +++ b/src/chains/localnet.ts @@ -1,9 +1,10 @@ import {Address, defineChain} from "viem"; import {GenLayerChain} from "@/types"; +import {withStudioConsensusMainTrainAbi} from "@/abi/studioConsensusTrain"; // chains/localnet.ts const SIMULATOR_JSON_RPC_URL = "http://127.0.0.1:4000/api"; -const CONSENSUS_MAIN_CONTRACT = { +const CONSENSUS_MAIN_CONTRACT_BASE = { address: "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" as Address, abi: [ { @@ -1440,6 +1441,11 @@ const CONSENSUS_MAIN_CONTRACT = { bytecode: "", }; +const CONSENSUS_MAIN_CONTRACT = { + ...CONSENSUS_MAIN_CONTRACT_BASE, + abi: withStudioConsensusMainTrainAbi(CONSENSUS_MAIN_CONTRACT_BASE.abi), +}; + const CONSENSUS_DATA_CONTRACT = { address: "0x88B0F18613Db92Bf970FfE264E02496e20a74D16" as Address, abi: [ diff --git a/src/chains/studionet.ts b/src/chains/studionet.ts index 8e93053..7af4af0 100644 --- a/src/chains/studionet.ts +++ b/src/chains/studionet.ts @@ -1,10 +1,11 @@ import {Address, defineChain} from "viem"; import {GenLayerChain} from "@/types"; +import {withStudioConsensusMainTrainAbi} from "@/abi/studioConsensusTrain"; // chains/localnet.ts const SIMULATOR_JSON_RPC_URL = "https://studio.genlayer.com/api"; const EXPLORER_URL = "https://genlayer-explorer.vercel.app"; -const CONSENSUS_MAIN_CONTRACT = { +const CONSENSUS_MAIN_CONTRACT_BASE = { address: "0xb7278A61aa25c888815aFC32Ad3cC52fF24fE575" as Address, abi: [ { @@ -1441,6 +1442,11 @@ const CONSENSUS_MAIN_CONTRACT = { bytecode: "", }; +const CONSENSUS_MAIN_CONTRACT = { + ...CONSENSUS_MAIN_CONTRACT_BASE, + abi: withStudioConsensusMainTrainAbi(CONSENSUS_MAIN_CONTRACT_BASE.abi), +}; + const CONSENSUS_DATA_CONTRACT = { address: "0x88B0F18613Db92Bf970FfE264E02496e20a74D16" as Address, abi: [ diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 942da3f..23819cc 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -666,6 +666,18 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** Checks if a transaction can be appealed. */ canAppeal: async (args: {txId: `0x${string}`}): Promise => { + if (client.chain.isStudio) { + try { + await _readAppealContext({client, publicClient, txId: args.txId}); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/no active decision to appeal|CanNotAppeal|cannot appeal/i.test(message)) { + return false; + } + throw error; + } + } if (!client.chain.appealsContract?.address) { throw new Error("canAppeal not supported on this chain (missing appealsContract)"); } @@ -898,24 +910,15 @@ export const contractActions = (client: GenLayerClient, publicCli const {account, txId} = args; const senderAccount = account || client.account; - if (client.chain.isStudio) { - return _sendConsensusCall({ - client, - publicClient, - encodedData: encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, - functionName: "finalizeTransaction", - args: [txId], - }), - senderAccount, - operationName: "Finalize", - }); - } - const identity = await _readLifecycleIdentity({client, publicClient, txId}); if (!identity.decisionActive) { throw new Error(`Transaction ${txId} has no active decision to finalize`); } + if (identity.resolutionAction !== 6) { + throw new Error( + `Transaction ${txId} is not ready to finalize (resolution action ${identity.resolutionAction})`, + ); + } const encodedData = encodeFunctionData({ abi: CONSENSUS_FINALIZATION_TRAIN_ABI, functionName: "finalizeTransaction", @@ -1005,6 +1008,12 @@ export const contractActions = (client: GenLayerClient, publicCli if (!identity.decisionActive) { throw new Error(`Transaction ${args.txIds[index]} has no active decision to finalize`); } + if (identity.resolutionAction !== 6) { + throw new Error( + `Transaction ${args.txIds[index]} is not ready to finalize ` + + `(resolution action ${identity.resolutionAction})`, + ); + } }); const encodedData = encodeFunctionData({ abi: CONSENSUS_FINALIZATION_TRAIN_ABI, @@ -2021,8 +2030,8 @@ const _encodeSubmitAppealData = ({ const _studioTrainBatchError = (action: string): Error => new Error( - `${action} not supported on this chain (studio consensus predates the resolution-kernel train): ` + - "use finalizeTransaction for a studio transaction.", + `${action} is not exposed by Studio's embedded consensus: ` + + "use finalizeTransaction for an individual Studio transaction.", ); const ROUND_PAGE_SIZE = 64n; diff --git a/tests/chains-actions.test.ts b/tests/chains-actions.test.ts index 2ba4399..38d6518 100644 --- a/tests/chains-actions.test.ts +++ b/tests/chains-actions.test.ts @@ -1,5 +1,17 @@ import {describe, expect, it, vi} from "vitest"; import {chainActions} from "../src/chains/actions"; +import {localnet, studionet} from "../src/chains"; + +type TestAbiParameter = { + type: string; + components?: readonly TestAbiParameter[]; +}; + +type TestAbiEntry = { + type: string; + name?: string; + inputs?: readonly TestAbiParameter[]; +}; const makeClient = (chainId: number) => ({ @@ -37,3 +49,64 @@ describe("chainActions.initializeConsensusSmartContract", () => { fetchSpy.mockRestore(); }); }); + +describe.each([ + ["localnet", localnet], + ["studionet", studionet], +])("%s Studio consensus ABI", (_name, chain) => { + it("exports only the v0.6 fee and exact-decision transaction signatures", () => { + const functions = new Map( + (chain.consensusMainContract!.abi as readonly TestAbiEntry[]) + .filter((entry) => entry.type === "function" && entry.name) + .map((entry) => [entry.name!, entry]), + ); + const inputTypes = (name: string) => + functions.get(name)!.inputs!.map((input) => input.type); + + expect(inputTypes("addTransaction")).toEqual(["tuple"]); + expect(inputTypes("deploySalted")).toEqual(["tuple"]); + expect(inputTypes("topUpFees")).toEqual(["bytes32", "tuple"]); + expect(inputTypes("topUpAndSubmitAppeal")).toEqual([ + "bytes32", + "uint256", + "tuple", + ]); + expect(inputTypes("submitAppeal")).toEqual(["bytes32", "uint256"]); + expect(inputTypes("finalizeTransaction")).toEqual(["bytes32", "uint256"]); + + const addParams = functions.get("addTransaction")!.inputs![0].components!; + expect(addParams.map((component) => component.type)).toEqual([ + "address", + "address", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "tuple", + "bytes", + "tuple[]", + ]); + expect(addParams[7].components!.map((component) => component.type)).toEqual([ + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256", + "uint256[]", + "uint256", + "uint256", + "uint256", + ]); + expect(addParams[9].components!.map((component) => component.type)).toEqual([ + "uint8", + "bool", + "uint256", + "address", + "bytes32", + "uint256", + "bytes", + ]); + }); +}); diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 089cf0a..efaf55c 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1860,7 +1860,13 @@ const trainLifecycle = ({action = 6, decisionActive = true}: {action?: number; d decisionActive, }); -const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: string} = {}) => { +const setupFinalizeHarness = ({ + receiptStatus = "success", + resolutionAction = 6, +}: { + receiptStatus?: string; + resolutionAction?: number; +} = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: receiptStatus, logs: []}); @@ -1895,7 +1901,9 @@ const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: stri }; const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { - if (functionName === "getTransactionLifecycle") return trainLifecycle(); + if (functionName === "getTransactionLifecycle") { + return trainLifecycle({action: resolutionAction}); + } throw new Error(`Unexpected contract read: ${functionName}`); }); const publicClient = { @@ -2373,6 +2381,15 @@ describe("contractActions finalizeTransaction", () => { actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID}), ).rejects.toThrow(/Finalize reverted/); }); + + it("does not broadcast when the lifecycle action is not Finalize", async () => { + const {actions, signTransaction} = setupFinalizeHarness({resolutionAction: 0}); + + await expect( + actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID}), + ).rejects.toThrow(/not ready to finalize.*resolution action 0/i); + expect(signTransaction).not.toHaveBeenCalled(); + }); }); describe("contractActions finalizeIdlenessTxs", () => { @@ -2415,13 +2432,16 @@ describe("contractActions train lifecycle batches", () => { }); }); -/** Legacy finalization entrypoint still exposed by the studio-embedded consensus. */ +/** Exact-decision finalization entrypoint exposed by Studio's v0.123 train. */ const CONSENSUS_MAIN_STUDIO_ABI = [ { type: "function" as const, name: "finalizeTransaction", stateMutability: "nonpayable" as const, - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, ] as const; @@ -2431,7 +2451,7 @@ const CONSENSUS_MAIN_STUDIO_ABI = [ * resolve, while lifecycle identity and appeal quotes are exposed by Studio's * train-compatible RPC projection rather than EVM reads. */ -const setupStudioLifecycleHarness = () => { +const setupStudioLifecycleHarness = ({resolutionAction = 0}: {resolutionAction?: number} = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: "success", logs: []}); @@ -2468,7 +2488,7 @@ const setupStudioLifecycleHarness = () => { return { storedStatusCode: 5, projectedStatusCode: 5, - resolutionActionCode: 0, + resolutionActionCode: resolutionAction, resolutionSourceCode: 6, decisionId: MOCK_DECISION_ID.toString(), decisionActive: true, @@ -2487,8 +2507,8 @@ const setupStudioLifecycleHarness = () => { }), }; - // Studio lifecycle reads must stay on its RPC projection rather than trying - // to decode the embedded pre-train ConsensusData tuple. + // Studio lifecycle reads stay on its authoritative RPC projection; the + // embedded ConsensusData address is not a state-authoritative EVM contract. const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { throw new Error(`Unexpected consensus read on Studio: ${functionName}`); }); @@ -2510,6 +2530,13 @@ const setupStudioLifecycleHarness = () => { }; describe("contractActions Studio lifecycle compatibility", () => { + it("answers canAppeal from Studio's authoritative decision quote", async () => { + const {actions, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(true); + expect(readContract).not.toHaveBeenCalled(); + }); + it("encodes submitAppeal with Studio's active decision id", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); @@ -2556,8 +2583,10 @@ describe("contractActions Studio lifecycle compatibility", () => { expect(distribution.rotations).toEqual([0n, 1n]); }); - it("encodes finalizeTransaction(bytes32) without a decision id", async () => { - const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); + it("encodes finalizeTransaction with Studio's active decision id", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness({ + resolutionAction: 6, + }); await expect(actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( MOCK_EVM_TX_HASH, @@ -2569,7 +2598,7 @@ describe("contractActions Studio lifecycle compatibility", () => { data: signTransaction.mock.calls[0][0].data, }); expect(decoded.functionName).toBe("finalizeTransaction"); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); }); it("reads appeal charges from Studio's authoritative RPC projection", async () => { @@ -2580,23 +2609,14 @@ describe("contractActions Studio lifecycle compatibility", () => { expect(readContract).not.toHaveBeenCalled(); }); - it("reports the missing appeals contract from canAppeal", async () => { - const {actions, readContract} = setupStudioLifecycleHarness(); - - await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( - /missing appealsContract/, - ); - expect(readContract).not.toHaveBeenCalled(); - }); - it("rejects the train-only batch actions with an actionable message and never broadcasts", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await expect(actions.resolveTransactions({txIds: [MOCK_GENLAYER_TX_ID]})).rejects.toThrow( - /resolveTransactions not supported on this chain.*finalizeTransaction/s, + /resolveTransactions is not exposed by Studio's embedded consensus.*finalizeTransaction/s, ); await expect(actions.finalizeDecisions({txIds: [MOCK_GENLAYER_TX_ID]})).rejects.toThrow( - /finalizeDecisions not supported on this chain.*finalizeTransaction/s, + /finalizeDecisions is not exposed by Studio's embedded consensus.*finalizeTransaction/s, ); expect(readContract).not.toHaveBeenCalled(); expect(signTransaction).not.toHaveBeenCalled(); From 120a0938c6fbb7793971ae8f3c8a77c8e788af58 Mon Sep 17 00:00:00 2001 From: kirilaa Date: Fri, 28 Aug 2026 08:22:24 +0200 Subject: [PATCH 10/12] fix(lifecycle): fall back to the Studio status when the RPC is absent The train surface routes Studio chains through the node RPC gen_getTransactionLifecycle: advanced.getTransactionLifecycle reads it directly, and _readLifecycleIdentity reads it for every appeal and finalization. The genlayer-studio backend deployed from studio main does not implement that method, so viem raises MethodNotFoundRpcError and the whole flow dies before it reaches anything the transaction can answer. Keep the node-RPC-first design and degrade only when the endpoint says it does not implement the method (viem's typed error, JSON-RPC -32601, or a message that reports the gap in text). The fallback synthesizes the lifecycle from the Studio consumer surface the SDK already uses, client.getTransaction, which proves the exact stored status and nothing else: the projection repeats the stored status, the resolution is NoOp/Unspecified, and the decision identity stays inactive. When the status cannot be read, the original RPC error is re-raised rather than reported as a lifecycle. _readLifecycleIdentity returns the same conservative identity, so a Studio chain without the RPC reports no decision instead of a fabricated one. A fabricated decision id would be signed into submitAppeal or finalizeTransaction as expectedDecisionId, and every consumer of that identity refuses to act on an inactive decision, which is the honest outcome for a decision the SDK cannot read. This unblocks the Studio flows that never touch a decision. It does not make the four appeal scenarios of 051_fee_lifecycle pass: those need a real active decision id on Studio, which only the backend can supply. --- src/contracts/actions.ts | 36 ++++++- src/transactions/actions.ts | 23 +++-- src/transactions/lifecycleFallback.ts | 131 ++++++++++++++++++++++++++ tests/contracts-actions.test.ts | 97 ++++++++++++++++++- tests/transactions.test.ts | 92 +++++++++++++++++- 5 files changed, 365 insertions(+), 14 deletions(-) create mode 100644 src/transactions/lifecycleFallback.ts diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 23819cc..48aa771 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -40,6 +40,7 @@ import { normalizeTransactionFees, NormalizedTransactionFees, } from "@/transactions/fees"; +import {isMethodNotFoundError, readStudioLifecycleFallback} from "@/transactions/lifecycleFallback"; import {CONSENSUS_DATA_TRAIN_ABI, ROUNDS_STORAGE_TRAIN_READ_ABI} from "@/abi/consensusTrain"; const prefixHex = (hex: string): `0x${string}` => { @@ -2131,6 +2132,9 @@ const _readRoundDataSnapshot = async ({ }; }; +/** Studio has no per-attempt identity to report; the train reads a real one. */ +const ZERO_ATTEMPT_ID = `0x${"00".repeat(32)}` as `0x${string}`; + type LifecycleIdentity = { blockNumber: bigint; blockTimestamp: bigint; @@ -2154,15 +2158,37 @@ const _readLifecycleIdentity = async ({ blockTimestamp?: bigint; }): Promise => { if (client.chain.isStudio) { - const lifecycle = await client.request({ - method: "gen_getTransactionLifecycle", - params: [{txId: txId as TransactionHash}], - }) as { + type StudioLifecycleIdentity = { resolutionActionCode: unknown; decisionActive: unknown; decisionId: unknown; evaluatedAt: unknown; }; + let lifecycle: StudioLifecycleIdentity; + try { + lifecycle = await client.request({ + method: "gen_getTransactionLifecycle", + params: [{txId: txId as TransactionHash}], + }) as StudioLifecycleIdentity; + } catch (error) { + if (!isMethodNotFoundError(error)) throw error; + // A Studio deployment without the lifecycle RPC proves no decision + // identity at all. Report that gap instead of inventing a decision id: + // a fabricated id would be signed into an appeal or a finalization. + const fallback = await readStudioLifecycleFallback({ + client, + hash: txId as TransactionHash, + cause: error, + }); + return { + blockNumber: 0n, + blockTimestamp: BigInt(fallback.evaluatedAt), + resolutionAction: fallback.resolutionActionCode, + attemptId: ZERO_ATTEMPT_ID, + decisionActive: false, + decisionId: 0n, + }; + } if (typeof lifecycle.decisionActive !== "boolean") { throw new Error( `Invalid Studio lifecycle decisionActive: ${String(lifecycle.decisionActive)}`, @@ -2173,7 +2199,7 @@ const _readLifecycleIdentity = async ({ blockNumber: 0n, blockTimestamp: BigInt(lifecycle.evaluatedAt as string | number | bigint), resolutionAction: Number(lifecycle.resolutionActionCode), - attemptId: `0x${"00".repeat(32)}`, + attemptId: ZERO_ATTEMPT_ID, decisionActive, decisionId: decisionActive ? BigInt(lifecycle.decisionId as string) : 0n, }; diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 13958e3..67e7a9e 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -22,6 +22,7 @@ import {sleep} from "../utils/async"; import {GenLayerChain} from "@/types"; import {Abi, PublicClient, Address, keccak256, concat, stringToBytes, toBytes, zeroAddress} from "viem"; import {decodeLocalnetTransaction, decodeTransaction, simplifyTransactionReceipt} from "./decoders"; +import {isMethodNotFoundError, readStudioLifecycleFallback} from "./lifecycleFallback"; import { ADDRESS_MANAGER_TRAIN_ABI, CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, @@ -315,6 +316,10 @@ export const transactionActions = (client: GenLayerClient, public * resolution action/source, and active decision identity. Studio uses the * node RPC; contract networks use one fixed-block lifecycle read. `Finalize` * is an action, not a status or separate readiness field. + * + * A Studio deployment that does not yet serve `gen_getTransactionLifecycle` + * degrades to the stored status its consumer surface does prove, rather + * than failing the whole read. */ getTransactionLifecycle: async ({ hash, @@ -324,12 +329,18 @@ export const transactionActions = (client: GenLayerClient, public timestamp?: number; }): Promise => { if (client.chain.isStudio) { - const raw = await client.request({ - method: "gen_getTransactionLifecycle", - params: [{txId: hash, ...(timestamp === undefined ? {} : { - timestamp: protocolInteger(timestamp, "timestamp"), - })}], - }) as RawProtocolLifecycle; + let raw: RawProtocolLifecycle; + try { + raw = await client.request({ + method: "gen_getTransactionLifecycle", + params: [{txId: hash, ...(timestamp === undefined ? {} : { + timestamp: protocolInteger(timestamp, "timestamp"), + })}], + }) as RawProtocolLifecycle; + } catch (error) { + if (!isMethodNotFoundError(error)) throw error; + raw = await readStudioLifecycleFallback({client, hash, timestamp, cause: error}); + } return normalizeProtocolLifecycle(raw); } const consensusDataAddress = client.chain.consensusDataContract?.address as Address; diff --git a/src/transactions/lifecycleFallback.ts b/src/transactions/lifecycleFallback.ts new file mode 100644 index 0000000..0f49183 --- /dev/null +++ b/src/transactions/lifecycleFallback.ts @@ -0,0 +1,131 @@ +import {MethodNotFoundRpcError} from "viem"; + +import {GenLayerClient} from "../types/clients"; +import {GenLayerChain} from "../types/chains"; +import { + GenLayerTransaction, + TransactionHash, + TransactionStatus, + transactionsStatusNameToNumber, + transactionsStatusNumberToName, +} from "../types/transactions"; + +/** JSON-RPC "method not found" as defined by the spec. */ +const METHOD_NOT_FOUND_CODE = -32601; + +/** + * Studio reports a missing method through several shapes: the raw JSON-RPC + * error object thrown by the SDK transport, viem's typed wrapper, and older + * gateways that answer with a generic code and only the text to go on. + */ +const METHOD_NOT_FOUND_MESSAGE = /method not found|does not exist|method not supported/i; + +/** Studio's pre-proposal state; the protocol equivalent is Pending. */ +const STUDIO_ACTIVATED_STATUS = "ACTIVATED"; + +/** + * Reports whether the RPC endpoint answered "I do not implement this method", + * as opposed to failing to answer a method it does implement. Only the former + * may degrade to a locally synthesized lifecycle. + */ +export const isMethodNotFoundError = (error: unknown): boolean => { + if (error instanceof MethodNotFoundRpcError) return true; + + const seen = new Set(); + let current: unknown = error; + while (current && typeof current === "object" && !seen.has(current)) { + seen.add(current); + const {code, message} = current as {code?: unknown; message?: unknown}; + if (Number(code) === METHOD_NOT_FOUND_CODE) return true; + if (typeof message === "string" && METHOD_NOT_FOUND_MESSAGE.test(message)) return true; + current = (current as {cause?: unknown}).cause; + } + return false; +}; + +/** + * Lifecycle synthesized from the Studio consumer surface. It carries the exact + * stored status and nothing the consumer surface cannot prove: no projection, + * no resolution action, and no decision identity. + */ +export type StudioLifecycleFallback = { + storedStatusCode: number; + projectedStatusCode: number; + resolutionActionCode: number; + resolutionSourceCode: number; + decisionId: null; + decisionActive: false; + evaluatedAt: number; +}; + +/** Resolution-action and resolution-source codes for "nothing to report". */ +const NO_OP_RESOLUTION_ACTION_CODE = 0; +const UNSPECIFIED_RESOLUTION_SOURCE_CODE = 0; + +/** + * Reads the exact stored status off a Studio transaction. `transactionActions` + * hands over viem's raw transaction, whose `status` is the Studio status name, + * while `contractActions` hands over the SDK transaction, whose `status` is + * already the numeric code and whose `statusName` is already normalized. + */ +const storedStatusName = (transaction: GenLayerTransaction): TransactionStatus | undefined => { + const raw: unknown = transaction.statusName ?? transaction.status; + const byCode = (code: string): TransactionStatus | undefined => + Object.prototype.hasOwnProperty.call(transactionsStatusNumberToName, code) + ? transactionsStatusNumberToName[code as keyof typeof transactionsStatusNumberToName] + : undefined; + + if (typeof raw === "number") return byCode(String(raw)); + if (typeof raw !== "string") return undefined; + if (raw === STUDIO_ACTIVATED_STATUS) return TransactionStatus.PENDING; + if (/^\d+$/.test(raw)) return byCode(raw); + return Object.prototype.hasOwnProperty.call(transactionsStatusNameToNumber, raw) + ? (raw as TransactionStatus) + : undefined; +}; + +/** + * Synthesizes the protocol lifecycle from the Studio consumer surface after the + * node lifecycle RPC reported that it does not implement the method. + * + * Studio's transaction object proves the stored status and nothing else, so the + * projection repeats the stored status, the resolution is `NoOp`/`Unspecified`, + * and the decision identity stays inactive. The SDK must never invent a + * decision id: a fabricated one would be signed into an appeal or finalization. + * + * @throws the original RPC error when the status cannot be read, so a genuine + * gap is never reported as a lifecycle. + */ +export const readStudioLifecycleFallback = async ({ + client, + hash, + timestamp, + cause, +}: { + client: GenLayerClient; + hash: TransactionHash; + timestamp?: number; + cause: unknown; +}): Promise => { + let transaction: GenLayerTransaction; + try { + transaction = await client.getTransaction({hash}); + } catch { + throw cause; + } + + const status = storedStatusName(transaction); + if (!status) throw cause; + const storedStatusCode = Number(transactionsStatusNameToNumber[status]); + + return { + storedStatusCode, + // The consumer surface cannot project a status forward in time. + projectedStatusCode: storedStatusCode, + resolutionActionCode: NO_OP_RESOLUTION_ACTION_CODE, + resolutionSourceCode: UNSPECIFIED_RESOLUTION_SOURCE_CODE, + decisionId: null, + decisionActive: false, + evaluatedAt: timestamp ?? Math.floor(Date.now() / 1000), + }; +}; diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index efaf55c..34e8f9e 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1,6 +1,14 @@ import {describe, it, expect, vi} from "vitest"; -import {decodeAbiParameters, decodeFunctionData, encodeFunctionData, keccak256, toHex} from "viem"; +import { + decodeAbiParameters, + decodeFunctionData, + encodeFunctionData, + keccak256, + MethodNotFoundRpcError, + toHex, +} from "viem"; import {contractActions} from "../src/contracts/actions"; +import {TransactionStatus, transactionsStatusNameToNumber} from "../src/types/transactions"; import {NFT_MINTER_ABI} from "../src/abi/nftMinter"; import { CALL_KEY_DEPLOY, @@ -2451,12 +2459,27 @@ const CONSENSUS_MAIN_STUDIO_ABI = [ * resolve, while lifecycle identity and appeal quotes are exposed by Studio's * train-compatible RPC projection rather than EVM reads. */ -const setupStudioLifecycleHarness = ({resolutionAction = 0}: {resolutionAction?: number} = {}) => { +const setupStudioLifecycleHarness = ({ + resolutionAction = 0, + lifecycleRpcError, + studioStatus = TransactionStatus.ACCEPTED, +}: { + resolutionAction?: number; + /** Thrown instead of answering gen_getTransactionLifecycle. */ + lifecycleRpcError?: unknown; + studioStatus?: TransactionStatus; +} = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: "success", logs: []}); + const getTransaction = vi.fn().mockResolvedValue({ + txId: MOCK_GENLAYER_TX_ID, + status: Number(transactionsStatusNameToNumber[studioStatus]), + statusName: studioStatus, + }); const client = { + getTransaction, chain: { id: 61_999, isStudio: true, @@ -2485,6 +2508,7 @@ const setupStudioLifecycleHarness = ({resolutionAction = 0}: {resolutionAction?: request: vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; if (method === "gen_getTransactionLifecycle") { + if (lifecycleRpcError !== undefined) throw lifecycleRpcError; return { storedStatusCode: 5, projectedStatusCode: 5, @@ -2524,6 +2548,7 @@ const setupStudioLifecycleHarness = ({resolutionAction = 0}: {resolutionAction?: sendRawTransaction, waitForTransactionReceipt, readContract, + getTransaction, client, publicClient, }; @@ -2637,3 +2662,71 @@ describe("contractActions Studio lifecycle compatibility", () => { expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); }); }); + +/** The raw JSON-RPC error the SDK transport throws before viem types it. */ +const RAW_METHOD_NOT_FOUND = {code: -32601, message: "Method not found"}; + +describe("contractActions Studio lifecycle degradation", () => { + it("reports no decision instead of failing when Studio omits the lifecycle RPC", async () => { + const {actions, getTransaction, readContract} = setupStudioLifecycleHarness({ + lifecycleRpcError: new MethodNotFoundRpcError(new Error("Method not found"), { + method: "gen_getTransactionLifecycle", + }), + }); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(false); + expect(getTransaction).toHaveBeenCalledWith({hash: MOCK_GENLAYER_TX_ID}); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("recognizes the untyped -32601 error the SDK transport raises", async () => { + const {actions, getTransaction} = setupStudioLifecycleHarness({ + lifecycleRpcError: RAW_METHOD_NOT_FOUND, + }); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(false); + expect(getTransaction).toHaveBeenCalledTimes(1); + }); + + it("refuses to appeal a decision it cannot read instead of signing a zero decision id", async () => { + const {actions, signTransaction} = setupStudioLifecycleHarness({ + lifecycleRpcError: RAW_METHOD_NOT_FOUND, + }); + + await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).rejects.toThrow( + /has no active decision to appeal/, + ); + expect(signTransaction).not.toHaveBeenCalled(); + }); + + it("refuses to finalize a decision it cannot read", async () => { + const {actions, signTransaction} = setupStudioLifecycleHarness({ + lifecycleRpcError: RAW_METHOD_NOT_FOUND, + }); + + await expect(actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /has no active decision to finalize/, + ); + expect(signTransaction).not.toHaveBeenCalled(); + }); + + it("propagates a lifecycle RPC failure that is not a missing method", async () => { + const {actions, getTransaction} = setupStudioLifecycleHarness({ + lifecycleRpcError: {code: -32000, message: "execution reverted"}, + }); + + await expect( + actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n}), + ).rejects.toEqual({code: -32000, message: "execution reverted"}); + expect(getTransaction).not.toHaveBeenCalled(); + }); + + it("re-raises the original RPC error when the Studio status is unreadable", async () => { + const harness = setupStudioLifecycleHarness({lifecycleRpcError: RAW_METHOD_NOT_FOUND}); + harness.getTransaction.mockResolvedValue({txId: MOCK_GENLAYER_TX_ID, status: "NOT_A_STATUS"}); + + await expect(harness.actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).rejects.toEqual( + RAW_METHOD_NOT_FOUND, + ); + }); +}); diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index e9a0971..175ba3c 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -23,7 +23,13 @@ import { receiptActions, transactionActions, isSuccessful } from "../src/transac import { decodeTransaction, simplifyTransactionReceipt } from "../src/transactions/decoders"; import { localnet } from "../src/chains/localnet"; import type { GenLayerRawTransaction } from "../src/types/transactions"; -import {decodeFunctionResult, encodeFunctionResult, keccak256, stringToBytes} from "viem"; +import { + decodeFunctionResult, + encodeFunctionResult, + keccak256, + MethodNotFoundRpcError, + stringToBytes, +} from "viem"; import {CONSENSUS_DATA_BIG_ROUNDS_TRAIN_ABI, CONSENSUS_DATA_TRAIN_ABI} from "../src/abi/consensusTrain"; const mockFetch = vi.fn(); @@ -884,6 +890,90 @@ describe("getTransaction train lifecycle", () => { expect(lifecycle).not.toHaveProperty("canFinalize"); }); + it("synthesizes the lifecycle from the Studio transaction when the RPC is absent", async () => { + const hash = makeLightTx().txId as any; + const request = vi.fn().mockRejectedValue( + new MethodNotFoundRpcError(new Error("Method not found"), { + method: "gen_getTransactionLifecycle", + }), + ); + const getTransaction = vi.fn().mockResolvedValue({status: TransactionStatus.ACCEPTED}); + const publicClient = {getBlock: vi.fn(), readContract: vi.fn()} as any; + const client = {chain: {isStudio: true}, request, getTransaction} as any; + + const lifecycle = await transactionActions(client, publicClient).advanced.getTransactionLifecycle({ + hash, + timestamp: 500, + }); + + // Studio's consumer surface proves the stored status and nothing else: no + // projection, no resolution action, and no decision identity to report. + expect(lifecycle).toEqual({ + storedStatus: "Accepted", + storedStatusCode: 5, + projectedStatus: "Accepted", + projectedStatusCode: 5, + resolutionAction: "NoOp", + resolutionActionCode: 0, + resolutionSource: "Unspecified", + resolutionSourceCode: 0, + decisionId: null, + decisionActive: false, + evaluatedAt: 500, + }); + expect(getTransaction).toHaveBeenCalledWith({hash}); + expect(publicClient.readContract).not.toHaveBeenCalled(); + }); + + it("maps Studio's ACTIVATED state onto the protocol Pending status", async () => { + const hash = makeLightTx().txId as any; + const before = Math.floor(Date.now() / 1000); + const request = vi.fn().mockRejectedValue({code: -32601, message: "Method not found"}); + const client = { + chain: {isStudio: true}, + request, + getTransaction: vi.fn().mockResolvedValue({status: "ACTIVATED"}), + } as any; + + const lifecycle = await transactionActions(client, {} as any).advanced.getTransactionLifecycle({ + hash, + }); + + expect(lifecycle.storedStatus).toBe("Pending"); + expect(lifecycle.projectedStatus).toBe("Pending"); + expect(lifecycle.decisionActive).toBe(false); + expect(lifecycle.evaluatedAt).toBeGreaterThanOrEqual(before); + }); + + it("propagates a Studio lifecycle failure that is not a missing method", async () => { + const hash = makeLightTx().txId as any; + const getTransaction = vi.fn(); + const client = { + chain: {isStudio: true}, + request: vi.fn().mockRejectedValue({code: -32000, message: "execution reverted"}), + getTransaction, + } as any; + + await expect( + transactionActions(client, {} as any).advanced.getTransactionLifecycle({hash}), + ).rejects.toEqual({code: -32000, message: "execution reverted"}); + expect(getTransaction).not.toHaveBeenCalled(); + }); + + it("re-raises the original RPC error when the Studio status is unreadable", async () => { + const hash = makeLightTx().txId as any; + const cause = {code: -32601, message: "Method not found"}; + const client = { + chain: {isStudio: true}, + request: vi.fn().mockRejectedValue(cause), + getTransaction: vi.fn().mockResolvedValue({status: "NOT_A_STATUS"}), + } as any; + + await expect( + transactionActions(client, {} as any).advanced.getTransactionLifecycle({hash}), + ).rejects.toEqual(cause); + }); + it("uses block time by default and nulls an inactive decision identity", async () => { const light = makeLightTx(); const readContract = trainReadContract({ From b223dfe264bfb43c77465be58b061c45d842843d Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 14:18:07 +0100 Subject: [PATCH 11/12] fix(studio): preserve the current consensus surface --- README.md | 3 +- docs/api-references/contracts.md | 10 +- docs/api-references/index.md | 3 +- docs/api-references/transactions.md | 9 +- ...ioConsensusTrain.ts => studioConsensus.ts} | 29 +-- src/chains/localnet.ts | 9 +- src/chains/studionet.ts | 9 +- src/contracts/actions.ts | 187 +++++++++-------- src/transactions/actions.ts | 6 +- tests/chains-actions.test.ts | 12 +- tests/contracts-actions.test.ts | 190 +++++------------- 11 files changed, 195 insertions(+), 272 deletions(-) rename src/abi/{studioConsensusTrain.ts => studioConsensus.ts} (77%) diff --git a/README.md b/README.md index 3b52867..caeff92 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,8 @@ console.log(tx.lifecycle); // {state: "decided", outcome: "accepted"} // Advanced protocol consumers can explicitly request timestamp projection and -// the exact resolution action/source. The same shape works on Studio and train. +// the exact resolution action/source. Current Studio can prove only the stored +// status, so unsupported action/decision fields remain inactive there. const protocolLifecycle = await client.advanced.getTransactionLifecycle({ hash: txHash }); console.log(protocolLifecycle.storedStatus); console.log(protocolLifecycle.projectedStatus); diff --git a/docs/api-references/contracts.md b/docs/api-references/contracts.md index ceaee7f..ecb3e31 100644 --- a/docs/api-references/contracts.md +++ b/docs/api-references/contracts.md @@ -188,7 +188,9 @@ should pass with the real transaction. ### getAppealCharge -Returns the full authoritative appeal charge (bond plus appeal funding). +Returns the full authoritative appeal charge (bond plus appeal funding) +on resolution-kernel contract networks. Current Studio has no +decision-bound quote surface. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -322,6 +324,9 @@ Claims a bounded number of reward epochs for a developer NFT. Returns the EVM tr ### appealTransaction Appeals a consensus transaction to trigger a new round of validation. +Contract networks bind the call to the active decision and quote an +omitted value. Current Studio uses its native decision-free entrypoint; +its value defaults to zero when omitted. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -352,6 +357,9 @@ backends, or the target GenLayer tx id on Studio/localnet. Deposits appeal fee budget and submits an appeal in the same consensus call. Returns the existing GenLayer transaction id, matching appealTransaction. +Contract networks bind the call to the active decision and quote an +omitted value. Current Studio uses its native decision-free entrypoint; +its value defaults to zero when omitted. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| diff --git a/docs/api-references/index.md b/docs/api-references/index.md index 0f3a53a..bed07bb 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -299,7 +299,8 @@ console.log(tx.lifecycle); // {state: "decided", outcome: "accepted"} // Advanced protocol consumers can explicitly request timestamp projection and -// the exact resolution action/source. The same shape works on Studio and train. +// the exact resolution action/source. Current Studio can prove only the stored +// status, so unsupported action/decision fields remain inactive there. const protocolLifecycle = await client.advanced.getTransactionLifecycle({ hash: txHash }); console.log(protocolLifecycle.storedStatus); console.log(protocolLifecycle.projectedStatus); diff --git a/docs/api-references/transactions.md b/docs/api-references/transactions.md index a197734..09eb1d6 100644 --- a/docs/api-references/transactions.md +++ b/docs/api-references/transactions.md @@ -52,9 +52,12 @@ Polls until the stored transaction state is finalized. ### getTransactionLifecycle `advanced.getTransactionLifecycle` exposes stored/projected status, -resolution action/source, and active decision identity. Studio uses the -node RPC; contract networks use one fixed-block lifecycle read. `Finalize` -is an action, not a status or separate readiness field. +resolution action/source, and active decision identity. Contract networks +use one fixed-block lifecycle read. `Finalize` is an action, not a status +or separate readiness field. +A Studio deployment that does not yet serve `gen_getTransactionLifecycle` +degrades to the stored status its consumer surface does prove, rather +than failing the whole read. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| diff --git a/src/abi/studioConsensusTrain.ts b/src/abi/studioConsensus.ts similarity index 77% rename from src/abi/studioConsensusTrain.ts rename to src/abi/studioConsensus.ts index b522d81..d428dd4 100644 --- a/src/abi/studioConsensusTrain.ts +++ b/src/abi/studioConsensus.ts @@ -37,11 +37,11 @@ const addTransactionParamsComponents = [ ] as const; /** - * Studio's v0.123 transaction surface. The bundled legacy ConsensusMain ABI - * is retained for unrelated reads/events, while these entries replace every - * transaction function whose v0.6 signature is authoritative at admission. + * Current Studio transaction surface. Fee-aware admission and top-up are + * already live, while appeal and finalization remain decision-free. Studio's + * decision-bound parity work is qualified and released independently. */ -export const studioConsensusMainTrainAbi = [ +export const studioConsensusMainAbi = [ { type: "function", name: "addTransaction", @@ -72,7 +72,6 @@ export const studioConsensusMainTrainAbi = [ stateMutability: "payable", inputs: [ {name: "_txId", type: "bytes32"}, - {name: "_expectedDecisionId", type: "uint256"}, {name: "_feesDistribution", type: "tuple", components: feesDistributionComponents}, ], outputs: [], @@ -81,34 +80,26 @@ export const studioConsensusMainTrainAbi = [ type: "function", name: "submitAppeal", stateMutability: "payable", - inputs: [ - {name: "_txId", type: "bytes32"}, - {name: "_expectedDecisionId", type: "uint256"}, - ], + inputs: [{name: "_txId", type: "bytes32"}], outputs: [], }, { type: "function", name: "finalizeTransaction", stateMutability: "nonpayable", - inputs: [ - {name: "_txId", type: "bytes32"}, - {name: "_expectedDecisionId", type: "uint256"}, - ], + inputs: [{name: "_txId", type: "bytes32"}], outputs: [], }, ] as const satisfies Abi; -const overriddenNames = new Set( - studioConsensusMainTrainAbi.map((entry) => entry.name), -); +const overriddenNames = new Set(studioConsensusMainAbi.map((entry) => entry.name)); -export const withStudioConsensusMainTrainAbi = (base: readonly unknown[]): Abi => { +export const withStudioConsensusMainAbi = (base: readonly unknown[]): Abi => { const retained = base.filter((rawEntry) => { const entry = rawEntry as {type?: string; name?: string}; return entry.type !== "function" || entry.name === undefined || - !overriddenNames.has(entry.name as typeof studioConsensusMainTrainAbi[number]["name"]); + !overriddenNames.has(entry.name as typeof studioConsensusMainAbi[number]["name"]); }); - return [...retained, ...studioConsensusMainTrainAbi] as Abi; + return [...retained, ...studioConsensusMainAbi] as Abi; }; diff --git a/src/chains/localnet.ts b/src/chains/localnet.ts index b18682c..b15f8d8 100644 --- a/src/chains/localnet.ts +++ b/src/chains/localnet.ts @@ -1,6 +1,6 @@ import {Address, defineChain} from "viem"; import {GenLayerChain} from "@/types"; -import {withStudioConsensusMainTrainAbi} from "@/abi/studioConsensusTrain"; +import {withStudioConsensusMainAbi} from "@/abi/studioConsensus"; // chains/localnet.ts const SIMULATOR_JSON_RPC_URL = "http://127.0.0.1:4000/api"; @@ -1381,11 +1381,6 @@ const CONSENSUS_MAIN_CONTRACT_BASE = { name: "_txId", type: "bytes32", }, - { - internalType: "uint256", - name: "_expectedDecisionId", - type: "uint256", - }, ], name: "submitAppeal", outputs: [], @@ -1443,7 +1438,7 @@ const CONSENSUS_MAIN_CONTRACT_BASE = { const CONSENSUS_MAIN_CONTRACT = { ...CONSENSUS_MAIN_CONTRACT_BASE, - abi: withStudioConsensusMainTrainAbi(CONSENSUS_MAIN_CONTRACT_BASE.abi), + abi: withStudioConsensusMainAbi(CONSENSUS_MAIN_CONTRACT_BASE.abi), }; const CONSENSUS_DATA_CONTRACT = { diff --git a/src/chains/studionet.ts b/src/chains/studionet.ts index 7af4af0..8a8c7cb 100644 --- a/src/chains/studionet.ts +++ b/src/chains/studionet.ts @@ -1,6 +1,6 @@ import {Address, defineChain} from "viem"; import {GenLayerChain} from "@/types"; -import {withStudioConsensusMainTrainAbi} from "@/abi/studioConsensusTrain"; +import {withStudioConsensusMainAbi} from "@/abi/studioConsensus"; // chains/localnet.ts const SIMULATOR_JSON_RPC_URL = "https://studio.genlayer.com/api"; @@ -1382,11 +1382,6 @@ const CONSENSUS_MAIN_CONTRACT_BASE = { name: "_txId", type: "bytes32", }, - { - internalType: "uint256", - name: "_expectedDecisionId", - type: "uint256", - }, ], name: "submitAppeal", outputs: [], @@ -1444,7 +1439,7 @@ const CONSENSUS_MAIN_CONTRACT_BASE = { const CONSENSUS_MAIN_CONTRACT = { ...CONSENSUS_MAIN_CONTRACT_BASE, - abi: withStudioConsensusMainTrainAbi(CONSENSUS_MAIN_CONTRACT_BASE.abi), + abi: withStudioConsensusMainAbi(CONSENSUS_MAIN_CONTRACT_BASE.abi), }; const CONSENSUS_DATA_CONTRACT = { diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 48aa771..44cc6d6 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -11,7 +11,6 @@ import { CalldataEncodable, Address, TransactionHashVariant, - TransactionHash, TransactionFeeOptions, TransactionFeeEstimate, FeeEstimateOptions, @@ -40,7 +39,6 @@ import { normalizeTransactionFees, NormalizedTransactionFees, } from "@/transactions/fees"; -import {isMethodNotFoundError, readStudioLifecycleFallback} from "@/transactions/lifecycleFallback"; import {CONSENSUS_DATA_TRAIN_ABI, ROUNDS_STORAGE_TRAIN_READ_ABI} from "@/abi/consensusTrain"; const prefixHex = (hex: string): `0x${string}` => { @@ -603,13 +601,23 @@ export const contractActions = (client: GenLayerClient, publicCli observed, }; }, - /** Returns the full authoritative appeal charge (bond plus appeal funding). */ + /** + * Returns the full authoritative appeal charge (bond plus appeal funding) + * on resolution-kernel contract networks. Current Studio has no + * decision-bound quote surface. + */ getAppealCharge: async (args: {txId: `0x${string}`}): Promise => { + if (client.chain.isStudio) { + throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); + } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, /** @deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. */ getMinAppealBond: async (args: {txId: `0x${string}`}): Promise => { + if (client.chain.isStudio) { + throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); + } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, @@ -667,18 +675,6 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** Checks if a transaction can be appealed. */ canAppeal: async (args: {txId: `0x${string}`}): Promise => { - if (client.chain.isStudio) { - try { - await _readAppealContext({client, publicClient, txId: args.txId}); - return true; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (/no active decision to appeal|CanNotAppeal|cannot appeal/i.test(message)) { - return false; - } - throw error; - } - } if (!client.chain.appealsContract?.address) { throw new Error("canAppeal not supported on this chain (missing appealsContract)"); } @@ -810,7 +806,12 @@ export const contractActions = (client: GenLayerClient, publicCli operationName: "Claim NFT epochs", }); }, - /** Appeals a consensus transaction to trigger a new round of validation. */ + /** + * Appeals a consensus transaction to trigger a new round of validation. + * Contract networks bind the call to the active decision and quote an + * omitted value. Current Studio uses its native decision-free entrypoint; + * its value defaults to zero when omitted. + */ appealTransaction: async (args: { account?: Account; txId: `0x${string}`; @@ -822,6 +823,18 @@ export const contractActions = (client: GenLayerClient, publicCli // Appeals don't go through _sendTransaction because submitAppeal emits // AppealStarted/TransactionActivated events, not NewTransaction/CreatedTransaction. // The appeal operates on the same GenLayer txId, so we return it directly. + if (client.chain.isStudio) { + await _sendConsensusCall({ + client, + publicClient, + encodedData: _encodeStudioSubmitAppealData({txId}), + senderAccount, + value: args.value ?? 0n, + operationName: "Appeal", + }); + return txId; + } + const context = await _readAppealContext({ client, publicClient, @@ -870,6 +883,9 @@ export const contractActions = (client: GenLayerClient, publicCli /** * Deposits appeal fee budget and submits an appeal in the same consensus call. * Returns the existing GenLayer transaction id, matching appealTransaction. + * Contract networks bind the call to the active decision and quote an + * omitted value. Current Studio uses its native decision-free entrypoint; + * its value defaults to zero when omitted. */ topUpAndSubmitAppeal: async (args: { account?: Account; @@ -880,6 +896,18 @@ export const contractActions = (client: GenLayerClient, publicCli const {account, txId, distribution} = args; const senderAccount = account || client.account; + if (client.chain.isStudio) { + await _sendConsensusCall({ + client, + publicClient, + encodedData: _encodeStudioTopUpAndSubmitAppealData({txId, distribution}), + senderAccount, + value: args.value ?? 0n, + operationName: "Top up and submit appeal", + }); + return txId; + } + const context = await _readAppealContext({ client, publicClient, @@ -911,6 +939,20 @@ export const contractActions = (client: GenLayerClient, publicCli const {account, txId} = args; const senderAccount = account || client.account; + if (client.chain.isStudio) { + return _sendConsensusCall({ + client, + publicClient, + encodedData: encodeFunctionData({ + abi: client.chain.consensusMainContract?.abi as any, + functionName: "finalizeTransaction", + args: [txId], + }), + senderAccount, + operationName: "Finalize", + }); + } + const identity = await _readLifecycleIdentity({client, publicClient, txId}); if (!identity.decisionActive) { throw new Error(`Transaction ${txId} has no active decision to finalize`); @@ -1128,6 +1170,20 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ }, ] as const; +/** Studio's current embedded consensus does not carry decision identities. */ +const CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI = [ + { + type: "function", + name: "topUpAndSubmitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, +] as const; + const CONSENSUS_APPEAL_TRAIN_ABI = [ { type: "function", @@ -2029,6 +2085,37 @@ const _encodeSubmitAppealData = ({ }); }; +const STUDIO_APPEAL_QUOTE_UNSUPPORTED = + "Appeal bond calculation not supported on this chain (missing feeManagerContract/roundsStorageContract)"; + +const _encodeStudioSubmitAppealData = ({ + txId, +}: { + txId: `0x${string}`; +}): `0x${string}` => encodeFunctionData({ + abi: [{ + type: "function", + name: "submitAppeal", + stateMutability: "payable", + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }], + functionName: "submitAppeal", + args: [txId], +}); + +const _encodeStudioTopUpAndSubmitAppealData = ({ + txId, + distribution, +}: { + txId: `0x${string}`; + distribution: FeesDistributionInput; +}): `0x${string}` => encodeFunctionData({ + abi: CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI, + functionName: "topUpAndSubmitAppeal", + args: [txId, createFeesDistribution(distribution)], +}); + const _studioTrainBatchError = (action: string): Error => new Error( `${action} is not exposed by Studio's embedded consensus: ` + @@ -2132,9 +2219,6 @@ const _readRoundDataSnapshot = async ({ }; }; -/** Studio has no per-attempt identity to report; the train reads a real one. */ -const ZERO_ATTEMPT_ID = `0x${"00".repeat(32)}` as `0x${string}`; - type LifecycleIdentity = { blockNumber: bigint; blockTimestamp: bigint; @@ -2157,54 +2241,6 @@ const _readLifecycleIdentity = async ({ blockNumber?: bigint; blockTimestamp?: bigint; }): Promise => { - if (client.chain.isStudio) { - type StudioLifecycleIdentity = { - resolutionActionCode: unknown; - decisionActive: unknown; - decisionId: unknown; - evaluatedAt: unknown; - }; - let lifecycle: StudioLifecycleIdentity; - try { - lifecycle = await client.request({ - method: "gen_getTransactionLifecycle", - params: [{txId: txId as TransactionHash}], - }) as StudioLifecycleIdentity; - } catch (error) { - if (!isMethodNotFoundError(error)) throw error; - // A Studio deployment without the lifecycle RPC proves no decision - // identity at all. Report that gap instead of inventing a decision id: - // a fabricated id would be signed into an appeal or a finalization. - const fallback = await readStudioLifecycleFallback({ - client, - hash: txId as TransactionHash, - cause: error, - }); - return { - blockNumber: 0n, - blockTimestamp: BigInt(fallback.evaluatedAt), - resolutionAction: fallback.resolutionActionCode, - attemptId: ZERO_ATTEMPT_ID, - decisionActive: false, - decisionId: 0n, - }; - } - if (typeof lifecycle.decisionActive !== "boolean") { - throw new Error( - `Invalid Studio lifecycle decisionActive: ${String(lifecycle.decisionActive)}`, - ); - } - const decisionActive = lifecycle.decisionActive; - return { - blockNumber: 0n, - blockTimestamp: BigInt(lifecycle.evaluatedAt as string | number | bigint), - resolutionAction: Number(lifecycle.resolutionActionCode), - attemptId: ZERO_ATTEMPT_ID, - decisionActive, - decisionId: decisionActive ? BigInt(lifecycle.decisionId as string) : 0n, - }; - } - const consensusDataAddress = client.chain.consensusDataContract?.address as Address | undefined; if (!consensusDataAddress || consensusDataAddress === zeroAddress) { throw new Error("ConsensusData contract is not configured for this chain"); @@ -2261,23 +2297,6 @@ const _readAppealContext = async ({ return {...identity, requiredValue: 0n}; } - if (client.chain.isStudio) { - const quote = await client.request({ - method: "gen_estimateLatestAppealCharge", - params: [{txId: txId as TransactionHash}], - }) as {decisionId: unknown; bond: unknown; funding: unknown}; - const quoteDecisionId = BigInt(quote.decisionId as string); - if (quoteDecisionId !== identity.decisionId) { - throw new Error( - `Appeal decision changed while reading ${txId}: expected ${identity.decisionId}, received ${quoteDecisionId}`, - ); - } - return { - ...identity, - requiredValue: BigInt(quote.bond as string) + BigInt(quote.funding as string), - }; - } - const consensusDataAddress = client.chain.consensusDataContract!.address as Address; const quote = await publicClient.readContract({ address: consensusDataAddress, diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 67e7a9e..1b2c455 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -313,9 +313,9 @@ export const transactionActions = (client: GenLayerClient, public advanced: { /** * `advanced.getTransactionLifecycle` exposes stored/projected status, - * resolution action/source, and active decision identity. Studio uses the - * node RPC; contract networks use one fixed-block lifecycle read. `Finalize` - * is an action, not a status or separate readiness field. + * resolution action/source, and active decision identity. Contract networks + * use one fixed-block lifecycle read. `Finalize` is an action, not a status + * or separate readiness field. * * A Studio deployment that does not yet serve `gen_getTransactionLifecycle` * degrades to the stored status its consumer surface does prove, rather diff --git a/tests/chains-actions.test.ts b/tests/chains-actions.test.ts index 38d6518..e869fd3 100644 --- a/tests/chains-actions.test.ts +++ b/tests/chains-actions.test.ts @@ -54,7 +54,7 @@ describe.each([ ["localnet", localnet], ["studionet", studionet], ])("%s Studio consensus ABI", (_name, chain) => { - it("exports only the v0.6 fee and exact-decision transaction signatures", () => { + it("exports Studio's fee-aware, decision-free transaction signatures", () => { const functions = new Map( (chain.consensusMainContract!.abi as readonly TestAbiEntry[]) .filter((entry) => entry.type === "function" && entry.name) @@ -66,13 +66,9 @@ describe.each([ expect(inputTypes("addTransaction")).toEqual(["tuple"]); expect(inputTypes("deploySalted")).toEqual(["tuple"]); expect(inputTypes("topUpFees")).toEqual(["bytes32", "tuple"]); - expect(inputTypes("topUpAndSubmitAppeal")).toEqual([ - "bytes32", - "uint256", - "tuple", - ]); - expect(inputTypes("submitAppeal")).toEqual(["bytes32", "uint256"]); - expect(inputTypes("finalizeTransaction")).toEqual(["bytes32", "uint256"]); + expect(inputTypes("topUpAndSubmitAppeal")).toEqual(["bytes32", "tuple"]); + expect(inputTypes("submitAppeal")).toEqual(["bytes32"]); + expect(inputTypes("finalizeTransaction")).toEqual(["bytes32"]); const addParams = functions.get("addTransaction")!.inputs![0].components!; expect(addParams.map((component) => component.type)).toEqual([ diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 34e8f9e..85887b2 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -4,11 +4,9 @@ import { decodeFunctionData, encodeFunctionData, keccak256, - MethodNotFoundRpcError, toHex, } from "viem"; import {contractActions} from "../src/contracts/actions"; -import {TransactionStatus, transactionsStatusNameToNumber} from "../src/types/transactions"; import {NFT_MINTER_ABI} from "../src/abi/nftMinter"; import { CALL_KEY_DEPLOY, @@ -2440,15 +2438,32 @@ describe("contractActions train lifecycle batches", () => { }); }); -/** Exact-decision finalization entrypoint exposed by Studio's v0.123 train. */ +/** Current Studio entrypoints intentionally remain independent of the v0.6 train. */ const CONSENSUS_MAIN_STUDIO_ABI = [ + { + type: "function" as const, + name: "submitAppeal", + stateMutability: "payable" as const, + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, { type: "function" as const, name: "finalizeTransaction", stateMutability: "nonpayable" as const, + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, +] as const; + +const FEE_MANAGEMENT_STUDIO_ABI = [ + { + type: "function" as const, + name: "topUpAndSubmitAppeal", + stateMutability: "payable" as const, inputs: [ {name: "_txId", type: "bytes32"}, - {name: "_expectedDecisionId", type: "uint256"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, ], outputs: [], }, @@ -2456,30 +2471,15 @@ const CONSENSUS_MAIN_STUDIO_ABI = [ /** * Mirrors the localnet/studionet chain config: ConsensusMain and ConsensusData - * resolve, while lifecycle identity and appeal quotes are exposed by Studio's - * train-compatible RPC projection rather than EVM reads. + * resolve, but the fee manager, rounds storage, and appeals contracts carry no + * address. Studio stays a consumer of this SDK without joining this train. */ -const setupStudioLifecycleHarness = ({ - resolutionAction = 0, - lifecycleRpcError, - studioStatus = TransactionStatus.ACCEPTED, -}: { - resolutionAction?: number; - /** Thrown instead of answering gen_getTransactionLifecycle. */ - lifecycleRpcError?: unknown; - studioStatus?: TransactionStatus; -} = {}) => { +const setupStudioLifecycleHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: "success", logs: []}); - const getTransaction = vi.fn().mockResolvedValue({ - txId: MOCK_GENLAYER_TX_ID, - status: Number(transactionsStatusNameToNumber[studioStatus]), - statusName: studioStatus, - }); const client = { - getTransaction, chain: { id: 61_999, isStudio: true, @@ -2507,32 +2507,12 @@ const setupStudioLifecycleHarness = ({ sendRawTransaction, request: vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; - if (method === "gen_getTransactionLifecycle") { - if (lifecycleRpcError !== undefined) throw lifecycleRpcError; - return { - storedStatusCode: 5, - projectedStatusCode: 5, - resolutionActionCode: resolutionAction, - resolutionSourceCode: 6, - decisionId: MOCK_DECISION_ID.toString(), - decisionActive: true, - evaluatedAt: "456", - }; - } - if (method === "gen_estimateLatestAppealCharge") { - return { - decisionId: MOCK_DECISION_ID.toString(), - bond: "1000", - funding: "234", - appealDeadline: "789", - }; - } throw new Error(`Unexpected RPC method: ${method}`); }), }; - // Studio lifecycle reads stay on its authoritative RPC projection; the - // embedded ConsensusData address is not a state-authoritative EVM contract. + // Any train lifecycle read here is a regression: current Studio is a consumer + // of this SDK, not a member of the resolution-kernel landing cut. const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { throw new Error(`Unexpected consensus read on Studio: ${functionName}`); }); @@ -2548,21 +2528,13 @@ const setupStudioLifecycleHarness = ({ sendRawTransaction, waitForTransactionReceipt, readContract, - getTransaction, client, publicClient, }; }; -describe("contractActions Studio lifecycle compatibility", () => { - it("answers canAppeal from Studio's authoritative decision quote", async () => { - const {actions, readContract} = setupStudioLifecycleHarness(); - - await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(true); - expect(readContract).not.toHaveBeenCalled(); - }); - - it("encodes submitAppeal with Studio's active decision id", async () => { +describe("contractActions current Studio surface", () => { + it("encodes Studio's native submitAppeal(bytes32) without a train lifecycle read", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).resolves.toBe( @@ -2573,21 +2545,21 @@ describe("contractActions Studio lifecycle compatibility", () => { const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.to).toBe(MAIN_CONTRACT_ADDRESS); expect(txRequest.value).toBe(500n); - const decoded = decodeFunctionData({abi: APPEAL_TRAIN_ABI, data: txRequest.data}); + const decoded = decodeFunctionData({abi: CONSENSUS_MAIN_STUDIO_ABI, data: txRequest.data}); expect(decoded.functionName).toBe("submitAppeal"); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); }); - it("uses Studio's authoritative appeal quote when the caller omits value", async () => { + it("defaults the Studio appeal value to zero when the caller omits it", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID}); expect(readContract).not.toHaveBeenCalled(); - expect(signTransaction.mock.calls[0][0].value).toBe(1234n); + expect(signTransaction.mock.calls[0][0].value).toBe(0n); }); - it("encodes topUpAndSubmitAppeal with Studio's active decision id", async () => { + it("encodes Studio's native topUpAndSubmitAppeal without a decision id", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); const txId = await actions.topUpAndSubmitAppeal({ @@ -2600,18 +2572,15 @@ describe("contractActions Studio lifecycle compatibility", () => { expect(readContract).not.toHaveBeenCalled(); const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.value).toBe(1234n); - const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_ABI as any, data: txRequest.data}); - const [decodedTxId, expectedDecisionId, distribution] = decoded.args as any[]; + const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_STUDIO_ABI as any, data: txRequest.data}); + const [decodedTxId, distribution] = decoded.args as any[]; expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); - expect(expectedDecisionId).toBe(MOCK_DECISION_ID); expect(distribution.appealRounds).toBe(1n); expect(distribution.rotations).toEqual([0n, 1n]); }); - it("encodes finalizeTransaction with Studio's active decision id", async () => { - const {actions, signTransaction, readContract} = setupStudioLifecycleHarness({ - resolutionAction: 6, - }); + it("encodes Studio's native finalizeTransaction(bytes32)", async () => { + const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await expect(actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( MOCK_EVM_TX_HASH, @@ -2623,14 +2592,27 @@ describe("contractActions Studio lifecycle compatibility", () => { data: signTransaction.mock.calls[0][0].data, }); expect(decoded.functionName).toBe("finalizeTransaction"); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); }); - it("reads appeal charges from Studio's authoritative RPC projection", async () => { + it("reports that the current Studio has no authoritative appeal quote", async () => { const {actions, readContract} = setupStudioLifecycleHarness(); - await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); - await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); + await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing feeManagerContract\/roundsStorageContract/, + ); + await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing feeManagerContract\/roundsStorageContract/, + ); + expect(readContract).not.toHaveBeenCalled(); + }); + + it("reports the missing appeals contract from canAppeal", async () => { + const {actions, readContract} = setupStudioLifecycleHarness(); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( + /missing appealsContract/, + ); expect(readContract).not.toHaveBeenCalled(); }); @@ -2662,71 +2644,3 @@ describe("contractActions Studio lifecycle compatibility", () => { expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); }); }); - -/** The raw JSON-RPC error the SDK transport throws before viem types it. */ -const RAW_METHOD_NOT_FOUND = {code: -32601, message: "Method not found"}; - -describe("contractActions Studio lifecycle degradation", () => { - it("reports no decision instead of failing when Studio omits the lifecycle RPC", async () => { - const {actions, getTransaction, readContract} = setupStudioLifecycleHarness({ - lifecycleRpcError: new MethodNotFoundRpcError(new Error("Method not found"), { - method: "gen_getTransactionLifecycle", - }), - }); - - await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(false); - expect(getTransaction).toHaveBeenCalledWith({hash: MOCK_GENLAYER_TX_ID}); - expect(readContract).not.toHaveBeenCalled(); - }); - - it("recognizes the untyped -32601 error the SDK transport raises", async () => { - const {actions, getTransaction} = setupStudioLifecycleHarness({ - lifecycleRpcError: RAW_METHOD_NOT_FOUND, - }); - - await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(false); - expect(getTransaction).toHaveBeenCalledTimes(1); - }); - - it("refuses to appeal a decision it cannot read instead of signing a zero decision id", async () => { - const {actions, signTransaction} = setupStudioLifecycleHarness({ - lifecycleRpcError: RAW_METHOD_NOT_FOUND, - }); - - await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).rejects.toThrow( - /has no active decision to appeal/, - ); - expect(signTransaction).not.toHaveBeenCalled(); - }); - - it("refuses to finalize a decision it cannot read", async () => { - const {actions, signTransaction} = setupStudioLifecycleHarness({ - lifecycleRpcError: RAW_METHOD_NOT_FOUND, - }); - - await expect(actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( - /has no active decision to finalize/, - ); - expect(signTransaction).not.toHaveBeenCalled(); - }); - - it("propagates a lifecycle RPC failure that is not a missing method", async () => { - const {actions, getTransaction} = setupStudioLifecycleHarness({ - lifecycleRpcError: {code: -32000, message: "execution reverted"}, - }); - - await expect( - actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n}), - ).rejects.toEqual({code: -32000, message: "execution reverted"}); - expect(getTransaction).not.toHaveBeenCalled(); - }); - - it("re-raises the original RPC error when the Studio status is unreadable", async () => { - const harness = setupStudioLifecycleHarness({lifecycleRpcError: RAW_METHOD_NOT_FOUND}); - harness.getTransaction.mockResolvedValue({txId: MOCK_GENLAYER_TX_ID, status: "NOT_A_STATUS"}); - - await expect(harness.actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).rejects.toEqual( - RAW_METHOD_NOT_FOUND, - ); - }); -}); From 8f72796efa6f1f52d420957bd253771c8583ca14 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 28 Aug 2026 20:32:41 +0100 Subject: [PATCH 12/12] fix(fees): fund default consensus rotations in estimates --- README.md | 5 +- docs/api-references/index.md | 5 +- src/contracts/actions.ts | 54 +++++++++++++++++--- tests/contracts-actions.test.ts | 89 +++++++++++++++++++++++++++++++-- 4 files changed, 138 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index caeff92..7cdf046 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,6 @@ values in wallet or app UI before signing. const estimate = await client.estimateTransactionFees({ leaderTimeunitsAllocation: 100n, validatorTimeunitsAllocation: 200n, - rotations: [0n], }); const txHash = await client.writeContract({ @@ -132,6 +131,10 @@ const txHash = await client.writeContract({ }); ``` +When `rotations` is omitted from an estimate, the SDK funds every round through +the chain's `defaultConsensusMaxRotations`. Pass `rotations` explicitly—including +`[0n]`—when the transaction should use a lower rotation budget. + If `fees.distribution` is provided without `feeValue`, the SDK derives the fee deposit from FeeManager on network backends, or from `sim_getFeeConfig` on Studio. Use `messageAllocations` with `estimateTransactionFees` for transactions diff --git a/docs/api-references/index.md b/docs/api-references/index.md index bed07bb..bc6e3cc 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -113,7 +113,6 @@ values in wallet or app UI before signing. const estimate = await client.estimateTransactionFees({ leaderTimeunitsAllocation: 100n, validatorTimeunitsAllocation: 200n, - rotations: [0n], }); const txHash = await client.writeContract({ @@ -128,6 +127,10 @@ const txHash = await client.writeContract({ }); ``` +When `rotations` is omitted from an estimate, the SDK funds every round through +the chain's `defaultConsensusMaxRotations`. Pass `rotations` explicitly—including +`[0n]`—when the transaction should use a lower rotation budget. + If `fees.distribution` is provided without `feeValue`, the SDK derives the fee deposit from FeeManager on network backends, or from `sim_getFeeConfig` on Studio. Use `messageAllocations` with `estimateTransactionFees` for transactions diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 44cc6d6..d2de6c3 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -456,10 +456,17 @@ export const contractActions = (client: GenLayerClient, publicCli getCurrentFeePolicy: async (): Promise => { return readCurrentFeePolicy(client, publicClient); }, - /** Builds a fee distribution with caps derived from the active fee policy. */ + /** + * Builds a fee distribution with caps derived from the active fee policy. + * Omitted rotations fund the chain's configured consensus maximum. + */ estimateFeesDistribution: async (args?: FeeEstimateOptions): Promise => { const policy = await readCurrentFeePolicy(client, publicClient); - return buildEstimatedFeesDistribution(args, policy); + return buildEstimatedFeesDistribution( + args, + policy, + client.chain.defaultConsensusMaxRotations, + ); }, /** * Builds a complete transaction `fees` object, including feeValue. @@ -468,7 +475,11 @@ export const contractActions = (client: GenLayerClient, publicCli */ estimateTransactionFees: async (args?: FeeEstimateOptions): Promise => { const policy = await readCurrentFeePolicy(client, publicClient); - const distribution = buildEstimatedFeesDistribution(args, policy); + const distribution = buildEstimatedFeesDistribution( + args, + policy, + client.chain.defaultConsensusMaxRotations, + ); return { distribution, @@ -489,7 +500,11 @@ export const contractActions = (client: GenLayerClient, publicCli const policy = await readCurrentFeePolicy(client, publicClient); const {estimateOptions, observed, messageAllocations} = buildEstimatedFeesOptionsFromSimulation(args, policy); - const distribution = buildEstimatedFeesDistribution(estimateOptions, policy); + const distribution = buildEstimatedFeesDistribution( + estimateOptions, + policy, + client.chain.defaultConsensusMaxRotations, + ); return { distribution, @@ -523,7 +538,11 @@ export const contractActions = (client: GenLayerClient, publicCli } = args; const policy = await readCurrentFeePolicy(client, publicClient); - const initialDistribution = buildEstimatedFeesDistribution(feeOptions, policy); + const initialDistribution = buildEstimatedFeesDistribution( + feeOptions, + policy, + client.chain.defaultConsensusMaxRotations, + ); const initialEstimate: TransactionFeeEstimate = { distribution: initialDistribution, messageAllocations: feeOptions.messageAllocations, @@ -591,7 +610,11 @@ export const contractActions = (client: GenLayerClient, publicCli }, policy, ); - const distribution = buildEstimatedFeesDistribution(estimateOptions, policy); + const distribution = buildEstimatedFeesDistribution( + estimateOptions, + policy, + client.chain.defaultConsensusMaxRotations, + ); return { distribution, @@ -1596,6 +1619,7 @@ const defaultExecutionBudgetPerRound = (policy: FeePolicyQuote): bigint => { const buildEstimatedFeesDistribution = ( options: FeeEstimateOptions | undefined, policy: FeePolicyQuote, + defaultConsensusMaxRotations: number, ): FeesDistribution => { const headroomBps = toUInt( options?.priceCapHeadroomBps, @@ -1628,6 +1652,20 @@ const buildEstimatedFeesDistribution = ( const executionBudgetDefault = emitsMessages ? baseExecutionBudgetDefault + (policy.receiptGasPrice * DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM) : baseExecutionBudgetDefault; + const appealRounds = toUInt(options?.appealRounds, "appealRounds", 0n); + const rotationsCount = Number(appealRounds + 1n); + if (!Number.isSafeInteger(rotationsCount)) { + throw new Error("rotations appealRounds is too large."); + } + let rotations = options?.rotations; + if (rotations === undefined) { + const defaultRotationBudget = toUInt( + defaultConsensusMaxRotations, + "defaultConsensusMaxRotations", + 0n, + ); + rotations = Array.from({length: rotationsCount}, () => defaultRotationBudget); + } return createFeesDistribution({ leaderTimeunitsAllocation: options?.leaderTimeunitsAllocation ?? ( @@ -1636,11 +1674,11 @@ const buildEstimatedFeesDistribution = ( validatorTimeunitsAllocation: options?.validatorTimeunitsAllocation ?? ( policy.enabled ? DEFAULT_VALIDATOR_TIMEUNITS_ALLOCATION : 0n ), - appealRounds: options?.appealRounds, + appealRounds, executionBudgetPerRound: options?.executionBudgetPerRound ?? executionBudgetDefault, executionConsumed: options?.executionConsumed, totalMessageFees, - rotations: options?.rotations, + rotations, maxPriceGenPerTimeUnit: options?.maxPriceGenPerTimeUnit ?? withCapHeadroom(policy.genPerTimeUnit, headroomBps), storageFeeMaxGasPrice: diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 85887b2..f91f208 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -893,6 +893,85 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(estimateTransactionGas.mock.calls[0][0].value).toBe(11_000n); }); + it("defaults direct fee estimates to the chain rotation budget for every appeal round", async () => { + const requestMock = vi.fn().mockResolvedValue({ + enabled: false, + policy: { + genPerTimeUnit: "0", + storageUnitPrice: "0", + receiptGasPrice: "0", + }, + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const defaulted = await actions.estimateFeesDistribution({appealRounds: 2n}); + const explicit = await actions.estimateFeesDistribution({rotations: [0n]}); + + expect(defaulted.rotations).toEqual([3n, 3n, 3n]); + expect(explicit.rotations).toEqual([0n]); + }); + + it("defaults simulation fee estimates to the chain rotation budget", async () => { + const requestMock = vi.fn().mockResolvedValue({ + enabled: false, + policy: { + genPerTimeUnit: "0", + storageUnitPrice: "0", + receiptGasPrice: "0", + }, + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFeesFromSimulation({ + appealRounds: 1n, + simulation: {}, + }); + + expect(fees.distribution.rotations).toEqual([3n, 3n]); + }); + + it("uses the chain rotation budget in write estimation requests and fallback results", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: false, + policy: { + genPerTimeUnit: "0", + storageUnitPrice: "0", + receiptGasPrice: "0", + }, + }; + } + if (method === "sim_estimateTransactionFees") return {}; + if (method === "gen_call") return {data: "0x"}; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFeesForWrite({ + address: RECIPIENT_ADDRESS, + functionName: "ping", + }); + + const estimateRequest = requestMock.mock.calls.find( + ([call]) => call.method === "sim_estimateTransactionFees", + )?.[0]; + expect(estimateRequest.params[0].fees.distribution.rotations).toEqual(["3"]); + expect(fees.distribution.rotations).toEqual([3n]); + }); + it("estimates fee distribution caps and fee value from FeeManager prices", async () => { const publicClient = { readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { @@ -1078,7 +1157,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.distribution.storageFeeMaxGasPrice).toBe(20n); expect(fees.distribution.receiptFeeMaxGasPrice).toBe(30n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); - expect(fees.feeValue).toBe(3_000_011_000n); + expect(fees.feeValue).toBe(12_000_044_000n); }); it("prefers Studio's exposed message fee budget floor over local fallback math", async () => { @@ -1108,7 +1187,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.policy.executionBudgetFloor).toBe(700_000n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); - expect(fees.feeValue).toBe(3_000_011_000n); + expect(fees.feeValue).toBe(12_000_044_000n); }); it("builds a Studio trusted fee preset from a simulation fee report", async () => { @@ -1252,7 +1331,7 @@ describe("contractActions addTransaction ABI compatibility", () => { }); expect(fees.distribution.executionBudgetPerRound).toBe(expectedExecutionBudgetPerRound); expect(fees.distribution.totalMessageFees).toBe(6n); - expect(fees.feeValue).toBe(613_123n); + expect(fees.feeValue).toBe(2_452_474n); }); it("uses the execution budget floor for simulation recommendations when observed usage is lower", async () => { @@ -1389,7 +1468,7 @@ describe("contractActions addTransaction ABI compatibility", () => { const simCall = requestMock.mock.calls.find(([call]) => call.method === "sim_estimateTransactionFees")?.[0]; expect(simCall).toBeDefined(); expect(simCall.params[0].value).toBe("0x7"); - expect(simCall.params[0].fees.feeValue).toBe("3000311110"); + expect(simCall.params[0].fees.feeValue).toBe("12001244110"); expect(simCall.params[0].fees.distribution.totalMessageFees).toBe("110"); expect(simCall.params[0].fees.messageAllocations[0].budget).toBe("110"); expect(fees.observed?.recommendedExecutionBudgetPerRound).toBe(602_117n); @@ -1451,7 +1530,7 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.messageAllocations?.[0].budget).toBe(50n); expect(fees.messageAllocations?.[0].feeParams).toBe(feeParams); expect(fees.distribution.totalMessageFees).toBe(50n); - expect(fees.feeValue).toBe(3_000_311_050n); + expect(fees.feeValue).toBe(12_001_244_050n); }); it("keeps Studio fee estimation gasless when sim_getFeeConfig is disabled", async () => {