From 9a231aeb14cb1ca08bd8016e95919458f400171f Mon Sep 17 00:00:00 2001 From: Edgars Date: Mon, 28 Sep 2026 10:41:22 +0100 Subject: [PATCH] fix(fees): price receipt floor with wrapper and terminal reserve Read FeeManager receiptWrapperBytes and quote propose-receipt gas with the effective gasPrice. Plain eth_call can expose tx.gasprice=0, which makes the Nitro rollup gas model revert. Avoid the zero-price messageFeeParamsBudgetFloor view and use the configured receipt wrapper plus Consensus #1692 terminal reserve. Recognize chain-derived pricing even when its plain-call quote is zero. Mirror the same floor in Studio when its RPC omits the authoritative value. Validation: 202 Vitest tests and typecheck, production build, changed-file ESLint, and diff whitespace check. --- src/contracts/actions.ts | 120 ++++++++++++++++++++++---------- tests/contracts-actions.test.ts | 117 +++++++++++++++++++++++++++---- 2 files changed, 187 insertions(+), 50 deletions(-) diff --git a/src/contracts/actions.ts b/src/contracts/actions.ts index 23af27e..624e6ae 100644 --- a/src/contracts/actions.ts +++ b/src/contracts/actions.ts @@ -30,7 +30,7 @@ import { ConsensusRoundData, ConsensusLastRoundData, } from "@/types"; -import {fromHex, toHex, zeroAddress, encodeFunctionData, PublicClient, parseEventLogs, type Abi} from "viem"; +import {fromHex, toHex, zeroAddress, encodeFunctionData, decodeFunctionResult, PublicClient, parseEventLogs, type Abi} from "viem"; import {toJsonSafeDeep, b64ToArray, arrayToB64} from "@/utils/jsonifier"; import { CALL_KEY_WILDCARD, @@ -1242,11 +1242,25 @@ const FEE_MANAGER_CALCULATE_ROUND_FEES_ABI = [ }, { type: "function", - name: "messageFeeParamsBudgetFloor", + name: "receiptWrapperBytes", stateMutability: "view", inputs: [], outputs: [{name: "", type: "uint256"}], }, + { + type: "function", + name: "useChainDerivedReceiptPrice", + stateMutability: "view", + inputs: [], + outputs: [{name: "", type: "bool"}], + }, + { + type: "function", + name: "estimateProposeReceiptGas", + stateMutability: "view", + inputs: [{name: "_receiptBytes", type: "uint256"}], + outputs: [{name: "", type: "uint256"}], + }, { type: "function", name: "calculateRoundFees", @@ -1372,11 +1386,9 @@ const DEFAULT_BOOTLOADER_OVERHEAD = 60_000n; const DEFAULT_GAS_PER_CHANGED_SLOT = 1_000n; const DEFAULT_CALLDATA_GAS_PER_BYTE = 16n; const DEFAULT_FIXED_PROPOSE_RECEIPT_GAS = 210_000n; -const DEFAULT_FIXED_MESSAGE_REVEAL_GAS = 100_000n; -// ConsensusHelpers.MIN_RECEIPT_BYTES — smallest receipt payload the on-chain budget floor prices. -const DEFAULT_MIN_RECEIPT_BYTES = 512n; -const DEFAULT_MESSAGE_REVEAL_LENGTH_SLOTS = 32n; -const DEFAULT_NONDET_OUTPUT_LENGTH_BYTES = 32n; +const DEFAULT_RECEIPT_WRAPPER_BYTES = 1_024n; +// FeeProfileHelpers.MIN_TERMINAL_OUTPUT_BYTES: startup reserve for an empty failure envelope. +const MIN_TERMINAL_OUTPUT_BYTES = 64n; const TRANSACTION_GAS_HEADROOM_BPS = 20_000n; const DEFAULT_PARENT_MESSAGE_RECEIPT_HEADROOM = 10_000n; const VALIDATORS_PER_ROUND = [ @@ -1418,6 +1430,30 @@ const bigintFromUnknown = (value: unknown, fieldName: string, fallback = 0n): bi throw new Error(`${fieldName} is not an integer value.`); }; +const proposeReceiptBudgetFloor = ({ + receiptGasPrice, + receiptWrapperBytes, + fixedProposeReceiptGas, + intrinsicGas, + bootloaderOverhead, + calldataGasPerByte, + gasPerChangedSlot, +}: { + receiptGasPrice: bigint; + receiptWrapperBytes: bigint; + fixedProposeReceiptGas: bigint; + intrinsicGas: bigint; + bootloaderOverhead: bigint; + calldataGasPerByte: bigint; + gasPerChangedSlot: bigint; +}): bigint => receiptGasPrice * ( + fixedProposeReceiptGas + + intrinsicGas + + bootloaderOverhead + + ((receiptWrapperBytes + MIN_TERMINAL_OUTPUT_BYTES) * calldataGasPerByte) + + (DEFAULT_RECEIPT_SLOTS_CHANGED * gasPerChangedSlot) +); + const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { const configRecord = config && typeof config === "object" && !Array.isArray(config) ? config as Record @@ -1462,23 +1498,21 @@ const extractStudioFeePolicy = (config: unknown): FeePolicyQuote => { "policy.fixedProposeReceiptGas", DEFAULT_FIXED_PROPOSE_RECEIPT_GAS, ); - const fixedMessageRevealGas = bigintFromUnknown( - policyRecord.fixedMessageRevealGas, - "policy.fixedMessageRevealGas", - DEFAULT_FIXED_MESSAGE_REVEAL_GAS, + const receiptWrapperBytes = bigintFromUnknown( + policyRecord.receiptWrapperBytes, + "policy.receiptWrapperBytes", + DEFAULT_RECEIPT_WRAPPER_BYTES, ); const executionBudgetFloor = policyRecord.messageFeeParamsBudgetFloor == null - ? receiptGasPrice * ( - fixedProposeReceiptGas + - intrinsicGas + - bootloaderOverhead + - (DEFAULT_RECEIPT_SLOTS_CHANGED * gasPerChangedSlot) + - fixedMessageRevealGas + - intrinsicGas + - bootloaderOverhead + - (DEFAULT_MESSAGE_REVEAL_LENGTH_SLOTS * gasPerChangedSlot) + - (DEFAULT_NONDET_OUTPUT_LENGTH_BYTES * calldataGasPerByte) - ) + ? proposeReceiptBudgetFloor({ + receiptGasPrice, + receiptWrapperBytes, + fixedProposeReceiptGas, + intrinsicGas, + bootloaderOverhead, + calldataGasPerByte, + gasPerChangedSlot, + }) : bigintFromUnknown( policyRecord.messageFeeParamsBudgetFloor, "policy.messageFeeParamsBudgetFloor", @@ -1513,38 +1547,48 @@ const readCurrentFeePolicy = async ( const address = client.chain.feeManagerContract.address as `0x${string}`; const abi = FEE_MANAGER_CALCULATE_ROUND_FEES_ABI as any; - const [genPerTimeUnit, storageUnitPrice, quotedReceiptGasPrice, executionBudgetFloor] = await Promise.all([ + const [genPerTimeUnit, storageUnitPrice, quotedReceiptGasPrice, receiptWrapperBytes, useChainDerivedReceiptPrice] = await Promise.all([ publicClient.readContract({address, abi, functionName: "GENPerTimeUnit", args: []}) as Promise, publicClient.readContract({address, abi, functionName: "storageUnitPrice", args: []}) as Promise, publicClient.readContract({address, abi, functionName: "quoteGasPrice", args: []}) as Promise, - publicClient.readContract({address, abi, functionName: "messageFeeParamsBudgetFloor", args: []}) as Promise, + publicClient.readContract({address, abi, functionName: "receiptWrapperBytes", args: []}) as Promise, + publicClient.readContract({address, abi, functionName: "useChainDerivedReceiptPrice", args: []}) as Promise, ]); - const enabled = genPerTimeUnit > 0n || storageUnitPrice > 0n || quotedReceiptGasPrice > 0n; + const enabled = useChainDerivedReceiptPrice || genPerTimeUnit > 0n || storageUnitPrice > 0n || quotedReceiptGasPrice > 0n; const networkReceiptGasPrice = enabled ? await publicClient.getGasPrice() : 0n; const receiptGasPrice = maxBigint(quotedReceiptGasPrice, networkReceiptGasPrice); if (enabled && receiptGasPrice === 0n) { throw new Error("receipt gas price quoted as zero; refusing to build a zero price cap"); } - // messageFeeParamsBudgetFloor() multiplies by quoteGasPrice() on-chain, which reads - // tx.gasprice ~ 0 under a plain eth_call — so the view can report a zero floor on - // chain-derived networks while the real submission-time floor is non-zero. Recompute - // the floor locally at the effective receipt price (FeeManager.estimateProposeReceiptGas - // at ConsensusHelpers.MIN_RECEIPT_BYTES) and take the max. - const localExecutionBudgetFloor = receiptGasPrice * ( - DEFAULT_FIXED_PROPOSE_RECEIPT_GAS + - DEFAULT_INTRINSIC_GAS + - DEFAULT_BOOTLOADER_OVERHEAD + - (DEFAULT_MIN_RECEIPT_BYTES * DEFAULT_CALLDATA_GAS_PER_BYTE) + - (DEFAULT_RECEIPT_SLOTS_CHANGED * DEFAULT_GAS_PER_CHANGED_SLOT) - ); + // A plain eth_call may have tx.gasprice == 0. On Nitro the fee model rejects + // that value, so quote gas with the effective price in the call context. + // The contract then applies its live governance and Nitro gas parameters. + let executionBudgetFloor = 0n; + if (receiptGasPrice > 0n) { + const data = encodeFunctionData({ + abi, + functionName: "estimateProposeReceiptGas", + args: [receiptWrapperBytes + MIN_TERMINAL_OUTPUT_BYTES], + }); + const result = await publicClient.call({to: address, data, gasPrice: receiptGasPrice, batch: false}); + if (!result.data) { + throw new Error("FeeManager.estimateProposeReceiptGas returned no data."); + } + const proposeReceiptGas = decodeFunctionResult({ + abi, + functionName: "estimateProposeReceiptGas", + data: result.data, + }) as bigint; + executionBudgetFloor = receiptGasPrice * proposeReceiptGas; + } return { enabled, genPerTimeUnit, storageUnitPrice, receiptGasPrice, - executionBudgetFloor: maxBigint(executionBudgetFloor, localExecutionBudgetFloor), + executionBudgetFloor, // Live networks quote through FeeManager.calculateRoundFees; this field is // only consumed by Studio's local mirror. timeUnitOverlayBps: 0n, diff --git a/tests/contracts-actions.test.ts b/tests/contracts-actions.test.ts index 604f0f7..55607aa 100644 --- a/tests/contracts-actions.test.ts +++ b/tests/contracts-actions.test.ts @@ -279,6 +279,13 @@ const setupWriteContractHarness = ({ return {actions, estimateTransactionGas, client, signTransaction, publicClient}; }; +const mockNitroReceiptGasCall = (estimatedGas: bigint) => vi.fn().mockImplementation( + async ({gasPrice}: {gasPrice?: bigint}) => { + if (!gasPrice) throw new Error("InvalidRollupGasPrice"); + return {data: toHex(estimatedGas, {size: 32})}; + }, +); + const setupDeveloperNftHarness = ({ readContractMock, signTransactionMock, @@ -1028,11 +1035,13 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "GENPerTimeUnit") return 10n; if (functionName === "storageUnitPrice") return 20n; if (functionName === "quoteGasPrice") return 30n; - if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return false; if (functionName === "calculateRoundFees") return 77n; throw new Error(`unexpected readContract ${functionName}`); }), getGasPrice: vi.fn().mockResolvedValue(1n), + call: mockNitroReceiptGasCall(315_408n), }; const {actions} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, @@ -1042,12 +1051,10 @@ describe("contractActions addTransaction ABI compatibility", () => { const fees = await actions.estimateTransactionFees({totalMessageFees: 5n}); - // Effective floor = max(on-chain view (1,234 — reads ~0-priced quoteGasPrice under - // eth_call), local recompute at the effective receipt price). Local formula pins - // FeeManager.estimateProposeReceiptGas(MIN_RECEIPT_BYTES=512): - // 210,000 + 21,000 + 60,000 + 512*16 + 7*1,000 = 306,192 gas. - const expectedLocalFloor = 30n * (210_000n + 21_000n + 60_000n + 512n * 16n + 7n * 1_000n); - expect(expectedLocalFloor).toBe(30n * 306_192n); + // Consensus prices the live 1,024-byte wrapper plus 64 terminal-output bytes: + // 210,000 + 21,000 + 60,000 + 1,088*16 + 7*1,000 = 315,408 gas. + const expectedLocalFloor = 30n * (210_000n + 21_000n + 60_000n + 1_088n * 16n + 7n * 1_000n); + expect(expectedLocalFloor).toBe(30n * 315_408n); expect(fees.policy).toEqual({ enabled: true, genPerTimeUnit: 10n, @@ -1061,19 +1068,33 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.distribution.receiptFeeMaxGasPrice).toBe(36n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_300_000n); expect(fees.feeValue).toBe(82n); + expect(publicClient.readContract).toHaveBeenCalledWith(expect.objectContaining({ + functionName: "receiptWrapperBytes", + })); + expect(publicClient.call).toHaveBeenCalledWith(expect.objectContaining({ + to: "0x00000000000000000000000000000000000000fe", + gasPrice: 30n, + batch: false, + })); + expect(publicClient.call.mock.calls[0][0].data.slice(-64)).toBe(toHex(1_088n, {size: 32}).slice(2)); + expect(publicClient.readContract).not.toHaveBeenCalledWith(expect.objectContaining({ + functionName: "messageFeeParamsBudgetFloor", + })); }); - it("uses the network gas price when FeeManager quotes a lower receipt gas price", async () => { + it("uses network gas price and FeeManager's configured receipt gas model", async () => { const publicClient = { readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { if (functionName === "GENPerTimeUnit") return 10n; if (functionName === "storageUnitPrice") return 20n; if (functionName === "quoteGasPrice") return 0n; - if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "receiptWrapperBytes") return 2_048n; + if (functionName === "useChainDerivedReceiptPrice") return true; if (functionName === "calculateRoundFees") return 77n; throw new Error(`unexpected readContract ${functionName}`); }), getGasPrice: vi.fn().mockResolvedValue(25n), + call: mockNitroReceiptGasCall(400_000n), }; const {actions} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, @@ -1085,7 +1106,47 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(publicClient.getGasPrice).toHaveBeenCalledOnce(); expect(fees.policy.receiptGasPrice).toBe(25n); + expect(fees.policy.executionBudgetFloor).toBe(25n * 400_000n); expect(fees.distribution.receiptFeeMaxGasPrice).toBe(25n); + expect(publicClient.call).toHaveBeenCalledWith(expect.objectContaining({ + gasPrice: 25n, + batch: false, + })); + expect(publicClient.call.mock.calls[0][0].data.slice(-64)).toBe(toHex(2_112n, {size: 32}).slice(2)); + }); + + it("prices receipts when chain-derived quote is zero under eth_call", async () => { + const publicClient = { + readContract: vi.fn().mockImplementation(async ({functionName}: {functionName: string}) => { + if (functionName === "GENPerTimeUnit") return 0n; + if (functionName === "storageUnitPrice") return 0n; + if (functionName === "quoteGasPrice") return 0n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return true; + if (functionName === "calculateRoundFees") return 77n; + throw new Error(`unexpected readContract ${functionName}`); + }), + getGasPrice: vi.fn().mockResolvedValue(25n), + call: mockNitroReceiptGasCall(315_408n), + }; + const {actions} = setupWriteContractHarness({ + initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, + publicClient, + feeManagerAddress: "0x00000000000000000000000000000000000000fe", + }); + + const fees = await actions.estimateTransactionFees(); + + expect(fees.policy.enabled).toBe(true); + expect(fees.policy.receiptGasPrice).toBe(25n); + expect(fees.policy.executionBudgetFloor).toBe(25n * 315_408n); + expect(fees.feeValue).toBe(77n); + expect(publicClient.getGasPrice).toHaveBeenCalledOnce(); + expect(publicClient.call).toHaveBeenCalledWith(expect.objectContaining({gasPrice: 25n})); + const roundFeeCall = publicClient.readContract.mock.calls.find(([call]) => + call.functionName === "calculateRoundFees", + )?.[0]; + expect(roundFeeCall.args[0].executionBudgetPerRound).toBeGreaterThan(0n); }); it("throws instead of building a zero receipt gas price cap when policy is enabled", async () => { @@ -1094,7 +1155,8 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "GENPerTimeUnit") return 10n; if (functionName === "storageUnitPrice") return 0n; if (functionName === "quoteGasPrice") return 0n; - if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return true; throw new Error(`unexpected readContract ${functionName}`); }), getGasPrice: vi.fn().mockResolvedValue(0n), @@ -1116,7 +1178,8 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "GENPerTimeUnit") return 0n; if (functionName === "storageUnitPrice") return 0n; if (functionName === "quoteGasPrice") return 0n; - if (functionName === "messageFeeParamsBudgetFloor") return 0n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return false; if (functionName === "calculateRoundFees") return 0n; throw new Error(`unexpected readContract ${functionName}`); }), @@ -1141,11 +1204,13 @@ describe("contractActions addTransaction ABI compatibility", () => { if (functionName === "GENPerTimeUnit") return 10n; if (functionName === "storageUnitPrice") return 20n; if (functionName === "quoteGasPrice") return 30n; - if (functionName === "messageFeeParamsBudgetFloor") return 1_234n; + if (functionName === "receiptWrapperBytes") return 1_024n; + if (functionName === "useChainDerivedReceiptPrice") return true; if (functionName === "calculateRoundFees") return 77n; throw new Error(`unexpected readContract ${functionName}`); }), getGasPrice: vi.fn().mockResolvedValue(1n), + call: mockNitroReceiptGasCall(315_408n), }; const {actions} = setupWriteContractHarness({ initialAbi: ADD_TRANSACTION_ABI_WITH_FEES, @@ -1207,10 +1272,38 @@ describe("contractActions addTransaction ABI compatibility", () => { expect(fees.distribution.maxPriceGenPerTimeUnit).toBe(10n); expect(fees.distribution.storageFeeMaxGasPrice).toBe(20n); expect(fees.distribution.receiptFeeMaxGasPrice).toBe(30n); + expect(fees.policy.executionBudgetFloor).toBe(30n * 315_408n); expect(fees.distribution.executionBudgetPerRound).toBe(3_000_000_000n); expect(fees.feeValue).toBe(12_000_044_000n); }); + it("uses Studio's configured wrapper when its fee floor is absent", async () => { + const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { + if (method === "sim_getFeeConfig") { + return { + enabled: true, + policy: { + genPerTimeUnit: "10", + storageUnitPrice: "20", + receiptGasPrice: "30", + receiptWrapperBytes: "2048", + }, + }; + } + 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(); + + expect(fees.policy.executionBudgetFloor).toBe(30n * 331_792n); + }); + it("prefers Studio's exposed message fee budget floor over local fallback math", async () => { const requestMock = vi.fn().mockImplementation(async ({method}: {method: string}) => { if (method === "sim_getFeeConfig") {