From 77b556925aa26f0865003f0483a020058160e308 Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 30 Aug 2026 19:38:59 +0100 Subject: [PATCH 1/8] fix(fees): mirror consensus deposit quote --- src/contracts/actions.ts | 56 +++++++++++++++++++++++++++------ src/types/transactions.ts | 2 ++ tests/contracts-actions.test.ts | 41 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 10 deletions(-) diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index d2de6c3..02d8967 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -1493,6 +1493,10 @@ const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { const genPerTimeUnit = bigintFromUnknown(policyRecord.genPerTimeUnit, "policy.genPerTimeUnit"); const storageUnitPrice = bigintFromUnknown(policyRecord.storageUnitPrice, "policy.storageUnitPrice"); const receiptGasPrice = bigintFromUnknown(policyRecord.receiptGasPrice, "policy.receiptGasPrice"); + const timeUnitOverlayBps = bigintFromUnknown( + policyRecord.timeUnitOverlayBps, + "policy.timeUnitOverlayBps", + ); const intrinsicGas = bigintFromUnknown(policyRecord.intrinsicGas, "policy.intrinsicGas", DEFAULT_INTRINSIC_GAS); const bootloaderOverhead = bigintFromUnknown( policyRecord.bootloaderOverhead, @@ -1546,6 +1550,7 @@ const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { storageUnitPrice, receiptGasPrice, executionBudgetFloor, + timeUnitOverlayBps, }; }; @@ -1596,6 +1601,9 @@ const readCurrentFeePolicy = async ( storageUnitPrice, receiptGasPrice, executionBudgetFloor: maxBigint(executionBudgetFloor, localExecutionBudgetFloor), + // Live networks quote through FeeManager.calculateRoundFees; this field is + // only consumed by Studio's local mirror. + timeUnitOverlayBps: 0n, }; }; @@ -1939,6 +1947,14 @@ const calculateFeeForRound = ( leaderTimeunitsAllocation + (numOfValidators * validatorTimeunitsAllocation) ); +const validatorsPerRoundSafe = (round: number): bigint => ( + VALIDATORS_PER_ROUND[Math.min(Math.max(round, 0), VALIDATORS_PER_ROUND.length - 1)] +); + +const successfulAppealProfit = (appealBond: bigint): bigint => ( + appealBond + (appealBond / 2n) +); + const calculateLocalRoundFees = ( distribution: FeesDistribution, numOfInitialValidators: number, @@ -1967,11 +1983,8 @@ const calculateLocalRoundFees = ( } const startIndex = validatorIndex(numOfInitialValidators); - if (startIndex + Number(distribution.appealRounds * 2n) >= VALIDATORS_PER_ROUND.length) { - throw new Error("InvalidNumOfValidators"); - } - let total = calculateFeeForRound( + let taxableWork = calculateFeeForRound( VALIDATORS_PER_ROUND[startIndex], distribution.rotations[0] + 1n, distribution.leaderTimeunitsAllocation, @@ -1987,24 +2000,47 @@ const calculateLocalRoundFees = ( rotationsThisRound = 1n; } - total += calculateFeeForRound( - VALIDATORS_PER_ROUND[startIndex + offset], + // Consensus indexes appeal/next-normal committees by absolute round and + // saturates at the published ladder; only round zero uses the requested + // initial committee. + taxableWork += calculateFeeForRound( + validatorsPerRoundSafe(offset), rotationsThisRound, distribution.leaderTimeunitsAllocation, distribution.validatorTimeunitsAllocation, ); } - if (policy.genPerTimeUnit > 0n) { - total *= policy.genPerTimeUnit; + const priceCap = distribution.maxPriceGenPerTimeUnit; + if (priceCap > 0n) { + taxableWork *= priceCap; + } + + let appealProfitReserve = 0n; + for (let appealOrdinal = 0; appealOrdinal < Number(distribution.appealRounds); appealOrdinal++) { + const nextNormalBond = calculateFeeForRound( + validatorsPerRoundSafe((appealOrdinal + 1) * 2), + distribution.rotations[appealOrdinal + 1] + 1n, + distribution.leaderTimeunitsAllocation, + distribution.validatorTimeunitsAllocation, + ) * (priceCap > 0n ? priceCap : 1n); + appealProfitReserve += successfulAppealProfit(nextNormalBond); + } + + const overlayBps = policy.timeUnitOverlayBps ?? 0n; + if (overlayBps < 0n || overlayBps >= 10_000n) { + throw new Error("InvalidTimeUnitOverlayBps"); } + const overlay = overlayBps === 0n + ? 0n + : (taxableWork * overlayBps) / (10_000n - overlayBps); const leaderRounds = distribution.rotations.reduce( (sum, rotations) => sum + rotations + 1n, distribution.appealRounds, ); - total += distribution.executionBudgetPerRound * leaderRounds; - return total; + return taxableWork + appealProfitReserve + overlay + + (distribution.executionBudgetPerRound * leaderRounds); }; const _resolveTransactionFees = async ({ diff --git a/src/types/transactions.ts b/src/types/transactions.ts index bcdbcfa..6addccb 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -461,6 +461,8 @@ export type FeePolicyQuote = { storageUnitPrice: bigint; receiptGasPrice: bigint; executionBudgetFloor: bigint; + /** Combined developer/DAO share, grossed up over taxable time-unit work. */ + timeUnitOverlayBps?: bigint; }; export type FeeEstimateOptions = FeesDistributionInput & { diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index f91f208..f0260e2 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -893,6 +893,46 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(estimateTransactionGas.mock.calls[0][0].value).toBe(11_000n); }); + it("matches the consensus cap, overlay, appeal reserve, and round ladder locally on Studio", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "0", + receiptGasPrice: "0", + timeUnitOverlayBps: "1500", + }, + }; + } + if (method === "eth_gasPrice") return "0x1"; + throw new Error(`unexpected request ${method}`); + }); + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + isStudio: true, + requestMock, + }); + + const fees = await actions.estimateTransactionFees({ + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + appealRounds: 1n, + rotations: [0n, 0n], + executionBudgetPerRound: 0n, + maxPriceGenPerTimeUnit: 12n, + storageFeeMaxGasPrice: 0n, + receiptFeeMaxGasPrice: 0n, + }); + + // Taxable work: (5-validator round 0 + absolute rounds 1 and 2) * cap + // = (1100 + 1500 + 2300) * 12 = 58800. + // Appeal profit reserve: 1.5 * (2300 * 12) = 41400. + // Overlay: floor(58800 * 1500 / 8500) = 10376. + expect(fees.feeValue).toBe(110_576n); + }); + it("defaults direct fee estimates to the chain rotation budget for every appeal round", async () => { const requestMock = vi.fn().mockResolvedValue({ enabled: false, @@ -1004,6 +1044,7 @@ describe("contractActions addTransaction ABI compatibility", () => { storageUnitPrice: 20n, receiptGasPrice: 30n, executionBudgetFloor: expectedLocalFloor, + timeUnitOverlayBps: 0n, }); expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(12n); expect(fees.distribution.storageFeeMaxGasPrice).toBe(24n); From aa559b15cdc0ef231831b3e06740f8db6a95509f Mon Sep 17 00:00:00 2001 From: Edgars Date: Sun, 30 Aug 2026 20:02:58 +0100 Subject: [PATCH 2/8] fix(studio): bind decision lifecycle actions Use Studio's lifecycle and appeal-charge RPCs to bind appeal, combined top-up/appeal, and finalization to the active decision. Keep Studio ABI exports aligned with the v0.6 consensus surface and cover quoting and guarded selectors. --- src/abi/studioConsensus.ts | 16 ++- src/contracts/actions.ts | 168 +++++++++++++------------------- tests/chains-actions.test.ts | 8 +- tests/contracts-actions.test.ts | 82 ++++++++++------ 4 files changed, 136 insertions(+), 138 deletions(-) diff --git a/src/abi/studioConsensus.ts b/src/abi/studioConsensus.ts index d428dd4..4e34a92 100644 --- a/src/abi/studioConsensus.ts +++ b/src/abi/studioConsensus.ts @@ -37,9 +37,8 @@ const addTransactionParamsComponents = [ ] 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. + * Studio transaction surface for the v0.6 consensus train. Appeals and + * finalization bind to the active decision identity just like contract nodes. */ export const studioConsensusMainAbi = [ { @@ -72,6 +71,7 @@ export const studioConsensusMainAbi = [ stateMutability: "payable", inputs: [ {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, {name: "_feesDistribution", type: "tuple", components: feesDistributionComponents}, ], outputs: [], @@ -80,14 +80,20 @@ export const studioConsensusMainAbi = [ type: "function", name: "submitAppeal", stateMutability: "payable", - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, { type: "function", name: "finalizeTransaction", stateMutability: "nonpayable", - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, ] as const satisfies Abi; diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 02d8967..425fcf7 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -10,6 +10,7 @@ import { GenLayerClient, CalldataEncodable, Address, + TransactionHash, TransactionHashVariant, TransactionFeeOptions, TransactionFeeEstimate, @@ -624,23 +625,13 @@ export const contractActions = (client: GenLayerClient, publicCli observed, }; }, - /** - * Returns the full authoritative appeal charge (bond plus appeal funding) - * on resolution-kernel contract networks. Current Studio has no - * decision-bound quote surface. - */ + /** Returns the full authoritative appeal charge (bond plus appeal funding). */ getAppealCharge: async (args: {txId: `0x${string}`}): Promise => { - if (client.chain.isStudio) { - throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); - } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, /** @deprecated Use getAppealCharge. This legacy name also returns bond plus appeal funding. */ getMinAppealBond: async (args: {txId: `0x${string}`}): Promise => { - if (client.chain.isStudio) { - throw new Error(STUDIO_APPEAL_QUOTE_UNSUPPORTED); - } const context = await _readAppealContext({client, publicClient, txId: args.txId}); return context.requiredValue; }, @@ -698,6 +689,20 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** Checks if a transaction can be appealed. */ canAppeal: async (args: {txId: `0x${string}`}): Promise => { + if (client.chain.isStudio) { + const context = await _readLifecycleIdentity({client, publicClient, txId: args.txId}); + if (!context.decisionActive) return false; + try { + const quote = await client.request({ + method: "gen_estimateLatestAppealCharge", + params: [{txId: args.txId as TransactionHash}], + }) as {decisionId?: unknown}; + return BigInt(String(quote.decisionId)) === context.decisionId; + } catch (error) { + if (/CanNotAppeal/i.test(String(error))) return false; + throw error; + } + } if (!client.chain.appealsContract?.address) { throw new Error("canAppeal not supported on this chain (missing appealsContract)"); } @@ -831,9 +836,8 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** * 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. + * The call is bound to the active decision on both Studio and contract + * networks. When value is omitted, the authoritative appeal charge is used. */ appealTransaction: async (args: { account?: Account; @@ -846,18 +850,6 @@ export const contractActions = (client: GenLayerClient, publicCli // Appeals don't go through _sendTransaction because submitAppeal emits // AppealStarted/TransactionActivated events, not NewTransaction/CreatedTransaction. // The appeal operates on the same GenLayer txId, so we return it directly. - if (client.chain.isStudio) { - await _sendConsensusCall({ - client, - publicClient, - encodedData: _encodeStudioSubmitAppealData({txId}), - senderAccount, - value: args.value ?? 0n, - operationName: "Appeal", - }); - return txId; - } - const context = await _readAppealContext({ client, publicClient, @@ -906,9 +898,8 @@ 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. + * The call is bound to the active decision on both Studio and contract + * networks. When value is omitted, the authoritative appeal charge is used. */ topUpAndSubmitAppeal: async (args: { account?: Account; @@ -919,18 +910,6 @@ export const contractActions = (client: GenLayerClient, publicCli const {account, txId, distribution} = args; const senderAccount = account || client.account; - if (client.chain.isStudio) { - await _sendConsensusCall({ - client, - publicClient, - encodedData: _encodeStudioTopUpAndSubmitAppealData({txId, distribution}), - senderAccount, - value: args.value ?? 0n, - operationName: "Top up and submit appeal", - }); - return txId; - } - const context = await _readAppealContext({ client, publicClient, @@ -962,20 +941,6 @@ export const contractActions = (client: GenLayerClient, publicCli const {account, txId} = args; const senderAccount = account || client.account; - if (client.chain.isStudio) { - return _sendConsensusCall({ - client, - publicClient, - encodedData: encodeFunctionData({ - abi: client.chain.consensusMainContract?.abi as any, - functionName: "finalizeTransaction", - args: [txId], - }), - senderAccount, - operationName: "Finalize", - }); - } - const identity = await _readLifecycleIdentity({client, publicClient, txId}); if (!identity.decisionActive) { throw new Error(`Transaction ${txId} has no active decision to finalize`); @@ -1193,20 +1158,6 @@ const CONSENSUS_FEE_MANAGEMENT_ABI = [ }, ] as const; -/** Studio's current embedded consensus does not carry decision identities. */ -const CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI = [ - { - type: "function", - name: "topUpAndSubmitAppeal", - stateMutability: "payable", - inputs: [ - {name: "_txId", type: "bytes32"}, - {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, - ], - outputs: [], - }, -] as const; - const CONSENSUS_APPEAL_TRAIN_ABI = [ { type: "function", @@ -2159,37 +2110,6 @@ const _encodeSubmitAppealData = ({ }); }; -const STUDIO_APPEAL_QUOTE_UNSUPPORTED = - "Appeal bond calculation not supported on this chain (missing feeManagerContract/roundsStorageContract)"; - -const _encodeStudioSubmitAppealData = ({ - txId, -}: { - txId: `0x${string}`; -}): `0x${string}` => encodeFunctionData({ - abi: [{ - type: "function", - name: "submitAppeal", - stateMutability: "payable", - inputs: [{name: "_txId", type: "bytes32"}], - outputs: [], - }], - functionName: "submitAppeal", - args: [txId], -}); - -const _encodeStudioTopUpAndSubmitAppealData = ({ - txId, - distribution, -}: { - txId: `0x${string}`; - distribution: FeesDistributionInput; -}): `0x${string}` => encodeFunctionData({ - abi: CONSENSUS_FEE_MANAGEMENT_STUDIO_ABI, - functionName: "topUpAndSubmitAppeal", - args: [txId, createFeesDistribution(distribution)], -}); - const _studioTrainBatchError = (action: string): Error => new Error( `${action} is not exposed by Studio's embedded consensus: ` + @@ -2315,6 +2235,33 @@ const _readLifecycleIdentity = async ({ blockNumber?: bigint; blockTimestamp?: bigint; }): Promise => { + if (client.chain.isStudio) { + const lifecycle = await client.request({ + method: "gen_getTransactionLifecycle", + params: [{txId: txId as TransactionHash}], + }) as { + resolutionActionCode?: unknown; + decisionId?: unknown; + decisionActive?: unknown; + evaluatedAt?: unknown; + }; + if (typeof lifecycle.decisionActive !== "boolean") { + throw new Error( + `Studio returned an invalid decisionActive for ${txId}: ${String(lifecycle.decisionActive)}`, + ); + } + const decisionId = lifecycle.decisionActive ? BigInt(String(lifecycle.decisionId)) : 0n; + const evaluatedAt = BigInt(String(lifecycle.evaluatedAt ?? 0)); + return { + blockNumber: 0n, + blockTimestamp: evaluatedAt, + resolutionAction: Number(lifecycle.resolutionActionCode), + attemptId: `0x${"00".repeat(32)}`, + decisionActive: lifecycle.decisionActive, + decisionId, + }; + } + const consensusDataAddress = client.chain.consensusDataContract?.address as Address | undefined; if (!consensusDataAddress || consensusDataAddress === zeroAddress) { throw new Error("ConsensusData contract is not configured for this chain"); @@ -2371,6 +2318,27 @@ const _readAppealContext = async ({ return {...identity, requiredValue: 0n}; } + if (client.chain.isStudio) { + const quote = await client.request({ + method: "gen_estimateLatestAppealCharge", + params: [{txId: txId as TransactionHash}], + }) as { + decisionId?: unknown; + bond?: unknown; + funding?: unknown; + }; + const quoteDecisionId = BigInt(String(quote.decisionId)); + if (quoteDecisionId !== identity.decisionId) { + throw new Error( + `Appeal decision changed while reading ${txId}: expected ${identity.decisionId}, received ${quoteDecisionId}`, + ); + } + return { + ...identity, + requiredValue: BigInt(String(quote.bond)) + BigInt(String(quote.funding)), + }; + } + const consensusDataAddress = client.chain.consensusDataContract!.address as Address; const quote = await publicClient.readContract({ address: consensusDataAddress, diff --git a/tests/chains-actions.test.ts b/tests/chains-actions.test.ts index e869fd3..593f628 100644 --- a/tests/chains-actions.test.ts +++ b/tests/chains-actions.test.ts @@ -54,7 +54,7 @@ describe.each([ ["localnet", localnet], ["studionet", studionet], ])("%s Studio consensus ABI", (_name, chain) => { - it("exports Studio's fee-aware, decision-free transaction signatures", () => { + it("exports Studio's fee-aware, decision-bound transaction signatures", () => { const functions = new Map( (chain.consensusMainContract!.abi as readonly TestAbiEntry[]) .filter((entry) => entry.type === "function" && entry.name) @@ -66,9 +66,9 @@ describe.each([ expect(inputTypes("addTransaction")).toEqual(["tuple"]); expect(inputTypes("deploySalted")).toEqual(["tuple"]); expect(inputTypes("topUpFees")).toEqual(["bytes32", "tuple"]); - expect(inputTypes("topUpAndSubmitAppeal")).toEqual(["bytes32", "tuple"]); - expect(inputTypes("submitAppeal")).toEqual(["bytes32"]); - expect(inputTypes("finalizeTransaction")).toEqual(["bytes32"]); + expect(inputTypes("topUpAndSubmitAppeal")).toEqual(["bytes32", "uint256", "tuple"]); + expect(inputTypes("submitAppeal")).toEqual(["bytes32", "uint256"]); + expect(inputTypes("finalizeTransaction")).toEqual(["bytes32", "uint256"]); const addParams = functions.get("addTransaction")!.inputs![0].components!; expect(addParams.map((component) => component.type)).toEqual([ diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index f0260e2..f19c1f7 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -2558,20 +2558,26 @@ describe("contractActions train lifecycle batches", () => { }); }); -/** Current Studio entrypoints intentionally remain independent of the v0.6 train. */ +/** Studio mirrors the v0.6 decision-bound transaction entrypoints. */ const CONSENSUS_MAIN_STUDIO_ABI = [ { type: "function" as const, name: "submitAppeal", stateMutability: "payable" as const, - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, { type: "function" as const, name: "finalizeTransaction", stateMutability: "nonpayable" as const, - inputs: [{name: "_txId", type: "bytes32"}], + inputs: [ + {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, + ], outputs: [], }, ] as const; @@ -2583,6 +2589,7 @@ const FEE_MANAGEMENT_STUDIO_ABI = [ stateMutability: "payable" as const, inputs: [ {name: "_txId", type: "bytes32"}, + {name: "_expectedDecisionId", type: "uint256"}, {name: "_feesDistribution", type: "tuple", components: FEES_DISTRIBUTION_COMPONENTS}, ], outputs: [], @@ -2590,9 +2597,9 @@ const FEE_MANAGEMENT_STUDIO_ABI = [ ] 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. + * Mirrors the localnet/studionet chain config. Studio exposes lifecycle and + * appeal-quote RPCs while fee manager, rounds, and appeals contract addresses + * remain absent from its chain definition. */ const setupStudioLifecycleHarness = () => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -2627,12 +2634,30 @@ const setupStudioLifecycleHarness = () => { sendRawTransaction, request: vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; + if (method === "gen_getTransactionLifecycle") { + return { + storedStatusCode: 5, + projectedStatusCode: 5, + resolutionActionCode: 6, + resolutionSourceCode: 6, + decisionId: MOCK_DECISION_ID.toString(), + decisionActive: true, + evaluatedAt: "456", + }; + } + if (method === "gen_estimateLatestAppealCharge") { + return { + decisionId: MOCK_DECISION_ID.toString(), + bond: "500", + funding: "734", + appealDeadline: "999", + }; + } 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. + // Studio lifecycle identity comes from its protocol RPC, never an EVM read. const readContract = vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { throw new Error(`Unexpected consensus read on Studio: ${functionName}`); }); @@ -2653,9 +2678,9 @@ const setupStudioLifecycleHarness = () => { }; }; -describe("contractActions current Studio surface", () => { - it("encodes Studio's native submitAppeal(bytes32) without a train lifecycle read", async () => { - const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); +describe("contractActions Studio decision-bound surface", () => { + it("encodes submitAppeal with Studio's active decision id", async () => { + const {actions, signTransaction, readContract, client} = setupStudioLifecycleHarness(); await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).resolves.toBe( MOCK_GENLAYER_TX_ID, @@ -2667,19 +2692,23 @@ describe("contractActions current Studio surface", () => { 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]); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + expect(client.request).toHaveBeenCalledWith({ + method: "gen_getTransactionLifecycle", + params: [{txId: MOCK_GENLAYER_TX_ID}], + }); }); - it("defaults the Studio appeal value to zero when the caller omits it", async () => { + it("uses Studio's authoritative appeal quote when value is omitted", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID}); expect(readContract).not.toHaveBeenCalled(); - expect(signTransaction.mock.calls[0][0].value).toBe(0n); + expect(signTransaction.mock.calls[0][0].value).toBe(1234n); }); - it("encodes Studio's native topUpAndSubmitAppeal without a decision id", async () => { + it("encodes Studio topUpAndSubmitAppeal with the active decision id", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); const txId = await actions.topUpAndSubmitAppeal({ @@ -2693,13 +2722,14 @@ describe("contractActions current Studio surface", () => { const txRequest = signTransaction.mock.calls[0][0]; expect(txRequest.value).toBe(1234n); const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_STUDIO_ABI as any, data: txRequest.data}); - const [decodedTxId, distribution] = decoded.args as any[]; + const [decodedTxId, decisionId, distribution] = decoded.args as any[]; expect(decodedTxId).toBe(MOCK_GENLAYER_TX_ID); + expect(decisionId).toBe(MOCK_DECISION_ID); expect(distribution.appealRounds).toBe(1n); expect(distribution.rotations).toEqual([0n, 1n]); }); - it("encodes Studio's native finalizeTransaction(bytes32)", async () => { + it("encodes Studio finalizeTransaction with the active decision id", async () => { const {actions, signTransaction, readContract} = setupStudioLifecycleHarness(); await expect(actions.finalizeTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( @@ -2712,27 +2742,21 @@ describe("contractActions current Studio surface", () => { data: signTransaction.mock.calls[0][0].data, }); expect(decoded.functionName).toBe("finalizeTransaction"); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID]); + expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); }); - it("reports that the current Studio has no authoritative appeal quote", async () => { + it("returns Studio's authoritative decision-bound 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/, - ); + await expect(actions.getAppealCharge({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); + await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); expect(readContract).not.toHaveBeenCalled(); }); - it("reports the missing appeals contract from canAppeal", async () => { + it("uses Studio's appeal quote surface for canAppeal", async () => { const {actions, readContract} = setupStudioLifecycleHarness(); - await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).rejects.toThrow( - /missing appealsContract/, - ); + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(true); expect(readContract).not.toHaveBeenCalled(); }); From b9a7bb2305f9b069d79f348d85e62a1b43d50b48 Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 31 Aug 2026 17:45:27 +0100 Subject: [PATCH 3/8] fix(fees): encode internal message price caps --- src/transactions/fees.ts | 6 ++++++ src/types/transactions.ts | 3 +++ tests/contracts-actions.test.ts | 9 +++++++++ 3 files changed, 18 insertions(+) diff --git a/src/transactions/fees.ts b/src/transactions/fees.ts index 9aa35b9..f5aabd4 100644 --- a/src/transactions/fees.ts +++ b/src/transactions/fees.ts @@ -156,6 +156,9 @@ export const encodeInternalMessageFeeParams = (input: InternalMessageFeeParamsIn {name: "appealRounds", type: "uint256"}, {name: "executionBudgetPerRound", type: "uint256"}, {name: "rotations", type: "uint256[]"}, + {name: "maxPriceGenPerTimeUnit", type: "uint256"}, + {name: "storageFeeMaxGasPrice", type: "uint256"}, + {name: "receiptFeeMaxGasPrice", type: "uint256"}, ], }, ], @@ -166,6 +169,9 @@ export const encodeInternalMessageFeeParams = (input: InternalMessageFeeParamsIn appealRounds, executionBudgetPerRound: toUInt(input.executionBudgetPerRound, "internalMessageFeeParams.executionBudgetPerRound"), rotations: normalizeRotations(input.rotations, appealRounds, "internalMessageFeeParams.rotations"), + maxPriceGenPerTimeUnit: toUInt(input.maxPriceGenPerTimeUnit, "internalMessageFeeParams.maxPriceGenPerTimeUnit"), + storageFeeMaxGasPrice: toUInt(input.storageFeeMaxGasPrice, "internalMessageFeeParams.storageFeeMaxGasPrice"), + receiptFeeMaxGasPrice: toUInt(input.receiptFeeMaxGasPrice, "internalMessageFeeParams.receiptFeeMaxGasPrice"), }, ], ); diff --git a/src/types/transactions.ts b/src/types/transactions.ts index 6addccb..e33e32c 100644 --- a/src/types/transactions.ts +++ b/src/types/transactions.ts @@ -422,6 +422,9 @@ export type InternalMessageFeeParamsInput = { appealRounds?: BigNumberish; executionBudgetPerRound?: BigNumberish; rotations?: BigNumberish[]; + maxPriceGenPerTimeUnit?: BigNumberish; + storageFeeMaxGasPrice?: BigNumberish; + receiptFeeMaxGasPrice?: BigNumberish; }; export type ExternalMessageFeeParamsInput = { diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index f19c1f7..5bc3e73 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -148,6 +148,9 @@ const INTERNAL_MESSAGE_FEE_PARAMS_ABI = [ {name: "appealRounds", type: "uint256"}, {name: "executionBudgetPerRound", type: "uint256"}, {name: "rotations", type: "uint256[]"}, + {name: "maxPriceGenPerTimeUnit", type: "uint256"}, + {name: "storageFeeMaxGasPrice", type: "uint256"}, + {name: "receiptFeeMaxGasPrice", type: "uint256"}, ], }, ] as const; @@ -593,6 +596,9 @@ describe("contractActions addTransaction ABI compatibility", () => { appealRounds: 1n, executionBudgetPerRound: 20n, rotations: [2n, 3n], + maxPriceGenPerTimeUnit: 30n, + storageFeeMaxGasPrice: 40n, + receiptFeeMaxGasPrice: 50n, }); const [decoded] = decodeAbiParameters(INTERNAL_MESSAGE_FEE_PARAMS_ABI, encoded) as any; @@ -601,6 +607,9 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(decoded.appealRounds).toBe(1n); expect(decoded.executionBudgetPerRound).toBe(20n); expect(decoded.rotations).toEqual([2n, 3n]); + expect(decoded.maxPriceGenPerTimeUnit).toBe(30n); + expect(decoded.storageFeeMaxGasPrice).toBe(40n); + expect(decoded.receiptFeeMaxGasPrice).toBe(50n); }); it("encodes external message fee params as the consensus tuple", () => { From 49775304ba0b5073c97d0ac31c41e1b62324a5f1 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 04:06:14 +0100 Subject: [PATCH 4/8] fix(studio): surface mined envelope reverts --- src/contracts/actions.ts | 122 ++++++++++++++++---------------- tests/contracts-actions.test.ts | 64 +++++++++++++++-- 2 files changed, 119 insertions(+), 67 deletions(-) diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 425fcf7..6b516b5 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -874,8 +874,7 @@ export const contractActions = (client: GenLayerClient, publicCli }, /** * 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. + * Returns the signed EVM envelope hash on every backend. */ topUpFees: async (args: { account?: Account; @@ -2388,6 +2387,45 @@ const _encodeTopUpAndSubmitAppealData = ({ }); }; +const _waitForSentEnvelope = async ({ + client, + publicClient, + evmHash, + operationName, + revertDetails, +}: { + client: GenLayerClient; + publicClient: PublicClient; + evmHash: `0x${string}`; + operationName: string; + revertDetails?: string; +}) => { + const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); + if (receipt.status !== "reverted") return receipt; + + let studioReason: string | undefined; + if (client.chain.isStudio) { + try { + const rawReceipt = await client.request({ + method: "eth_getTransactionReceipt", + params: [evmHash], + } as any) as Record | null; + const reason = rawReceipt?.revertReason ?? rawReceipt?.error; + if (typeof reason === "string" && reason.trim() !== "") { + studioReason = reason; + } + } catch { + // The status-0 receipt is authoritative. Older Studio versions may not + // expose the additive revertReason field, so preserve the generic error. + } + } + + const details = studioReason ?? revertDetails; + throw new Error( + `${operationName} reverted: EVM tx ${evmHash}${details ? `. ${details}` : ""}`, + ); +}; + const _sendEvmContractCall = async ({ client, publicClient, @@ -2439,13 +2477,7 @@ const _sendEvmContractCall = async ({ }; const serializedTransaction = await validatedAccount.signTransaction(txRequest); const evmHash = await client.sendRawTransaction({serializedTransaction}); - if (client.chain.isStudio) { - return evmHash; - } - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); - if (receipt.status === "reverted") { - throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); - } + await _waitForSentEnvelope({client, publicClient, evmHash, operationName}); return evmHash; } @@ -2461,13 +2493,7 @@ const _sendEvmContractCall = async ({ gasPrice: gasPriceHex as `0x${string}`, }], })) as `0x${string}`; - if (client.chain.isStudio) { - return evmHash; - } - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); - if (receipt.status === "reverted") { - throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); - } + await _waitForSentEnvelope({client, publicClient, evmHash, operationName}); return evmHash; }; @@ -2531,13 +2557,7 @@ const _sendConsensusCall = async ({ }; const serializedTransaction = await validatedAccount.signTransaction(txRequest); const evmHash = await client.sendRawTransaction({serializedTransaction}); - if (client.chain.isStudio) { - return evmHash; - } - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); - if (receipt.status === "reverted") { - throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); - } + await _waitForSentEnvelope({client, publicClient, evmHash, operationName}); return evmHash; } @@ -2551,13 +2571,7 @@ const _sendConsensusCall = async ({ gas: `0x${estimatedGas.toString(16)}` as `0x${string}`, }], })) as `0x${string}`; - if (client.chain.isStudio) { - return evmHash; - } - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); - if (receipt.status === "reverted") { - throw new Error(`${operationName} reverted: EVM tx ${evmHash}`); - } + await _waitForSentEnvelope({client, publicClient, evmHash, operationName}); return evmHash; }; @@ -2694,23 +2708,15 @@ const _sendTransaction = async ({ const serializedTransaction = await validatedSenderAccount.signTransaction(transactionRequest); const txHash = await client.sendRawTransaction({serializedTransaction: serializedTransaction}); + const receipt = await _waitForSentEnvelope({ + client, + publicClient, + evmHash: txHash, + operationName: "Transaction", + revertDetails: gasEstimationError ? `Gas estimation error: ${gasEstimationError}` : undefined, + }); - if (client.chain.isStudio) { - // Studio RPCs process eth_sendRawTransaction internally. The returned - // hash is already the GenLayer tx hash; there is no separate EVM - // receipt to wait for or consensus event to extract. - return txHash; - } - - const receipt = await publicClient.waitForTransactionReceipt({hash: txHash}); - - if (receipt.status === "reverted") { - throw new Error( - `Transaction reverted: EVM tx ${txHash} to consensus contract ${client.chain.consensusMainContract?.address} was reverted.${ - gasEstimationError ? ` Gas estimation error: ${gasEstimationError}` : "" - }`, - ); - } + if (client.chain.isStudio) return txHash; const txId = extractTxIdFromLogs(client, receipt.logs); if (!txId) { @@ -2761,23 +2767,15 @@ const _sendTransaction = async ({ params: [formattedRequest as any], })) as `0x${string}`; - if (client.chain.isStudio) { - // Studio RPCs process eth_sendRawTransaction internally (MetaMask signs - // and forwards). The returned hash IS the GenLayer tx hash — no need to - // wait for an EVM receipt or extract txId from logs. - return evmTxHash; - } - - // On real testnets, extract GenLayer txId from the NewTransaction event. - const externalReceipt = await publicClient.waitForTransactionReceipt({hash: evmTxHash}); + const externalReceipt = await _waitForSentEnvelope({ + client, + publicClient, + evmHash: evmTxHash, + operationName: "Transaction", + revertDetails: gasEstimationError ? `Gas estimation error: ${gasEstimationError}` : undefined, + }); - if (externalReceipt.status === "reverted") { - throw new Error( - `Transaction reverted: EVM tx ${evmTxHash} to consensus contract ${client.chain.consensusMainContract?.address} was reverted.${ - gasEstimationError ? ` Gas estimation error: ${gasEstimationError}` : "" - }`, - ); - } + if (client.chain.isStudio) return evmTxHash; const externalTxId = extractTxIdFromLogs(client, externalReceipt.logs); if (!externalTxId) { diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 5bc3e73..86bfc75 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1789,6 +1789,41 @@ describe("contractActions addTransaction ABI compatibility", () => { ).rejects.toThrow("Transaction reverted"); }); + it("surfaces Studio's receipt-level reason for a rejected write", 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 === "eth_gasPrice") return "0x1"; + if (method === "eth_getTransactionReceipt") { + return {status: "0x0", revertReason: "InsufficientFees"}; + } + throw new Error(`Unexpected RPC method: ${method}`); + }); + const publicClient = makeMockPublicClient({status: "reverted", logs: []}); + const {actions, client} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + signTransactionMock: vi.fn().mockResolvedValue("0xsigned"), + publicClient, + isStudio: true, + requestMock, + }); + (client as any).sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); + + await expect( + actions.writeContract({address: RECIPIENT_ADDRESS, functionName: "ping", value: 0n}), + ).rejects.toThrow(/InsufficientFees/); + + expect(publicClient.waitForTransactionReceipt).toHaveBeenCalledWith({hash: MOCK_EVM_TX_HASH}); + }); + it("decodes BudgetTooLow selector from gas estimation failures", async () => { const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); const signTransaction = vi.fn().mockResolvedValue("0xsigned"); @@ -2055,10 +2090,12 @@ const setupFeeManagementHarness = ({ receiptStatus = "success", isStudio = false, decisionActive = true, + studioRevertReason, }: { receiptStatus?: string; isStudio?: boolean; decisionActive?: boolean; + studioRevertReason?: string; } = {}) => { const signTransaction = vi.fn().mockResolvedValue("0xsigned"); const sendRawTransaction = vi.fn().mockResolvedValue(MOCK_EVM_TX_HASH); @@ -2100,6 +2137,9 @@ const setupFeeManagementHarness = ({ sendRawTransaction, request: vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; + if (method === "eth_getTransactionReceipt") { + return {status: "0x0", revertReason: studioRevertReason}; + } throw new Error(`Unexpected RPC method: ${method}`); }), }; @@ -2440,7 +2480,7 @@ describe("contractActions fee management", () => { ).rejects.toThrow(/Top up fees reverted/); }); - it("returns the Studio RPC hash for fee management calls without waiting for an EVM receipt", async () => { + it("returns the Studio envelope hash after confirming its EVM receipt", async () => { const {actions, waitForTransactionReceipt} = setupFeeManagementHarness({isStudio: true}); const hash = await actions.topUpFees({ @@ -2450,16 +2490,30 @@ describe("contractActions fee management", () => { }); expect(hash).toBe(MOCK_EVM_TX_HASH); - expect(waitForTransactionReceipt).not.toHaveBeenCalled(); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({hash: MOCK_EVM_TX_HASH}); + }); + + it("surfaces Studio's receipt-level revert reason", async () => { + const {actions} = setupFeeManagementHarness({ + isStudio: true, + receiptStatus: "reverted", + studioRevertReason: "TopUpCannotExtendSchedule", + }); + + await expect(actions.topUpFees({ + txId: MOCK_GENLAYER_TX_ID, + value: 1n, + distribution: {}, + })).rejects.toThrow(/TopUpCannotExtendSchedule/); }); - it("returns the Studio RPC hash for external-wallet fee management calls without waiting for an EVM receipt", async () => { + it("returns the Studio envelope hash for external-wallet fee management calls after its receipt", async () => { const request = vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "eth_gasPrice") return "0x1"; if (method === "eth_sendTransaction") return MOCK_EVM_TX_HASH; throw new Error(`Unexpected RPC method: ${method}`); }); - const waitForTransactionReceipt = vi.fn(); + const waitForTransactionReceipt = vi.fn().mockResolvedValue({status: "success", logs: []}); const client = { chain: { id: 61_127, @@ -2490,7 +2544,7 @@ describe("contractActions fee management", () => { expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "eth_sendTransaction", })); - expect(waitForTransactionReceipt).not.toHaveBeenCalled(); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({hash: MOCK_EVM_TX_HASH}); }); }); From 2b97d27e5498a797e60d0acf2e10019abc76f0d2 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 06:00:26 +0100 Subject: [PATCH 5/8] fix(fees): encode schedule-free topups --- src/contracts/actions.ts | 14 ++++++++-- src/transactions/fees.ts | 38 ++++++++++++++++++++++++++ tests/contracts-actions.test.ts | 47 +++++++++++++++++++++++++-------- 3 files changed, 86 insertions(+), 13 deletions(-) diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 6b516b5..c1dafc6 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -35,6 +35,7 @@ import {toJsonSafeDeep, b64ToArray, arrayToB64} from "@/utils/jsonifier"; import { CALL_KEY_WILDCARD, createFeesDistribution, + createTopUpFeesDistribution, MESSAGE_ALLOCATION_ROOT_PARENT_INDEX, normalizeMessageFeeAllocations, normalizeTransactionFees, @@ -2367,7 +2368,7 @@ const _encodeTopUpFeesData = ({ return encodeFunctionData({ abi: CONSENSUS_FEE_MANAGEMENT_ABI, functionName: "topUpFees", - args: [txId, createFeesDistribution(distribution)], + args: [txId, createTopUpFeesDistribution(distribution)], }); }; @@ -2400,7 +2401,16 @@ const _waitForSentEnvelope = async ({ operationName: string; revertDetails?: string; }) => { - const receipt = await publicClient.waitForTransactionReceipt({hash: evmHash}); + const receipt = await publicClient.waitForTransactionReceipt({ + hash: evmHash, + ...(client.chain.isStudio ? { + // Studio returns the envelope hash before its EVM transaction index is + // necessarily visible. viem's default six retries cover only ~12s and + // Studio reports that transient as ResourceNotFoundRpcError. + retryCount: 120, + retryDelay: 500, + } : {}), + }); if (receipt.status !== "reverted") return receipt; let studioReason: string | undefined; diff --git a/src/transactions/fees.ts b/src/transactions/fees.ts index f5aabd4..b3b390a 100644 --- a/src/transactions/fees.ts +++ b/src/transactions/fees.ts @@ -143,6 +143,44 @@ export const createFeesDistribution = (input: FeesDistributionInput = {}): FeesD }; }; +/** + * Normalizes the delta accepted by ConsensusMainWithFees.topUpFees. + * + * Existing fee-aware transactions must not resubmit or extend their appeal + * schedule, so an omitted schedule is encoded as appealRounds=0/rotations=[]. + * An explicit non-empty schedule remains available for the consensus path + * that initializes fee state on a transaction whose distribution is empty. + */ +export const createTopUpFeesDistribution = ( + input: FeesDistributionInput = {}, +): FeesDistribution => { + const appealRounds = toUInt(input.appealRounds, "fees.distribution.appealRounds"); + let rotations: bigint[]; + if (input.rotations === undefined || input.rotations.length === 0) { + if (appealRounds !== 0n) { + throw new Error( + "fees.distribution.rotations must contain appealRounds + 1 entries when appealRounds is non-zero.", + ); + } + rotations = []; + } else { + rotations = normalizeRotations(input.rotations, appealRounds, "fees.distribution.rotations"); + } + + return { + leaderTimeunitsAllocation: toUInt(input.leaderTimeunitsAllocation, "fees.distribution.leaderTimeunitsAllocation"), + validatorTimeunitsAllocation: toUInt(input.validatorTimeunitsAllocation, "fees.distribution.validatorTimeunitsAllocation"), + appealRounds, + executionBudgetPerRound: toUInt(input.executionBudgetPerRound, "fees.distribution.executionBudgetPerRound"), + executionConsumed: toUInt(input.executionConsumed, "fees.distribution.executionConsumed"), + totalMessageFees: toUInt(input.totalMessageFees, "fees.distribution.totalMessageFees"), + rotations, + maxPriceGenPerTimeUnit: toUInt(input.maxPriceGenPerTimeUnit, "fees.distribution.maxPriceGenPerTimeUnit"), + storageFeeMaxGasPrice: toUInt(input.storageFeeMaxGasPrice, "fees.distribution.storageFeeMaxGasPrice"), + receiptFeeMaxGasPrice: toUInt(input.receiptFeeMaxGasPrice, "fees.distribution.receiptFeeMaxGasPrice"), + }; +}; + export const encodeInternalMessageFeeParams = (input: InternalMessageFeeParamsInput = {}) => { const appealRounds = toUInt(input.appealRounds, "internalMessageFeeParams.appealRounds"); return encodeAbiParameters( diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 86bfc75..6ff53da 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -12,6 +12,7 @@ import { CALL_KEY_DEPLOY, CALL_KEY_UNNAMED, CALL_KEY_WILDCARD, + createTopUpFeesDistribution, DEPLOY_CALL_KEY, deployCallKey, deriveExternalMessageCallKey, @@ -1821,7 +1822,11 @@ describe("contractActions addTransaction ABI compatibility", () => { actions.writeContract({address: RECIPIENT_ADDRESS, functionName: "ping", value: 0n}), ).rejects.toThrow(/InsufficientFees/); - expect(publicClient.waitForTransactionReceipt).toHaveBeenCalledWith({hash: MOCK_EVM_TX_HASH}); + expect(publicClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: MOCK_EVM_TX_HASH, + retryCount: 120, + retryDelay: 500, + }); }); it("decodes BudgetTooLow selector from gas estimation failures", async () => { @@ -2365,6 +2370,22 @@ describe("contractActions bounded round reads", () => { }); describe("contractActions fee management", () => { + it("normalizes ordinary top-ups without resubmitting the appeal schedule", () => { + expect(createTopUpFeesDistribution({executionBudgetPerRound: 123n})).toMatchObject({ + appealRounds: 0n, + rotations: [], + executionBudgetPerRound: 123n, + }); + }); + + it("requires an explicit complete schedule when initializing appeal rounds", () => { + expect(() => createTopUpFeesDistribution({appealRounds: 1n})).toThrow( + /rotations must contain appealRounds \+ 1 entries/, + ); + expect(createTopUpFeesDistribution({appealRounds: 1n, rotations: [0n, 2n]})) + .toMatchObject({appealRounds: 1n, rotations: [0n, 2n]}); + }); + it("quotes the full authoritative appeal charge", async () => { const {actions, client} = setupFeeManagementHarness(); delete (client.chain as any).feeManagerContract; @@ -2402,12 +2423,8 @@ describe("contractActions fee management", () => { txId: MOCK_GENLAYER_TX_ID, value: 999n, distribution: { - leaderTimeunitsAllocation: 100n, - validatorTimeunitsAllocation: 200n, - appealRounds: 1n, executionBudgetPerRound: 500_000n, totalMessageFees: 30n, - rotations: [0n, 2n], maxPriceGenPerTimeUnit: 12n, storageFeeMaxGasPrice: 24n, receiptFeeMaxGasPrice: 36n, @@ -2427,12 +2444,12 @@ describe("contractActions fee management", () => { }); const [txId, distribution] = decoded.args as any[]; expect(txId).toBe(MOCK_GENLAYER_TX_ID); - expect(distribution.leaderTimeunitsAllocation).toBe(100n); - expect(distribution.validatorTimeunitsAllocation).toBe(200n); - expect(distribution.appealRounds).toBe(1n); + expect(distribution.leaderTimeunitsAllocation).toBe(0n); + expect(distribution.validatorTimeunitsAllocation).toBe(0n); + expect(distribution.appealRounds).toBe(0n); expect(distribution.executionBudgetPerRound).toBe(500_000n); expect(distribution.totalMessageFees).toBe(30n); - expect(distribution.rotations).toEqual([0n, 2n]); + expect(distribution.rotations).toEqual([]); expect(distribution.maxPriceGenPerTimeUnit).toBe(12n); expect(distribution.storageFeeMaxGasPrice).toBe(24n); expect(distribution.receiptFeeMaxGasPrice).toBe(36n); @@ -2490,7 +2507,11 @@ describe("contractActions fee management", () => { }); expect(hash).toBe(MOCK_EVM_TX_HASH); - expect(waitForTransactionReceipt).toHaveBeenCalledWith({hash: MOCK_EVM_TX_HASH}); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: MOCK_EVM_TX_HASH, + retryCount: 120, + retryDelay: 500, + }); }); it("surfaces Studio's receipt-level revert reason", async () => { @@ -2544,7 +2565,11 @@ describe("contractActions fee management", () => { expect(request).toHaveBeenCalledWith(expect.objectContaining({ method: "eth_sendTransaction", })); - expect(waitForTransactionReceipt).toHaveBeenCalledWith({hash: MOCK_EVM_TX_HASH}); + expect(waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: MOCK_EVM_TX_HASH, + retryCount: 120, + retryDelay: 500, + }); }); }); From bc1fc3203ded80bb601366412137fafbb0669fab Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 08:39:58 +0100 Subject: [PATCH 6/8] fix(transactions): discover message payment events --- src/transactions/actions.ts | 12 ++++-------- tests/transactions.test.ts | 3 ++- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/transactions/actions.ts b/src/transactions/actions.ts index 1b2c455..f978891 100644 --- a/src/transactions/actions.ts +++ b/src/transactions/actions.ts @@ -536,7 +536,9 @@ export const transactionActions = (client: GenLayerClient, public // InternalMessageProcessed indexes the child transaction ID, not its // parent. Find the EVM transactions that decided the parent first, then // inspect their receipts for the child-message events emitted alongside - // that decision. + // that decision. The event is emitted by MessagePayments, not + // ConsensusMain, so the decision receipt is the authoritative boundary + // and the event emitter address must not be restricted here. const decisionLogs = await publicClient.getLogs({ address: consensusAddress, event: undefined, @@ -553,17 +555,11 @@ export const transactionActions = (client: GenLayerClient, public publicClient.getTransactionReceipt({hash: transactionHash!}), ), ); - const normalizedConsensusAddress = consensusAddress.toLowerCase(); - return [ ...new Set( receipts.flatMap(receipt => receipt.logs - .filter( - log => - log.address.toLowerCase() === normalizedConsensusAddress && - log.topics[0] === internalMessageProcessedTopic, - ) + .filter(log => log.topics[0] === internalMessageProcessedTopic) .map(log => log.topics[1] as TransactionHash) .filter(Boolean), ), diff --git a/tests/transactions.test.ts b/tests/transactions.test.ts index 175ba3c..548c57e 100644 --- a/tests/transactions.test.ts +++ b/tests/transactions.test.ts @@ -685,6 +685,7 @@ describe("getTriggeredTransactionIds", () => { const childHash = ("0x" + "22".repeat(32)) as any; const decisionHash = ("0x" + "33".repeat(32)) as any; const consensusAddress = "0x0000000000000000000000000000000000000010"; + const messagePaymentsAddress = "0x0000000000000000000000000000000000000020"; const internalMessageTopic = keccak256( stringToBytes("InternalMessageProcessed(bytes32,address,address)"), ); @@ -697,7 +698,7 @@ describe("getTriggeredTransactionIds", () => { const getTransactionReceipt = vi.fn().mockResolvedValue({ logs: [ { - address: consensusAddress, + address: messagePaymentsAddress, topics: [internalMessageTopic, childHash], }, ], From 98d59cffce30895644c4901710494f8f375ad248 Mon Sep 17 00:00:00 2001 From: Edgars Date: Tue, 1 Sep 2026 18:12:13 +0100 Subject: [PATCH 7/8] test(appeals): lock configured contract routing Verify that canAppeal reads the active decision from the separately configured Appeals contract at the lifecycle snapshot. This catches local-chain interface drift before cross-stack E2E. --- tests/contracts-actions.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 6ff53da..6ef0d54 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -2416,6 +2416,20 @@ describe("contractActions fee management", () => { ); }); + it("reads canAppeal from the configured Appeals contract for the active decision", async () => { + const {actions, publicClient} = setupFeeManagementHarness(); + + await expect(actions.canAppeal({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(true); + expect(publicClient.readContract).toHaveBeenCalledWith( + expect.objectContaining({ + address: RECIPIENT_ADDRESS, + functionName: "canAppeal", + args: [MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID], + blockNumber: 123n, + }), + ); + }); + it("encodes topUpFees(bytes32, FeesDistribution) and returns the EVM tx hash", async () => { const {actions, signTransaction, sendRawTransaction} = setupFeeManagementHarness(); From 2f8122e81862bd5f6cf0daabcfa34b99a1bb0600 Mon Sep 17 00:00:00 2001 From: Edgars Date: Wed, 2 Sep 2026 16:19:29 +0100 Subject: [PATCH 8/8] fix(appeals): admit unfunded rounds safely Route the public appeal action through topUpAndSubmitAppeal, whose consensus-derived schedule extension is valid for both pre-funded and unfunded rounds. Keep the direct submitAppeal selector out of the high-level path so a full bond-plus-funding quote cannot still fail AppealRoundNotPermitted.\n\nCover quoted and caller-provided values on deployed Consensus and Studio, and refresh generated API docs. --- docs/api-references/contracts.md | 20 +++++++-------- src/contracts/actions.ts | 37 ++++++--------------------- tests/contracts-actions.test.ts | 43 ++++++++++++++++---------------- 3 files changed, 38 insertions(+), 62 deletions(-) diff --git a/docs/api-references/contracts.md b/docs/api-references/contracts.md index ecb3e31..c588ea7 100644 --- a/docs/api-references/contracts.md +++ b/docs/api-references/contracts.md @@ -133,6 +133,7 @@ _No parameters._ ### estimateFeesDistribution Builds a fee distribution with caps derived from the active fee policy. +Omitted rotations fund the chain's configured consensus maximum. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -188,9 +189,7 @@ should pass with the real transaction. ### getAppealCharge -Returns the full authoritative appeal charge (bond plus appeal funding) -on resolution-kernel contract networks. Current Studio has no -decision-bound quote surface. +Returns the full authoritative appeal charge (bond plus appeal funding). | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -324,9 +323,10 @@ Claims a bounded number of reward epochs for a developer NFT. Returns the EVM tr ### appealTransaction Appeals a consensus transaction to trigger a new round of validation. -Contract networks bind the call to the active decision and quote an -omitted value. Current Studio uses its native decision-free entrypoint; -its value defaults to zero when omitted. +The call is bound to the active decision on both Studio and contract +networks. The schedule-extending entry point is safe for both pre-funded +and unfunded appeals, while submitAppeal rejects an unfunded next round. +When value is omitted, the authoritative appeal charge is used. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -339,8 +339,7 @@ its value defaults to zero when omitted. ### 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. +Returns the signed EVM envelope hash on every backend. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| @@ -357,9 +356,8 @@ backends, or the target GenLayer tx id on Studio/localnet. Deposits appeal fee budget and submits an appeal in the same consensus call. Returns the existing GenLayer transaction id, matching appealTransaction. -Contract networks bind the call to the active decision and quote an -omitted value. Current Studio uses its native decision-free entrypoint; -its value defaults to zero when omitted. +The call is bound to the active decision on both Studio and contract +networks. When value is omitted, the authoritative appeal charge is used. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index c1dafc6..23af27e 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -838,7 +838,9 @@ export const contractActions = (client: GenLayerClient, publicCli /** * Appeals a consensus transaction to trigger a new round of validation. * The call is bound to the active decision on both Studio and contract - * networks. When value is omitted, the authoritative appeal charge is used. + * networks. The schedule-extending entry point is safe for both pre-funded + * and unfunded appeals, while submitAppeal rejects an unfunded next round. + * When value is omitted, the authoritative appeal charge is used. */ appealTransaction: async (args: { account?: Account; @@ -859,9 +861,13 @@ export const contractActions = (client: GenLayerClient, publicCli }); const value = args.value ?? context.requiredValue; - const encodedData = _encodeSubmitAppealData({ + const encodedData = _encodeTopUpAndSubmitAppealData({ txId, expectedDecisionId: context.decisionId, + // Consensus derives the appeal shape from live state and retains this + // normalized zero schedule only for ABI compatibility. The same call + // is therefore valid against both pre-funded and unfunded transactions. + distribution: {}, }); await _sendConsensusCall({ client, @@ -1158,19 +1164,6 @@ 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", @@ -2096,20 +2089,6 @@ const _encodeAddTransactionData = ({ }]; }; -const _encodeSubmitAppealData = ({ - txId, - expectedDecisionId, -}: { - txId: `0x${string}`; - expectedDecisionId: bigint; -}): `0x${string}` => { - return encodeFunctionData({ - abi: CONSENSUS_APPEAL_TRAIN_ABI, - functionName: "submitAppeal", - args: [txId, expectedDecisionId], - }); -}; - const _studioTrainBatchError = (action: string): Error => new Error( `${action} is not exposed by Studio's embedded consensus: ` + diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 6ef0d54..604f0f7 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -1949,19 +1949,6 @@ const FEE_MANAGEMENT_ABI = [ }, ] 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", @@ -2394,7 +2381,7 @@ describe("contractActions fee management", () => { await expect(actions.getMinAppealBond({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe(1234n); }); - it("encodes submitAppeal with the active decision id and quoted funding", async () => { + it("uses the schedule-extending appeal path with the active decision id and quoted funding", async () => { const {actions, signTransaction} = setupFeeManagementHarness(); await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID})).resolves.toBe( @@ -2403,8 +2390,13 @@ describe("contractActions fee management", () => { 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]); + const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_ABI as any, data: txRequest.data}); + const [txId, decisionId, distribution] = decoded.args as any[]; + expect(decoded.functionName).toBe("topUpAndSubmitAppeal"); + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(decisionId).toBe(MOCK_DECISION_ID); + expect(distribution.appealRounds).toBe(0n); + expect(distribution.rotations).toEqual([0n]); }); it("returns false from canAppeal when no decision is active", async () => { @@ -2781,7 +2773,7 @@ const setupStudioLifecycleHarness = () => { }; describe("contractActions Studio decision-bound surface", () => { - it("encodes submitAppeal with Studio's active decision id", async () => { + it("uses Studio's schedule-extending appeal path with the active decision id", async () => { const {actions, signTransaction, readContract, client} = setupStudioLifecycleHarness(); await expect(actions.appealTransaction({txId: MOCK_GENLAYER_TX_ID, value: 500n})).resolves.toBe( @@ -2792,9 +2784,13 @@ describe("contractActions Studio decision-bound surface", () => { 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, MOCK_DECISION_ID]); + const decoded = decodeFunctionData({abi: FEE_MANAGEMENT_STUDIO_ABI as any, data: txRequest.data}); + const [txId, decisionId, distribution] = decoded.args as any[]; + expect(decoded.functionName).toBe("topUpAndSubmitAppeal"); + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(decisionId).toBe(MOCK_DECISION_ID); + expect(distribution.appealRounds).toBe(0n); + expect(distribution.rotations).toEqual([0n]); expect(client.request).toHaveBeenCalledWith({ method: "gen_getTransactionLifecycle", params: [{txId: MOCK_GENLAYER_TX_ID}], @@ -2884,9 +2880,12 @@ describe("contractActions Studio decision-bound surface", () => { expect.objectContaining({functionName: "getTransactionLifecycle"}), ); const decoded = decodeFunctionData({ - abi: APPEAL_TRAIN_ABI, + abi: FEE_MANAGEMENT_ABI as any, data: signTransaction.mock.calls[0][0].data, }); - expect(decoded.args).toEqual([MOCK_GENLAYER_TX_ID, MOCK_DECISION_ID]); + const [txId, decisionId] = decoded.args as any[]; + expect(decoded.functionName).toBe("topUpAndSubmitAppeal"); + expect(txId).toBe(MOCK_GENLAYER_TX_ID); + expect(decisionId).toBe(MOCK_DECISION_ID); }); });