Skip to content

Conversation

@ibremseth
Copy link
Collaborator

What does this PR do?

Summary:
Updates upgradable contracts to use an external contract for tracking gas called GasMeter. Any appchain can call this contract directly with the function they want tracked (through the fallback function on GasMeter) or explicitly with .meterCall(...). GasMeter will then call back to the msg.sender with the passed in calldata (or exact calldata if through the fallback function) while keeping track of how much gas it uses and stores it per epoch.

How can this PR be tested?

forge test

Comment on lines 77 to 79
fallback() external {
meterCall(msg.data);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not needed, but I kinda think its a cool way for SeqChains contracts to call GasMeter for any of its functions! If people hate it, happy to drop

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the MyContract(gasMeterAddress).fooBar syntax, but think it's not worth it since using a fallback function can lead to function name collisions and unexpected behavior if the gas aggregator and sequencing contracts define a function with the same name and parameters. This is especially dangerous if the gas aggregator is updated later on to add new functions to it - the contract upgrade can break existing sequencing contracts that will end up calling the newly added function instead of fallback().

Instead of abi.encodeWithSelector, you can use abi.encodeCall to get the same type-checking benefits as the fallback approach, even if the syntax is slightly uglier.

uint256 version;
/// @notice The address of the GasMeter contract
/// @dev This is set during initialization and never changes
address gasMeter;
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it makes sense to move this into a different struct, since its not related to the CORE attributes of the chain and we could probably drop it once emissions are done?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make this an immutable variable instead to save gas - if we really want to modify this post-deployment, we can just redeploy the implementation contract with a new gasMeter value and upgrade the proxy to the new implementation.

@ibremseth ibremseth marked this pull request as ready for review October 23, 2025 18:24
@tsite tsite self-requested a review October 23, 2025 20:06
Copy link
Collaborator

@tsite tsite left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Amazing work - I love this design pattern & think it's a really clean and extensible way to track gas and run pre/post tx hooks! I left a couple small comments/questions, but it looks great overall

Comment on lines 77 to 79
fallback() external {
meterCall(msg.data);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the MyContract(gasMeterAddress).fooBar syntax, but think it's not worth it since using a fallback function can lead to function name collisions and unexpected behavior if the gas aggregator and sequencing contracts define a function with the same name and parameters. This is especially dangerous if the gas aggregator is updated later on to add new functions to it - the contract upgrade can break existing sequencing contracts that will end up calling the newly added function instead of fallback().

Instead of abi.encodeWithSelector, you can use abi.encodeCall to get the same type-checking benefits as the fallback approach, even if the syntax is slightly uglier.

uint256 version;
/// @notice The address of the GasMeter contract
/// @dev This is set during initialization and never changes
address gasMeter;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would make this an immutable variable instead to save gas - if we really want to modify this post-deployment, we can just redeploy the implementation contract with a new gasMeter value and upgrade the proxy to the new implementation.

uint256 startGas = gasleft();
(bool success, bytes memory result) = address(msg.sender).call(meteredCall);
if (!success) {
revert(string(result));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

think we should use
assembly { revert(result, mload(result)) }
instead to perfectly forward the revert reason. I think revert(string) will double abi-encode the error since result should already be abi-encoded, but not 100% sure if that's how it works.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried updating to that assembly code, but a bunch of tests started failing due to lack of revert reasons, so I think the current way is correct

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah yep it should be assembly { revert(add(result, 32), mload(result)) } instead. I guess revert(string(result)) works too though, apparently the compiler is smart enough to figure out that it's already encoded and doesn't double encode the error.

Copy link
Collaborator

@tsite tsite Oct 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some digging and it turns out the error is being double abi encoded, just the foundry vm.expectRevert function automatically strips the "Error(string)" selector that revert(string) adds to errors if it is present. So I think we should definitely use the assembly code to prevent that.

if (!success) {
revert(string(result));
}
_getGasMeterStorage().gasUsed[getCurrentEpoch()][msg.sender] += startGas - gasleft();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may want to add
assembly { return(result, mload(result)) }
to perfectly forward the return value. I doubt any code will use this return value, but it's probably worth forwarding it regardless just in case.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also think this should be (startGas - gasleft()) * tx.gasprice instead

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the assembly should be
assembly { return(add(result, 32), mload(result)) }
instead


/// @notice Disable gas tracking if needed
/// @dev Only callable by the contract owner
function disableGasTracking() external onlyOwner {
Copy link
Collaborator

@tsite tsite Oct 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one nice property of this design is the chain owner can disable gas tracking at any point in time by upgrading the sequencing contract to remove the calls to the gas meter

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. It would be nice to keep it.

/// @dev Allows for contracts to call the gas meter address like they would call a function on their own contract to track gas used
/// Allows for usage like: MyContract(gasMeterAddress).fooBar(arg1, arg2, arg3);
/// Instead of: gasMeterAddress.meterCall(abi.encodeWithSelector(MyContract.fooBar.selector, arg1, arg2, arg3));
fallback() external {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think we need some sort of reentrancy guard, to prevent a contract doing:

gas meter -> myImpl -> gas meter -> myImpl -> ...

Copy link
Collaborator

@tsite tsite Oct 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good catch. alternatively, I think we can allow reentrancy and change the code to something like

uint256 startGas = gasleft() + _getGasMeterStorage().gasUsed[getCurrentEpoch()][msg.sender];
address(msg.sender).call(meteredCall);
_getGasMeterStorage().gasUsed[getCurrentEpoch()][msg.sender] = startGas - gasleft();

instead

/// @notice Meter a call and track the gas used
/// @dev Meters the gas used for a call and tracks it in the gas used mapping
/// @param meteredCall The call to track gas for
function meterCall(bytes calldata meteredCall) public {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we make this internal, so all calls come via the fallback method?

Copy link
Collaborator

@jorgemmsilva jorgemmsilva left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this solution is really elegant and a step up from what we had before, great thinking! 🧠

@tsite tsite self-requested a review October 27, 2025 17:47
Copy link
Collaborator

@tsite tsite left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm % immutable gasMeter variable, return value forwarding, and ReentrancyGuardTransientUpgradeable

import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {EpochTracker} from "./EpochTracker.sol";
import {ReentrancyGuardUpgradeable} from "@openzeppelin/contracts-upgradeable/utils/ReentrancyGuardUpgradeable.sol";
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we use ReentrancyGuardTransientUpgradeable instead? that uses transient instead of regular storage and should be a lot cheaper in terms of gas cost

abi.encodeCall(SyndicateSequencingChain.initialize, (admin, address(permissionModule), chainId));
(bool upgradeSuccess,) = sequencingChain.call(
abi.encodeWithSignature("upgradeToAndCall(address,bytes)", $.syndicateChainImpl, initData)
abi.encodeWithSignature("upgradeToAndCall(address,bytes)", syndicateChainImpl, initData)
Copy link
Collaborator

@tsite tsite Oct 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I think abi.encodeCall(UUPSUpgradeable.upgradeToAndCall, ...) should work here for explicit type checking

}
}

uint256 gasPrice = tx.gasprice == 0 ? 1 : tx.gasprice;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably leave a comment that explains why this workaround is needed for gas estimation.
The necessity of 0 ? 1 might be lost to the reader in the future

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants