Skip to content

Conversation

cryptoAtwill
Copy link
Contributor

@cryptoAtwill cryptoAtwill commented Jul 22, 2025

This PR adds light client commitment to fendermint app state.

At the end of each block , the old checkpoint process is replaced with generating light client commitments instead of old bottom up checkpoints. When commit from abci is called, it will collect the light client commitments (if any) to derive a app hash that includes the old state param with new light client commitments.

This new app hash will be forwards to solidity light client contract to validate the cometbft precommit signatures quorum cert.

Then replayer will then collect all the commitments to submit the cometbft header including the app hash.

Key changes:
Contracts:

  • Added SubnetActorCheckpointFacet contract to validate cometbft signed header
  • Added app hash validation logic to SubnetActorCheckpointFacet and side effects execution.
  • Removed BottomUpCheckpoint and updated tests
    Fendermint:
  • Removed signature submission for bottom up checkpoint
  • Updated end block logic in interpreter to generate light client commitments.
    Relayer:
  • Replaced old bottom up checkpoint process to query cometbft signed header and submit side effects for execution.

Current submission flow:


Note

Replaces bottom-up checkpoints with CometBFT light client commitments across contracts, node, and relayer, updating app hash derivation and submission/exec flows.

  • Contracts:
    • Add SubnetActorCheckpointingFacet to validate CometBFT signed headers and execute side effects; emit StateCommitmentCreated and support batch exec via commitments.
    • Remove BottomUpCheckpoint/quorum logic and related getters; update tests and selector gen.
    • Wire new Tendermint light-client types (tendermint-sol), add chainID to Subnet params.
  • Node (Fendermint):
    • Replace checkpointing with end-block generation of LightClientCommitments; include in snapshot and app hash (new derive path) and state payloads.
    • Remove bottom-up signature flow/managers; add EndBlockManager and commitment plumbing.
  • Relayer/Provider:
    • Query CometBFT SignedHeader (new RPC & types), submit headers to subnet, confirm validator changes, and execute bottom-up batches using commitments.
    • Replace old checkpoint/quorum code; add active/waiting validator listing; adjust submission period/last height APIs.
  • API:
    • Add eth_getCommitSignedHeader; various ABI/type conversions updated; remove unused checkpoint APIs.
  • CLI/Genesis:
    • Add chain-id to subnet create; remove old checkpoint commands; make genesis chain_id mandatory and persisted.
  • Misc:
    • Add tendermint-sol submodule and deps; update storage-layouts accordingly.

Written by Cursor Bugbot for commit e6900d0. This will update automatically on new commits. Configure here.

@cryptoAtwill cryptoAtwill requested a review from a team as a code owner July 22, 2025 15:08
cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

cursor[bot]

This comment was marked as outdated.

return s.networkName;
}

/// @notice Returns a specific bottom-up checkpoint based on an epoch number.
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there any reason to keep all code related to the checkpoints quorum?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I missed them in deleting code, will delete one more round.

