Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 82 additions & 38 deletions src/contracts/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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<string, unknown>
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<bigint>,
publicClient.readContract({address, abi, functionName: "storageUnitPrice", args: []}) as Promise<bigint>,
publicClient.readContract({address, abi, functionName: "quoteGasPrice", args: []}) as Promise<bigint>,
publicClient.readContract({address, abi, functionName: "messageFeeParamsBudgetFloor", args: []}) as Promise<bigint>,
publicClient.readContract({address, abi, functionName: "receiptWrapperBytes", args: []}) as Promise<bigint>,
publicClient.readContract({address, abi, functionName: "useChainDerivedReceiptPrice", args: []}) as Promise<boolean>,
]);
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,
Expand Down
117 changes: 105 additions & 12 deletions tests/contracts-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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 () => {
Expand All @@ -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),
Expand All @@ -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}`);
}),
Expand All @@ -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,
Expand Down Expand Up @@ -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") {
Expand Down
Loading