Skip to content

Conversation

@batconjurer
Copy link

This draft PR is to give visibility to the POC for bridging avalanche to ethereum

Copy link

@github-advanced-security github-advanced-security bot left a comment

Choose a reason for hiding this comment

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

Semgrep PRO found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

@batconjurer batconjurer marked this pull request as draft November 4, 2025 16:20
import {ITeleporterReceiver} from "./ITeleporterReceiver.sol";
import {ReentrancyGuards} from "@utilities/ReentrancyGuards.sol";
import {IWarpExt} from "./IWarpExt.sol";
import {IWarpMessenger} from "../../lib/subnet-evm/contracts/contracts/interfaces/IWarpMessenger.sol";
Copy link
Contributor

Choose a reason for hiding this comment

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

Just a heads up, in main we've removed the subnet-evm submodule in favor of just copying over the 3 contracts we use from there.

package utils

import (
"bytes"
Copy link
Contributor

Choose a reason for hiding this comment

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

Looks like your IDE went and replaced the tabs with spaces in this files and teleporter_utils. Tbh I didn't even know we used tabs, but definitely don't want the entire file in the diff

Copy link
Author

Choose a reason for hiding this comment

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

Yeah, I noticed that. Sigh. Although it is much better to have spaces than tabs, that is definitely not something we should do in this PR. The IDE is currently set to autoformat on file save and I guess that did it

Copy link
Contributor

Choose a reason for hiding this comment

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

I used to be in the spaces camp but gofmt uses tabs and we should do the same.

Copy link
Author

Choose a reason for hiding this comment

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

Ok, I've adjusted the settings in my IDE and hopefully that fixes the issue.

Comment on lines +95 to +96
uint256 validatorSetID = nextValidatorSetID++;
_validatorSets[validatorSetID] = validatorSet;
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 a reason you chose a monotonic counter here instead of using something like the avalancheBlockChainID or a hash of avalancheNetworkID, avalancheBlockChainID?

Copy link
Author

Choose a reason for hiding this comment

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

I inherited this decision, but in its defense, it makes finding the latest n validator sets very easy. This is nice for updating the current validator set and for only validating sufficiently fresh messages.

Copy link
Author

Choose a reason for hiding this comment

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

Also, these are all the validators for a specific L1, so they can't be keyed by avalancheNetworkID, avalancheBlockChainID, etc as those will be constant

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess the current setup allows registering the validators of any given L1 multiple times, whereas keying on avalancheBlockChainID would require a single set

sourceChainID: message.avalancheSourceBlockchainID,
// N.B. This prevents this function from being used for internal interop calls
// to teleporter
originSenderAddress: address(0),
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we need to use the actual sender address here (eventually). We'll need a way of initializing this contract with the address of teleporter v2 on Avalanche. If we don't check that the message was emitted by teleporter v2, any contract on Avalanche could spoof any other contract on the same source blockchain.

Copy link
Author

Choose a reason for hiding this comment

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

So I'm not sure about this. It may not be clear from this function's location (which can be fixed), but this is called only by EthWarp (which is called directly by teleporter) after checking for a quorum of signatures. So I think we trust any message signed by a quorum of signatures, regardless of origin.

Copy link
Contributor

Choose a reason for hiding this comment

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

So yes, we've checked that the message was signed by a quorum of signatures, but we don't know what contract sent the message. The reason we use Nick's method to deploy Teleporter to the same address on Avalanche is so that on the receiving side, we can know exactly what code emitted the warp message.

As-is, a malicious contract on the same chain could emit a warp message that exactly spoofs any other Teleporter message. Basically the way this contract will (probably) have to work is that we'll create the Avalanche Teleporter V2 contract, deploy via Nick's method, and then hard-code that address here so we can verify which contract emitted the message.

for (uint256 i = 0; i < length; i++) {
target[cursor + i] = source[i];
}
return (target, cursor + length);
Copy link
Contributor

Choose a reason for hiding this comment

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

If the new ending position is deterministic, we don't really need to calculate and return it here

Copy link
Author

Choose a reason for hiding this comment

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

indeed, it was just to keep the function calling this clean.

Comment on lines +321 to +328
// TODO: Do we need to check the avalanche source blockchain ID?
// It's expected to be different in cases where the message is from the primary network.
// if (
// message.unsignedMessage.avalancheSourceBlockchainID
// != validatorSet.avalancheBlockchainID
// ) {
// revert("Invalid avalanche source blockchain ID");
// }
Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, we definitely need to check the source blockchain ID to know which validator set to compare it against (if we want to allow paths other than Eth <-> C-chain)

Copy link
Author

Choose a reason for hiding this comment

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

So there is a subtlety here. This function needs to verify two different types of messages: normal teleporter messages and validator set updates. In the first instance, this check is done at a higher scope by the calling EthWarp contract. For the second, EthWarp is bypassed altogether since it is outside the scope of teleporter. In the e2e test, the initial validator set is actually signed over by the P-chain, which would cause this check to fail. Hence the comment

Copy link
Contributor

Choose a reason for hiding this comment

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

Got it. So for validator set updates, message.unsignedMessage.avalancheSourceBlockchainID would be the p-chain, but validatorSet.avalancheBlockchainID would be the blockchainID of the validator set being updated.

Comment on lines 348 to 351
if (!result) {
revert("Invalid signature");
}
return result;
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 strange to have both reverts and a boolean return value. We can go with one or the other

Copy link
Author

Choose a reason for hiding this comment

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

Hah! I hadn't even noticed that

*/
function registerChain(bytes32 avaBlockchainId, address verifyWarpMessage) external {
require(!isChainRegistered(avaBlockchainId), "This chain is already registered");
require(verifyWarpMessage != address(0), "Provided address 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.

No need for a change yet, but the way we implemented registering contracts in ICTT iirc is that we had a register method on the given contract (so on the verifyWarpMessage contract in this case) so that we could at least be sure that the address supplied corresponded to a contract with the register method and wasn't incorrectly input (though someone could create a malicious dummy contract and point to that).

Comment on lines +33 to +35
function bytesToBoolArray(
bytes memory data
) internal pure returns (bool[] memory) {
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 if its feasible, but it may be more efficient to operate on the bitset directly instead of transforming it into a bool array (can be a future investigation)

Comment on lines +321 to +328
// TODO: Do we need to check the avalanche source blockchain ID?
// It's expected to be different in cases where the message is from the primary network.
// if (
// message.unsignedMessage.avalancheSourceBlockchainID
// != validatorSet.avalancheBlockchainID
// ) {
// revert("Invalid avalanche source blockchain ID");
// }
Copy link
Contributor

Choose a reason for hiding this comment

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

Got it. So for validator set updates, message.unsignedMessage.avalancheSourceBlockchainID would be the p-chain, but validatorSet.avalancheBlockchainID would be the blockchainID of the validator set being updated.

sourceChainID: message.avalancheSourceBlockchainID,
// N.B. This prevents this function from being used for internal interop calls
// to teleporter
originSenderAddress: address(0),
Copy link
Contributor

Choose a reason for hiding this comment

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

So yes, we've checked that the message was signed by a quorum of signatures, but we don't know what contract sent the message. The reason we use Nick's method to deploy Teleporter to the same address on Avalanche is so that on the receiving side, we can know exactly what code emitted the warp message.

As-is, a malicious contract on the same chain could emit a warp message that exactly spoofs any other Teleporter message. Basically the way this contract will (probably) have to work is that we'll create the Avalanche Teleporter V2 contract, deploy via Nick's method, and then hard-code that address here so we can verify which contract emitted the message.

Comment on lines +55 to +60
require(
ByteComparator.compare(unformattedPublicKey, previousPublicKey) > 0,
"BLS public key must be greater than the latest public key"
);
uint64 weight = uint64(bytes8(ByteSlicer.slice(data, offset + 96, 8)));
require(weight > 0, "Validator weight must be greater than 0");
Copy link
Contributor

Choose a reason for hiding this comment

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

For now best to keep these checks, but we can probably assume if the message is signed by a quorum of validators that it is indeed valid.

Comment on lines +126 to +131
return ValidatorSetStatePayload({
avalancheBlockchainID: avalancheBlockchainID,
pChainHeight: pChainHeight,
pChainTimestamp: pChainTimestamp,
validatorSetHash: validatorSetHash
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this a message that the p-chain has to sign?

Copy link
Author

Choose a reason for hiding this comment

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

That's the way it currently is. In theory, the L1 validator set should be sufficient except for maybe the first message.

Comment on lines +3 to +11
1. Creates an Avalanche L1 on the Fuji testnet with 3 local validators, running a custom version of AvalancheGo.
1. Deploys an `AvalancheValidatorSetRegistry` contract on the Sepolia testnet.
1. Registers the Avalanche L1's validator set in the registry via an ICM message from the P-Chain, self-signed by the validator set of the L1.
1. Sends a transaction on the L1 to send an ICM message to Sepolia.
1. Gets an aggregate signature of the ICM message using the ICM signature aggregator.
1. Broadcasts the signed ICM message to Sepolia, and confirms successful inclusion and verification.
1. Adds a new validator to the L1, representing 40% of the new total weight.
1. Updates the validator set reigstered on the Sepolia registry via a validator set update message signed by sufficient weight of the previous validator set.
1. Sends and verifies another ICM message from the L1 to Sepolia.
Copy link
Contributor

Choose a reason for hiding this comment

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

Numbering here is broken

@mdelle1
Copy link

mdelle1 commented Nov 21, 2025

I was wondering if this PR could be broken up into multiple smaller, standalone PRs? The breadth of the PR makes it a bit harder to review

Co-authored-by: Geoff Stuart <[email protected]>
Signed-off-by: Jacob Turner <[email protected]>
@batconjurer
Copy link
Author

I was wondering if this PR could be broken up into multiple smaller, standalone PRs? The breadth of the PR makes it a bit harder to review

Yes, I'm doing that now.

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.

6 participants