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/README.md b/README.md index fd9aa57..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 @@ -267,11 +270,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,6 +299,27 @@ Transactions can emit messages to other contracts. These messages create new chi ```typescript const tx = await client.getTransaction({ hash: txHash }); +// 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. 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); +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. +console.log(tx.txExecutionHash); + // Messages emitted by the contract during execution console.log(tx.messages); // [{messageType, recipient, value, data, onAcceptance, saltNonce}, ...] @@ -428,9 +451,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..ecb3e31 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,94 @@ 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) +on resolution-kernel contract networks. Current Studio has no +decision-bound quote surface. + +| 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,15 +212,217 @@ 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. +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 | +|-----------|------|----------|-------------| +| account | `Account` | no | | +| txId | ``0x${string}`` | yes | | +| value | `bigint` | no | | + +--- + +### 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. +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 | |-----------|------|----------|-------------| | 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..bc6e3cc 100644 --- a/docs/api-references/index.md +++ b/docs/api-references/index.md @@ -103,16 +103,173 @@ 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, +}); + +const txHash = await client.writeContract({ + account, + address: contractAddress, + functionName: "update_storage", + args: ["new_storage"], + fees: { + distribution: estimate.distribution, + feeValue: estimate.feeValue, + }, +}); +``` + +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 +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: ```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) { @@ -138,6 +295,27 @@ Transactions can emit messages to other contracts. These messages create new chi ```typescript const tx = await client.getTransaction({ hash: txHash }); +// 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. 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); +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. +console.log(tx.txExecutionHash); + // Messages emitted by the contract during execution console.log(tx.messages); // [{messageType, recipient, value, data, onAcceptance, saltNonce}, ...] @@ -164,6 +342,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 +447,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..09eb1d6 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 | | @@ -18,9 +19,59 @@ 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. 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 | +|-----------|------|----------|-------------| +| hash | `TransactionHash` | yes | | +| timestamp | `number` | no | | + +**Returns:** `TransactionProtocolLifecycle` + +--- + ### getTransaction -Fetches transaction data including status, execution result, and consensus details. +Fetches a transaction with a simple stored lifecycle and split round data. +Use advanced.getTransactionLifecycle for protocol projection/action details. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -67,6 +118,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..38b6a95 --- /dev/null +++ b/src/abi/consensusTrain.ts @@ -0,0 +1,41 @@ +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 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 9f3ea14..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,14 +508,14 @@ export const STAKING_ABI = [ // Functions { - name: "activeValidators", + name: "selectableValidators", type: "function", stateMutability: "view", inputs: [], outputs: [{name: "", type: "address[]"}], }, { - name: "activeValidatorsCount", + name: "selectableValidatorsCount", type: "function", stateMutability: "view", inputs: [], @@ -572,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, }, ], }, @@ -602,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, }, ], }, @@ -651,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, }, ], }, @@ -681,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, }, ], }, @@ -1201,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, }, ], }, @@ -1229,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, }, ], }, @@ -1340,10 +1311,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"}, @@ -1366,10 +1334,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 +1357,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"}, @@ -1421,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, }, ], }, @@ -1442,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, }, ], }, @@ -1486,13 +1438,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 @@ -1516,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/abi/studioConsensus.ts b/src/abi/studioConsensus.ts new file mode 100644 index 0000000..d428dd4 --- /dev/null +++ b/src/abi/studioConsensus.ts @@ -0,0 +1,105 @@ +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; + +/** + * 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 studioConsensusMainAbi = [ + { + 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: "_feesDistribution", type: "tuple", components: feesDistributionComponents}, + ], + outputs: [], + }, + { + type: "function", + name: "submitAppeal", + stateMutability: "payable", + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, + { + type: "function", + name: "finalizeTransaction", + stateMutability: "nonpayable", + inputs: [{name: "_txId", type: "bytes32"}], + outputs: [], + }, +] as const satisfies Abi; + +const overriddenNames = new Set(studioConsensusMainAbi.map((entry) => entry.name)); + +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 studioConsensusMainAbi[number]["name"]); + }); + return [...retained, ...studioConsensusMainAbi] as Abi; +}; diff --git a/src/chains/localnet.ts b/src/chains/localnet.ts index 733a8d3..b15f8d8 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 {withStudioConsensusMainAbi} from "@/abi/studioConsensus"; // 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: [ { @@ -1435,6 +1436,11 @@ const CONSENSUS_MAIN_CONTRACT = { bytecode: "", }; +const CONSENSUS_MAIN_CONTRACT = { + ...CONSENSUS_MAIN_CONTRACT_BASE, + abi: withStudioConsensusMainAbi(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 4d9de5f..8a8c7cb 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 {withStudioConsensusMainAbi} from "@/abi/studioConsensus"; // 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: [ { @@ -1436,6 +1437,11 @@ const CONSENSUS_MAIN_CONTRACT = { bytecode: "", }; +const CONSENSUS_MAIN_CONTRACT = { + ...CONSENSUS_MAIN_CONTRACT_BASE, + abi: withStudioConsensusMainAbi(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 93a8f35..d2de6c3 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}`; @@ -454,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. @@ -466,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, @@ -487,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, @@ -521,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, @@ -589,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, @@ -599,32 +624,25 @@ 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) + * 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 => { - 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)"); + if (client.chain.isStudio) { + throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); } - - 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 +651,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 +661,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 +675,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. */ @@ -792,20 +829,47 @@ 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}`; value?: bigint; }) => { const {account, txId} = args; - const value = await _resolveAppealValue({client, publicClient, txId, value: args.value}); - const senderAccount = account || client.account; - const encodedData = _encodeSubmitAppealData({client, txId}); + // 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, + txId, + includeQuote: args.value === undefined, + }); + const value = args.value ?? context.requiredValue; + + const encodedData = _encodeSubmitAppealData({ + txId, + expectedDecisionId: context.decisionId, + }); await _sendConsensusCall({ client, publicClient, @@ -842,6 +906,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; @@ -850,10 +917,33 @@ 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 senderAccount = account || client.account; - const encodedData = _encodeTopUpAndSubmitAppealData({txId, distribution}); + + 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, + txId, + includeQuote: args.value === undefined, + }); + const value = args.value ?? context.requiredValue; + + const encodedData = _encodeTopUpAndSubmitAppealData({ + txId, + expectedDecisionId: context.decisionId, + distribution, + }); await _sendConsensusCall({ client, publicClient, @@ -871,10 +961,34 @@ export const contractActions = (client: GenLayerClient, publicCli }): 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`); + } + if (identity.resolutionAction !== 6) { + throw new Error( + `Transaction ${txId} is not ready to finalize (resolution action ${identity.resolutionAction})`, + ); + } 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 +998,103 @@ 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 (client.chain.isStudio) { + throw _studioTrainBatchError("resolveTransactions"); } - const senderAccount = account || client.account; + if (args.txIds.length === 0) { + throw new Error("resolveTransactions 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, + }), + )); 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 (client.chain.isStudio) { + throw _studioTrainBatchError("finalizeDecisions"); + } + 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`); + } + 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, + 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 +1121,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"}, @@ -1023,6 +1180,21 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ ], outputs: [], }, + { + type: "function", + name: "topUpAndSubmitAppeal", + stateMutability: "payable", + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, + ], + outputs: [], + }, +] as const; + +/** Studio's current embedded consensus does not carry decision identities. */ +const CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI = [ { type: "function", name: "topUpAndSubmitAppeal", @@ -1035,6 +1207,73 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ }, ] 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 +1316,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; @@ -1408,6 +1619,7 @@ const defaultExecutionBudgetPerRound = (policy: FeePolicyQuote): bigint => { const buildEstimatedFeesDistribution = ( options: FeeEstimateOptions | undefined, policy: FeePolicyQuote, + defaultConsensusMaxRotations: number, ): FeesDistribution => { const headroomBps = toUInt( options?.priceCapHeadroomBps, @@ -1440,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 ?? ( @@ -1448,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: @@ -1860,129 +2086,272 @@ 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 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: ` + + "use finalizeTransaction for an individual Studio transaction.", + ); + +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 +2370,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 +2557,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 +2785,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 cf46ab1..337cb82 100644 --- a/src/staking/actions.ts +++ b/src/staking/actions.ts @@ -1,8 +1,14 @@ 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"; + +// 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, @@ -282,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 @@ -466,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, @@ -618,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; @@ -641,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 @@ -674,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), @@ -691,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, @@ -719,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), @@ -767,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), @@ -791,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, @@ -835,7 +769,7 @@ export const stakingActions = ( ] = await Promise.all([ contract.read.epoch() as Promise, contract.read.finalized() as Promise, - contract.read.activeValidatorsCount() as Promise, + contract.read.selectableValidatorsCount() as Promise, contract.read.epochMinDuration() as Promise, contract.read.epochZeroMinDuration() as Promise, contract.read.epochOdd() as Promise, @@ -873,6 +807,8 @@ export const stakingActions = ( currentEpoch: epoch, lastFinalizedEpoch: finalized, activeValidatorsCount: activeCount, + totalWeight: currentEpochData.weight, + inflationRaw: currentEpochData.inflation, epochMinDuration, nextEpochEstimate, validatorMinStake: formatStakingAmount(valMinStake), @@ -920,17 +856,37 @@ export const stakingActions = ( }; }, - /** Returns addresses of all currently active validators. */ + /** Returns validators currently eligible for consensus duties. */ getActiveValidators: async (): Promise => { const contract = getReadOnlyStakingContract(); - const validators = (await contract.read.activeValidators()) as Address[]; - return validators.filter(v => v !== "0x0000000000000000000000000000000000000000"); + return contract.read.selectableValidators() as Promise; }, - /** Returns the count of active validators. */ + /** Returns the count of validators currently eligible for consensus duties. */ getActiveValidatorsCount: async (): Promise => { const contract = getReadOnlyStakingContract(); - return contract.read.activeValidatorsCount() as Promise; + 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[] = []; + + 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 size of the append-only joined validator registry. */ + getJoinedValidatorsCount: async (): Promise => { + const contract = getReadOnlyStakingContract(); + 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..1b2c455 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -11,12 +11,135 @@ import { isDecidedState, DebugTraceResult, TransactionReceiptWaitUntil, + TransactionProtocolLifecycle, + transactionResolutionActionNumberToName, + transactionProtocolStatusNumberToName, + transactionResolutionSourceNumberToName, + transactionLifecycleFromStoredStatus, } 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 {isMethodNotFoundError, readStudioLifecycleFallback} from "./lifecycleFallback"; +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; + +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`); + } + 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; @@ -52,9 +175,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); } @@ -112,7 +246,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); @@ -137,10 +271,111 @@ 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) => ({ - /** Fetches transaction data including status, execution result, and consensus details. */ + advanced: { + /** + * `advanced.getTransactionLifecycle` exposes stored/projected status, + * 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. + */ + getTransactionLifecycle: async ({ + hash, + timestamp, + }: { + hash: TransactionHash; + timestamp?: number; + }): Promise => { + if (client.chain.isStudio) { + 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; + 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 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) { const transaction = await client.getTransaction({hash}); @@ -149,33 +384,133 @@ 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 contractAddress = client.chain.consensusDataContract?.address as Address; - const contractAbi = client.chain.consensusDataContract?.abi as Abi; + 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 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 addressManagerAddress = resolvedAddress( + "AddressManager", + await publicClient.readContract({ + address: consensusDataAddress, + abi: CONSENSUS_DATA_TRAIN_ABI, + functionName: "addressManager", + blockNumber, + }) as Address, + ); - const [txDataRaw, allDataRaw] = await Promise.all([ + const [bigRoundsAddressRaw, roundsStorageAddressRaw, transactionManagerAddressRaw] = await Promise.all([ publicClient.readContract({ - address: contractAddress, - abi: contractAbi, - functionName: "getTransactionData", - args: [hash, Math.round(new Date().getTime() / 1000)], - }) as Promise, + address: addressManagerAddress, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["ConsensusDataBigRounds"], + blockNumber, + }), publicClient.readContract({ - address: contractAddress, - abi: contractAbi, - functionName: "getTransactionAllData", - args: [hash], - }) as Promise<[any, any[]]>, - ]); - - const txData = txDataRaw as unknown as GenLayerRawTransaction; - const [txAllData, _roundsData] = allDataRaw as unknown as [any, any[]]; + address: addressManagerAddress, + abi: ADDRESS_MANAGER_TRAIN_ABI, + functionName: "getAddress", + args: ["RoundsStorage"], + blockNumber, + }), + publicClient.readContract({ + 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 = 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] = + 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 transaction = { ...txData, - txExecutionResult: Number(txAllData.txExecutionResult), - } as GenLayerRawTransaction; + numOfInitialValidators, + txExecutionResult: Number(txExecutionResult), + consumedValidators, + lastRound: { + ...txData.lastRound, + roundValidators, + validatorVotes: [...validatorVotes].map(Number), + validatorVotesHash: [...validatorVotesHash], + validatorResultHash: [...validatorResultHash], + }, + } as unknown as GenLayerRawTransaction; return decodeTransaction(transaction); }, /** Returns transaction IDs of child transactions created from emitted messages. */ diff --git a/src/transactions/decoders.ts b/src/transactions/decoders.ts index d112a7c..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"; @@ -74,19 +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; - - 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: tx.currentTimestamp.toString(), - numOfInitialValidators: numOfInitialValidators?.toString() ?? "0", + currentTimestamp: tx.observedAt.toString(), + numOfInitialValidators: tx.numOfInitialValidators.toString(), txSlot: tx.txSlot.toString(), createdTimestamp: tx.createdTimestamp.toString(), lastVoteTimestamp: tx.lastVoteTimestamp.toString(), @@ -102,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], @@ -284,4 +283,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/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/src/types/clients.ts b/src/types/clients.ts index 449975c..8347d25 100644 --- a/src/types/clients.ts +++ b/src/types/clients.ts @@ -15,6 +15,10 @@ import { BigNumberish, FeesDistributionInput, SimulateWriteContractResult, + ConsensusRoundData, + ConsensusLastRoundData, + TransactionProtocolLifecycle, + TransactionProtocolLifecycleArgs, } from "./transactions"; import {GenLayerChain} from "./chains"; import {Address, Account} from "./accounts"; @@ -34,6 +38,8 @@ 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: "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]} @@ -113,6 +119,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: { @@ -130,6 +140,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; @@ -142,8 +164,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 +207,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..0a32106 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 { @@ -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,24 +241,36 @@ 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; initiateOperatorTransfer: (options: InitiateOperatorTransferOptions) => Promise; 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; 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; + getCurrentEpoch: () => Promise; + isValidatorBelowMin: (validator: Address) => Promise; getValidatorInfo: (validator: Address) => Promise; getStakeInfo: (delegator: Address, validator: Address) => Promise; getEpochInfo: () => Promise; getEpochData: (epochNumber: bigint) => Promise; getActiveValidators: () => Promise; 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/src/types/transactions.ts b/src/types/transactions.ts index f4ad76e..bcdbcfa 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -18,17 +18,97 @@ 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 TransactionResult { - SUCCESS = "SUCCESS", - FAILURE = "FAILURE", +export enum TransactionResolutionAction { + NO_OP = "NoOp", + CANCEL = "Cancel", + REPLACE_ACTOR = "ReplaceActor", + ROTATE_LEADER = "RotateLeader", + RESOLVE_APPEAL = "ResolveAppeal", + MATERIALIZE_DECISION = "MaterializeDecision", + 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, +}; + +/** 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 { IDLE = "IDLE", AGREE = "AGREE", @@ -41,6 +121,8 @@ export enum TransactionResult { MAJORITY_TIMEOUT = "MAJORITY_TIMEOUT", } +// 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, @@ -53,13 +135,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: Record = { [TransactionStatus.UNINITIALIZED]: "0", [TransactionStatus.PENDING]: "1", [TransactionStatus.PROPOSING]: "2", @@ -71,10 +152,9 @@ 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", + [TransactionStatus.VALIDATORS_TIMEOUT]: "11", + [TransactionStatus.LEADER_TIMEOUT]: "12", + [TransactionStatus.LEADER_REVEALING]: "13", }; export const DECIDED_STATES = [ @@ -116,6 +196,7 @@ export enum ExecutionResult { FINISHED_WITH_ERROR = "FINISHED_WITH_ERROR", TIMEOUT = "TIMEOUT", NONDET_DISAGREE = "NONDET_DISAGREE", + DETERMINISTIC_VIOLATION = "DETERMINISTIC_VIOLATION", } export const executionResultNumberToName = { @@ -124,30 +205,34 @@ export const executionResultNumberToName = { "2": ExecutionResult.FINISHED_WITH_ERROR, "3": ExecutionResult.TIMEOUT, "4": ExecutionResult.NONDET_DISAGREE, + "5": ExecutionResult.DETERMINISTIC_VIOLATION, }; export enum VoteType { NOT_VOTED = "NOT_VOTED", - AGREE = "AGREE", - DISAGREE = "DISAGREE", + FINISHED_WITH_RETURN = "FINISHED_WITH_RETURN", + FINISHED_WITH_ERROR = "FINISHED_WITH_ERROR", TIMEOUT = "TIMEOUT", + NONDET_DISAGREE = "NONDET_DISAGREE", DETERMINISTIC_VIOLATION = "DETERMINISTIC_VIOLATION", } export const voteTypeNumberToName = { "0": VoteType.NOT_VOTED, - "1": VoteType.AGREE, - "2": VoteType.DISAGREE, + "1": VoteType.FINISHED_WITH_RETURN, + "2": VoteType.FINISHED_WITH_ERROR, "3": VoteType.TIMEOUT, - "4": VoteType.DETERMINISTIC_VIOLATION, + "4": VoteType.NONDET_DISAGREE, + "5": VoteType.DETERMINISTIC_VIOLATION, }; export const voteTypeNameToNumber = { [VoteType.NOT_VOTED]: "0", - [VoteType.AGREE]: "1", - [VoteType.DISAGREE]: "2", + [VoteType.FINISHED_WITH_RETURN]: "1", + [VoteType.FINISHED_WITH_ERROR]: "2", [VoteType.TIMEOUT]: "3", - [VoteType.DETERMINISTIC_VIOLATION]: "4", + [VoteType.NONDET_DISAGREE]: "4", + [VoteType.DETERMINISTIC_VIOLATION]: "5", }; export type TransactionType = "deploy" | "call"; @@ -159,6 +244,147 @@ 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; + leaderIndex: bigint; + votesCommitted: bigint; + votesRevealed: bigint; + appealBond: bigint; + rotationsLeft: bigint; + result: number; + roundValidators: Address[]; + validatorVotes: number[]; + validatorVotesHash: Hash[]; + validatorResultHash: Hash[]; +} + +/** Legacy tuple shape returned by getLastRoundData, with named properties retained. */ +export type ConsensusLastRoundData = [round: bigint, roundData: ConsensusRoundData] & { + round: bigint; + roundData: ConsensusRoundData; +}; + export enum MessageType { External = 0, Internal = 1, @@ -481,6 +707,8 @@ export type GenLayerTransaction = { // numOfInitialValidators: testnet numOfInitialValidators?: string; + /** Train maximum rotations, distinct from the initial committee size. */ + initialRotations?: bigint; // txSlot: testnet txSlot?: string; @@ -506,11 +734,15 @@ export type GenLayerTransaction = { data?: Record; 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; @@ -525,8 +757,12 @@ export type GenLayerTransaction = { lastLeader?: Address; // status: localnet: TransactionStatus // status: testnet: number + /** Exact lifecycle status persisted by the transaction manager. */ status?: TransactionStatus | number; + /** Named form of the exact persisted lifecycle status. */ statusName?: TransactionStatus; + /** Simple lifecycle derived from `status`; it never uses timestamp projection. */ + lifecycle: TransactionLifecycle; // hash: localnet // txId: testnet// hash: localnet // txId: testnet hash?: TransactionHash; @@ -553,6 +789,7 @@ export type GenLayerTransaction = { result: number; roundValidators: Address[]; validatorVotesHash: Hash[]; + validatorResultHash: Hash[]; validatorVotes: number[]; validatorVotesName: VoteType[]; }; @@ -575,20 +812,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; @@ -611,6 +850,7 @@ export type GenLayerRawTransaction = { result: number; roundValidators: Address[]; validatorVotesHash: Hash[]; + validatorResultHash: Hash[]; validatorVotes: number[]; }; }; diff --git a/tests/chains-actions.test.ts b/tests/chains-actions.test.ts index 2ba4399..e869fd3 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,60 @@ describe("chainActions.initializeConsensusSmartContract", () => { fetchSpy.mockRestore(); }); }); + +describe.each([ + ["localnet", localnet], + ["studionet", studionet], +])("%s Studio consensus ABI", (_name, chain) => { + 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) + .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", "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([ + "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/client.test-d.ts b/tests/client.test-d.ts index 7b375d4..acd1dfa 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, }); @@ -74,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/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 67791f2..f91f208 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1,5 +1,11 @@ import {describe, it, expect, vi} from "vitest"; -import {decodeAbiParameters, decodeFunctionData, encodeFunctionData, keccak256, toHex} from "viem"; +import { + decodeAbiParameters, + decodeFunctionData, + encodeFunctionData, + keccak256, + toHex, +} from "viem"; import {contractActions} from "../src/contracts/actions"; import {NFT_MINTER_ABI} from "../src/abi/nftMinter"; import { @@ -20,8 +26,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 +190,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 +643,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 +657,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 +674,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 () => { @@ -896,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}) => { @@ -1081,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 () => { @@ -1111,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 () => { @@ -1255,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 () => { @@ -1392,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); @@ -1454,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 () => { @@ -1490,11 +1566,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 +1581,12 @@ describe("contractActions addTransaction ABI compatibility", () => { 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(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"); - - 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(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 +1654,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 +1665,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 +1695,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 +1799,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: "resolveTransactions", + stateMutability: "nonpayable" as const, + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedAttemptId", type: "bytes32"}, + ], + }], outputs: [], }, { type: "function" as const, - name: "finalizeIdlenessTxs", + name: "finalizeDecisions", stateMutability: "nonpayable" as const, - inputs: [{name: "_txIds", type: "bytes32[]"}], + inputs: [{ + name: "_commands", + type: "tuple[]", + components: [ + {name: "txId", type: "bytes32"}, + {name: "expectedDecisionId", type: "uint256"}, + ], + }], outputs: [], }, ]; @@ -1826,22 +1852,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 resolveTransactionsSelector = encodeFunctionData({ + abi: FINALIZE_TX_ABI as any, + functionName: "resolveTransactions", + args: [[{txId: MOCK_GENLAYER_TX_ID, expectedAttemptId: MOCK_ATTEMPT_ID}]], }).slice(0, 10); -const finalizeIdlenessSelector = encodeFunctionData({ +const finalizeDecisionsSelector = encodeFunctionData({ abi: FINALIZE_TX_ABI as any, - functionName: "finalizeIdlenessTxs", - args: [[MOCK_GENLAYER_TX_ID]], + functionName: "finalizeDecisions", + args: [[{txId: MOCK_GENLAYER_TX_ID, expectedDecisionId: MOCK_DECISION_ID}]], }).slice(0, 10); const topUpFeesSelector = encodeFunctionData({ @@ -1869,6 +1915,7 @@ const topUpAndSubmitAppealSelector = encodeFunctionData({ functionName: "topUpAndSubmitAppeal", args: [ MOCK_GENLAYER_TX_ID, + MOCK_DECISION_ID, { leaderTimeunitsAllocation: 0n, validatorTimeunitsAllocation: 0n, @@ -1884,7 +1931,27 @@ const topUpAndSubmitAppealSelector = encodeFunctionData({ ], }).slice(0, 10); -const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: string} = {}) => { +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", + 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: []}); @@ -1898,6 +1965,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 +1985,30 @@ const setupFinalizeHarness = ({receiptStatus = "success"}: {receiptStatus?: stri }), }; - const publicClient = {waitForTransactionReceipt}; + const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "getTransactionLifecycle") { + return trainLifecycle({action: resolutionAction}); + } + 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 +2024,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 +2054,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 +2202,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 +2348,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 +2371,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 +2445,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 +2455,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); }); @@ -2250,25 +2466,260 @@ 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", () => { - 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(); + }); +}); - const evmHash = await actions.finalizeIdlenessTxs({txIds: [MOCK_GENLAYER_TX_ID]}); +describe("contractActions train lifecycle batches", () => { + it("encodes resolveTransactions with fixed-block attempt identities", async () => { + const {actions, signTransaction} = setupFinalizeHarness(); + + 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/); + + 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, + }]); + }); +}); + +/** 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: "_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. Studio stays a consumer of this SDK without joining this train. + */ +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 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}`); + }); + 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 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( + 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 Studio's native 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 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, + ); + + 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 that the current Studio has no authoritative appeal quote", 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 is not exposed by Studio's embedded consensus.*finalizeTransaction/s, + ); + await expect(actions.finalizeDecisions({txIds: [MOCK_GENLAYER_TX_ID]})).rejects.toThrow( + /finalizeDecisions is not exposed by Studio's embedded consensus.*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]); + }); }); 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 6465852..0ad8136 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,126 @@ 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, 10n, 100n, 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(info.totalWeight).toBe(100n); + expect(info.inflationRaw).toBe(10n); + 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 6c343c0..175ba3c 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -6,14 +6,31 @@ import { DECIDED_STATES, isDecidedState, transactionsStatusNumberToName, + 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"; import { localnet } from "../src/chains/localnet"; import type { GenLayerRawTransaction } from "../src/types/transactions"; -import {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(); vi.stubGlobal("fetch", mockFetch); @@ -35,7 +52,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 +60,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,10 +78,69 @@ 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("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(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); + 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(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, @@ -74,6 +150,71 @@ 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("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", () => { @@ -152,7 +293,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 = { @@ -238,6 +379,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", () => { @@ -318,21 +504,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, @@ -355,11 +544,141 @@ 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, source: 0, evaluatedAt: 456n}, + latestDecision: {}, + decisionActive: false, + ...overrides, +}); + +const trainReadContract = ({ + light = makeLightTx(), + lifecycle = trainLifecycle(), + roundValidators = [] as string[], + consumedValidators = [] as string[], +}: { + light?: ReturnType; + lifecycle?: ReturnType; + 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 === "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; @@ -369,10 +688,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: [ @@ -384,6 +704,7 @@ describe("getTriggeredTransactionIds", () => { }); const publicClient = { readContract, + getBlock: vi.fn().mockResolvedValue({number: 150n, timestamp: 1000n}), getBlockNumber: vi.fn().mockResolvedValue(200n), getLogs, getTransactionReceipt, @@ -407,44 +728,310 @@ 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 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")}`, + ); + const consumed = validators.slice(0, 2); + const light = makeLightTx({ + status: 5, + lastRound: {...makeLightTx().lastRound, validatorsCount: 65n}, + consumedValidatorsCount: 2n, + }); + const readContract = trainReadContract({ + light, + 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; + + const transaction = await transactionActions(client, publicClient).getTransaction({ + hash: light.txId as any, + }); + + 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).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getTransactionLifecycle"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "canFinalize"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getTransactionAllData"}), + ); + expect(readContract).not.toHaveBeenCalledWith( + expect.objectContaining({functionName: "getStoredTransactionData"}), + ); }); - 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"); + 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: 6, action: 6, source: 11, evaluatedAt: 500n}, + latestDecision: {decisionId: 7n}, + decisionActive: true, + }), + }); + 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, + 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(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("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"); + 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("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("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("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"; + 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({ + 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}), + ); + }); +}); + +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.lifecycle).toEqual({state: "decided", outcome: "accepted"}); + expect(decoded.resultName).toBe("MAJORITY_AGREE"); }); it("should decode readStateBlockRange fields to strings", () => { @@ -463,8 +1050,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", + ]); }); });