/// @param checkpoint The executed bottom-up checkpoint.
/// @param signatories The addresses of validators signing the checkpoint.
/// @param signatures The signatures of validators on the checkpoint.
function submitCheckpoint(
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you explain why this was removed?
Conceptually we should still have the notion of checkpoint submission to the parent, as it's a larger process than merely submitting the signing data.

Any reason why to decouple the activity rollup & bottom-up messages commitment from that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For the second question, it's mainly for gas considerations. The verification of signed header and signatures consumes a lot of gas. Adding more processing to existing process will likely take longer to be mined.

Copy link
Contributor

Choose a reason for hiding this comment

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

In that question I was referring to the commitment (hash) of the activity rollup & the bottom-up messages.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have split them into separate functions as it provides max flexibility. If there is no activity required for a subnet, then activity rollup should never be used. If someone wants to merge them in one transaction, they can simply use multi-call pattern.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not sure i'm convinced.. if there was no activity, then it can be marked with a zero-value commitment, or something similar. Otherwise, the state of the checkpoint in that regard is unknown.

In addition, I don't think the current implementation supports multiple calls to execBottomUpMsgBatch for the same checkpoint.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I understand where you are coming from, but I still think there are more flexibility if we split the calls and putting them in one call does not add much more.

I dont consider the state of the checkpoint in that regard is unknown. to be true, because the checkpoint is the signed header submission, which is already handled. These are just executing side effects, merging them in one method does not change the face that the checkpoint is committed. And if no one cares about the activity, then no one needs to do anything about it. Putting them together forces these two to be handled simultaneously.

Eventually we will move to supporting single bottom up message batch execution, then we will have to break the methods into two.

// validate signatures and quorum threshold, revert if validation fails
validateActiveQuorumSignatures({signatories: signatories, hash: checkpointHash, signatures: signatures});
// TODO: The parameter should be SignedHeader.Data, but ethers-rust failed to generate the bindings
function submitSignedHeader(bytes calldata rawData) external whenNotPaused {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a cometbft-specific implementation. We need to introduce polymorphism, where the variant of the verifying client is declared for the given subnet.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm thinking the polymorphism should be from the Facet. I think that might be a better way to keep things simpler. Currently we dont have other checkpoint mechanism, I dont really see a point of polymorphism at the moment to create something that does not exist.

Copy link
Contributor

Choose a reason for hiding this comment

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

The IPC roadmap is to allow configurable consensus mechanisms, so we should cater for it, even if it doesn't exist yet.

Making the Facet as the configurable component is fine, but it currently carries other, non-related functionality.

I suggest to see the checkpoint mechanism as fixed, decoupled from a configurable verifying light client component.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated, the naming has been reverted.

Copy link
Contributor

Choose a reason for hiding this comment

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

I wasn't referring to the naming here.

}

function ensureValidatorSubmission(uint256 i, bytes memory incomingValidator) internal view returns (uint256 power, address validator) {
validator = LibPower.getActiveValidatorAddressByIndex(uint16(i));
Copy link
Contributor

Choose a reason for hiding this comment

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

Can you explain how the signature index correlates with the validator priority position?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I will add doc to this. TLDR, they must match.

Copy link
Contributor

Choose a reason for hiding this comment

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

You added a comment that it should match, which is fine for the contract side. But you didn't explain what exactly is making sure it is matching, given that the list is fetched from CometBFT, without being re-ordered prior to calling submitBottomUpCheckpoint.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Simply put, the validator orders are checked before SignedHeader submission, then the validator signatures are sorted against that order, see

header.order_commit_against(pubkeys)?;

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, I now see the code in the off-chain relayer. Please add some docs there explaining why this ordering is taking place.

string memory _chainID,
uint256 idx
) internal pure returns (bytes memory) {
CanonicalVote.Data memory vote = CanonicalVote.Data({
Copy link
Contributor

Choose a reason for hiding this comment

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

What prevents the signed header to contain an arbitrary app_hash?
It doesn't seems to be used here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it cannot be arbitrary app_hash because checkCommitHash checks it before reaching this step

Copy link
Contributor

Choose a reason for hiding this comment

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

It's not clear to me what exactly checkCommitHash is checking, and how that relates specifically to app_hash. Please provide more docs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

updated!

Copy link
Contributor

Choose a reason for hiding this comment

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

It still not clear what exactly checkCommitHash is checking.

  1. What header.hash() is including?
  2. You wrote "This also makes sure the AppHash is not fabricated" without explaining how it's done.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

function hash(SignedHeader.Data memory h) internal pure returns (bytes32) {
        require(h.header.validators_hash.length > 0, "Tendermint: hash can't be empty");

        bytes memory hbz = Consensus.encode(h.header.version);
        bytes memory pbt = Timestamp.encode(h.header.time);
        bytes memory bzbi = BlockID.encode(h.header.last_block_id);

        bytes[14] memory all = [
            hbz,
            Encoder.cdcEncode(h.header.chain_id),
            Encoder.cdcEncode(h.header.height),
            pbt,
            bzbi,
            Encoder.cdcEncode(h.header.last_commit_hash),
            Encoder.cdcEncode(h.header.data_hash),
            Encoder.cdcEncode(h.header.validators_hash),
            Encoder.cdcEncode(h.header.next_validators_hash),
            Encoder.cdcEncode(h.header.consensus_hash),
            Encoder.cdcEncode(h.header.app_hash),
            Encoder.cdcEncode(h.header.last_results_hash),
            Encoder.cdcEncode(h.header.evidence_hash),
            Encoder.cdcEncode(h.header.proposer_address)
        ];

        return MerkleTree.merkleRootHash(all, 0, all.length);
    }

This is what the hash function does. You can see the app_hash is part of the checking mechanism.

}

function ensureValidatorSubmission(uint256 i, bytes memory incomingValidator) internal view returns (uint256 power, address validator) {
validator = LibPower.getActiveValidatorAddressByIndex(uint16(i));
Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, I now see the code in the off-chain relayer. Please add some docs there explaining why this ordering is taking place.

validator = LibPower.getActiveValidatorAddressByIndex(uint16(i));
ValidatorInfo memory info = LibPower.getActiveValidatorInfo(validator);

bytes20 expectedCometbftAccountId = toCometBFTAddress(info.metadata);
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not clear how info.metadata contains the public key. It is documented as data for off-chain retrieval.

bytes memory message = generateSignePayload(header.commit, LibSubnetActorStorage.appStorage().chainID, i);
bytes32 messageHash = sha256(message);

ensureValidSignature(messageHash, commitSig.signature, validator);
Copy link
Contributor

Choose a reason for hiding this comment

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

Signature is checked here with the onchain address validator.
If it's actually passing, then CometBFT is using that key for signing.
If so, why ensureValidatorSubmission needs to check CometBFT address? If it doesn't match, the signature check will fail.
In that case, i'm not sure if/why commitSig.validator_address needs to be submitted.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think removing them should work as well because public key has to match the validator. I will remove this.

commitSig = header.commit.signatures[i];
// no need to verify absent or nil votes.
if (commitSig.block_id_flag != TENDERMINTLIGHT_PROTO_GLOBAL_ENUMS.BlockIDFlag.BLOCK_ID_FLAG_COMMIT) {
continue;
Copy link
Contributor

Choose a reason for hiding this comment

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

Any reason why not to filter these off-chain?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think even if offchain is filtered, contract should check it just ensure block id flag is correct. Sanity check, no need to revert, just ignore.

Copy link
Contributor

Choose a reason for hiding this comment

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

Since it's unsigned data, I don't see why it's need to be submitted. (both as a field, and as items who contain the wrong field value).

And on the same note, I don't think we should follow CometBFT data types when not needed.

// confirming the changes in membership in the child
LibPower.confirmChange(checkpoint.nextConfigurationNumber);
checkpointStorage.stateCommitments[height] = StateCommitment({
blockHeight: height,
Copy link
Contributor

Choose a reason for hiding this comment

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

Why blockHeight is needed in this struct? it's already used as the key.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

updated

assembly {
mstore(add(appHash, 32), derived)
}
return appHash;
Copy link
Contributor

Choose a reason for hiding this comment

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

You're trying to produce bytes instead of bytes32 to comply with having the commitment being bytes (taken from header_app_hash), which I don't think is needed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, we can simplify the payload. Because behind the scene it's using Tendermint library to do the verification, I would prefer to use the library as it is for now. Unless there is strong gas concerns.

SubnetActorCheckpointingStorage storage checkpointStorage = LibCheckpointingStorage.getStorage();

uint256 threshold = (activeCollateral * s.majorityPercentage) / 100;
validateAppHash(checkpointHeight, breakdown);
Copy link
Contributor

Choose a reason for hiding this comment

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

What prevents an arbitrary activity data, unrelated to the commitment breakdown?

Copy link
Contributor Author

@cryptoAtwill cryptoAtwill Oct 6, 2025

Choose a reason for hiding this comment

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

good catch, I missed this. The keccack should be equal. Updated.


uint256 threshold = (activeCollateral * s.majorityPercentage) / 100;
validateAppHash(checkpointHeight, breakdown);
ensureValidHeight(checkpointHeight, checkpointStorage.commitmentHeights.activity);
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems here that this will need to be called for every checkpoint, otherwise will fail, which doesn't follow what you wrote in #1402 (comment).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I mean if there is activity rollup, then we have to enforce it every checkpoint. If there is no activity rollup, then no one needs to call this method.

validateAppHash(checkpointHeight, breakdown);

LibBottomUpBatch.recordBottomUpBatchCommitment(checkpointHeight, breakdown.msgBatchCommitment);
_execBottomUpMsgBatch(checkpointHeight, inclusions);
Copy link
Contributor

Choose a reason for hiding this comment

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

This won’t allow multi-phase execution, i.e., execution without the full set of inclusion proofs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is multi-phase execution possible before this change?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, in the relayer, it's packing all the BottomUpBatch.Inclusion[] and submit. In the contract, it supports submission one by one. To replicate that, we can just add two more methods:

function recordBottomUpMsgBatch(
        uint64 checkpointHeight,
        StateCommitmentBreakDown calldata breakdown
) external whenNotPaused {
        validateAppHash(checkpointHeight, breakdown);
        LibBottomUpBatch.recordBottomUpBatchCommitment(checkpointHeight, breakdown.msgBatchCommitment);
}

function execBottomUpMsgBatch(
        uint64 checkpointHeight,
        BottomUpBatch.Inclusion[] calldata inclusions
) external whenNotPaused {
        _execBottomUpMsgBatch(checkpointHeight, inclusions);
}

cryptoAtwill added 2 commits October 7, 2025 16:36
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.

2 participants