Skip to content

feat: incentivized cross domain message delivery #272

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: l2tol2cdm-gasreceipt
Choose a base branch
from

Conversation

hamdiallam
Copy link
Contributor

Description

Incentivized relays settlement framework leveraging the data provided in #266

@hamdiallam hamdiallam self-assigned this Apr 22, 2025
@hamdiallam hamdiallam changed the base branch from l2tol2cdm-gasreceipt to main April 22, 2025 18:34
@hamdiallam hamdiallam changed the base branch from main to l2tol2cdm-gasreceipt April 22, 2025 18:38
contract L2ToL2CrossDomainGasTank {
uint256 constant MAX_DEPOSIT = 0.01 ether;

mapping(address => uint256) public balanceOf;
Copy link
Contributor

Choose a reason for hiding this comment

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

What are your thoughts on having a second mapping keying balance by root message hash?

mapping(address => uint256) public txOriginBalanceOf;
mapping(bytes32 => uint256) public messageBalanceOf;

This could still enforce a MAX_DEPOSIT to mitigate risk of a large amount of stuck funds for a given account. The deposit flow would looks like this:

 function deposit(bytes32 rootMsgHash) nonReentrant external payable {
        uint256 amount = msg.value;
        require(amount > 0);
        uint256 newTxOriginBalance = txOriginBalanceOf[msg.sender] + amount;
        require(newTxOriginBalance < MAX_DEPOSIT);
        txOriginBalanceOf[msg.sender] = newTxOriginBalance;
        uint256 newRootMessageBalanceOf = messageBalanceOf[rootMsgHash] + amount
        newRootMessageBalanceOf[rootMsgHash] = newRootMessageBalanceOf
    }

Then in claim you would have to deduct cost from both balance mappings:

txOriginBalanceOf[txOrigin] -= cost;
messageBalanceOf[rootMsgHash] -= cost;

The main benefit of messageBalanceOf is that it removes complexity from the relayer around determining whether L2ToL2CrossDomainGasTank contains enough funds for the gas required to relay a message because relayer can check the balance per message hash instead of having to check for other pending messages that might be associated with that tx.origin. Another benefit, is down the line when we support withdrawals, we could add timeouts per message hash on withdrawals, which would guarantee that the balance for the message hash would exist up until a given block timestamp.

However, a potential downside I see of the messageBalanceOf approach is that it would require a new deposit for every new root message hash. And a potential risk of this approach is that someone could assign a balance of MAX_DEPOSIT to a single message hash (or across multiple message hahes), that ends up not being relayable, and then that account ends up "stuck" and cannot fund any additional messages because the account balance cannot exceed MAX_DEPOSIT. However, this risk becomes mitigated if we assume L2ToL2CrossDomainGasTank is not "production-ready" until there is withdrawal support.

wdyt?

Copy link
Contributor Author

@hamdiallam hamdiallam Apr 23, 2025

Choose a reason for hiding this comment

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

yea this is a great point! I was actually thinking of having support for this not in replacement but for 3rdparty integrator that wants a different fee payer thantx.origin.

The reason it can't replace is that this requires native integration with someone using the L2ToL2CrossDomainMessenger. You'd have to wrap every sendMessage call with a call to deposit. Instead of a replacement, this would be an additive API.

One easy usecase. Lets say Layerzero wants to integrate this feature. Someone sends a message from Solana -> OPM that then spawns a bunch of operations. You dont want the LZ relayer from Sol->OPM to be the fee payer. However, the LZ DVN that integrates the L2ToL2CDM can use this api to specifically just push whatever gas was provided from the original tx as the deposit for the given root msg hash. Then everything just works. And if the provided gas wasn't enough, the person can top up the given root msg hash with more funds

Does this make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

100% agree that this first version should actually include this, i'll add it today

Copy link
Contributor

Choose a reason for hiding this comment

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

The reason it can't replace is that this requires native integration with someone using the L2ToL2CrossDomainMessenger. You'd have to wrap every sendMessage call with a call to deposit. Instead of a replacement, this would be an additive API.

when you say additive API, are you saying that the gas tank will support balance tracking and claiming by tx.origin and by msg hash?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

by default claiming by tx.origin unless the rootMsgHash has an explicit balance set.

If the rootMsgHash has a balance set, i think it should not claim by tx.origin. Is there a scenario where you think it should fallback to tx.origin after depleting the funds associated with the msg hash?

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 rootMsgHash has a balance set, i think it should not claim by tx.origin. Is there a scenario where you think it should fallback to tx.origin after depleting the funds associated with the msg hash?

Hmm, if we are going to support balances by tx.origin and by msgHash then we should fallback to tx.origin. Otherwise, a bad actor could potentially try to make it more difficult for a msg to relay by setting a very small balance to any msg hash they want to prevent from being relayed, and then the user would have to intervene and increase the balance on that msg hash in order to get it to relay.

The other option here is to just simplify things and only support msgHash balances. Even though it will always require an extra deposit, can't that be simplified by something like 7702?

}
```

We cap the deposits in order to cut scope in supporting withdrawals. This makes our first interation super simple since as withdrawal support introduces a race between deposited funds and a relayer compensating themselves for message delivery. With a relatively low max deposit, we eliminate the risk of a large amount of stuck funds for a given account whilst the amount being sufficient enough to cover hundreds of sub-cent transactions.
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we planning to launch this to mainnet without withdrawal support? My understanding was that prior to launching to mainnet withdrawal support would be a requirement.

Copy link
Contributor

Choose a reason for hiding this comment

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

yes I think that's right Harry!

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 all the way till mainnet is an open question. This lets us get to a first version and maybe we have time to iterate on a version with withdrawal support for mainnet


// compensate the relayer
balanceOf[txOrigin] -= cost;
new SafeSend{ value: cost }(payable(relayer));
Copy link
Contributor

@tremarkley tremarkley Apr 22, 2025

Choose a reason for hiding this comment

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

should we emit an event after this? might be helpful for tracking which messages have been claimed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yea agreed, an event here is good

Copy link
Contributor

Choose a reason for hiding this comment

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

another thing I thought of, wouldnt someone be able to claim the same gas receipt multiple times with this? do we need a mapping of claimed msgs to prevent this?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

lmfao yes good call 🤣

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 with the claim id

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 there is another scenario we will want to guard against for 3rd party relayers:

  1. user has an empty gas tank and sends a message through l2tol2cdm
  2. a 3rd party relayer relays this message and charges them a fee for this relay through their own fee mechanism
  3. at block n+1 after the relay, the user adds funds to their gas tank
  4. 3rd party relayer claims funds from the user's gas tank using a previous message that they relayed, which results in the user being charged twice for the same message.

should we have a permissioned set of relayers that can claim from the gas tank in order to prevent this?

Copy link
Contributor

Choose a reason for hiding this comment

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

@tremarkley interesting! curious how we could account for this. permissioned set of relayers doesn't feel like the right approach. or at least its not a long-term solution.

require(messenger.sentMessages[rootMsgHash]);

// ensure unclaimed, and mark the claim
bytes32 claimId = keccak256(abi.encode(rootMsgHash, callDepth));
Copy link
Contributor

@tremarkley tremarkley Apr 23, 2025

Choose a reason for hiding this comment

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

isn't it possible to have two messages with the same rootMsgHash and callDepth? For example, if a message goes from A -> B and then from B two more messages are kicked off (B -> C and B -> D), then I believe C and D will both have the same rootMsgHash and callDepth. why not just use msgHash 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.

yep i really should just use the msg hash here. Idk why i'm creating a new unique identifier when one exists

Copy link
Contributor

Choose a reason for hiding this comment

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

What if we use full msgHash + chainId + receipt blockNumber; this guarantees uniqueness even for identical sub-calls (A→B then A→C), right? Just thinking of ways to further protect against double spend.

contract L2ToL2CrossDomainGasTank {
uint256 constant MAX_DEPOSIT = 0.01 ether;

mapping(address => uint256) public balanceOf;
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 rootMsgHash has a balance set, i think it should not claim by tx.origin. Is there a scenario where you think it should fallback to tx.origin after depleting the funds associated with the msg hash?

Hmm, if we are going to support balances by tx.origin and by msgHash then we should fallback to tx.origin. Otherwise, a bad actor could potentially try to make it more difficult for a msg to relay by setting a very small balance to any msg hash they want to prevent from being relayed, and then the user would have to intervene and increase the balance on that msg hash in order to get it to relay.

The other option here is to just simplify things and only support msgHash balances. Even though it will always require an extra deposit, can't that be simplified by something like 7702?

// compute total cost
uint256 claimCost = CLAIM_OVERHEAD * block.basefee;
uint256 cost = relayCost + claimCost;
require(balanceOf[txOrigin] >= cost);
Copy link
Contributor

Choose a reason for hiding this comment

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

even if there is not enough balance to cover should we still let the relayer claim? I could see a scenario where the relayer has already relayed a message and even though the gas tank doesnt have the full balance to cover the cost, the relayer still wants to claim the remaining funds in the gas tank. wdyt?

Copy link
Contributor

Choose a reason for hiding this comment

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

wouldn't the tx not go through if there's not enough balance prior? I guess its possible that the simulated gas price and the actual are different, is that the scenario you had in mind?

Copy link
Contributor

Choose a reason for hiding this comment

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

Gave this some more thought - If the relayer spent gas it should be able to drain whatever funds are present if the cost > what's in the vault I feel. Should we add PartialClaim ability? do a min(cost, balance) and emit a PartialClaim event for accounting maybe? curious what you think! this should really only be an edge case since we estimate that there is enough to fund the relay.


With an incentivation framework, we can ensure permissionless delivery of cross domain messages by any relayer. Very similar to solvers fulfilling cross chain [intents](https://www.erc7683.org/) that are retroactively paid by the settlement system.

This settlement system is built outside external to the core protocol contracts, with this iteration serving as a functional MVP to start from. Any cut scope significantly simplifies implementation and can be re-added as improvements in further versions.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
This settlement system is built outside external to the core protocol contracts, with this iteration serving as a functional MVP to start from. Any cut scope significantly simplifies implementation and can be re-added as improvements in further versions.
This settlement system is built outside of the core protocol contracts, with this iteration serving as a functional MVP to start from. Any cut scope significantly simplifies implementation and can be re-added as improvements in further versions.

<!-- It is fine to remove this section from the final document,
but understanding the purpose of the doc when writing is very helpful. -->

We want to preserve a single transaction experience in the Superchain event event when a transaction spawns asynchronous cross chain invocations.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
We want to preserve a single transaction experience in the Superchain event event when a transaction spawns asynchronous cross chain invocations.
We want to preserve a single transaction experience in the Superchain even when a transaction spawns asynchronous cross chain invocations.

// compute total cost
uint256 claimCost = CLAIM_OVERHEAD * block.basefee;
uint256 cost = relayCost + claimCost;
require(balanceOf[txOrigin] >= cost);
Copy link
Contributor

Choose a reason for hiding this comment

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

wouldn't the tx not go through if there's not enough balance prior? I guess its possible that the simulated gas price and the actual are different, is that the scenario you had in mind?


// compensate the relayer
balanceOf[txOrigin] -= cost;
new SafeSend{ value: cost }(payable(relayer));
Copy link
Contributor

Choose a reason for hiding this comment

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

@tremarkley interesting! curious how we could account for this. permissioned set of relayers doesn't feel like the right approach. or at least its not a long-term solution.

require(messenger.sentMessages[rootMsgHash]);

// ensure unclaimed, and mark the claim
bytes32 claimId = keccak256(abi.encode(rootMsgHash, callDepth));
Copy link
Contributor

Choose a reason for hiding this comment

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

What if we use full msgHash + chainId + receipt blockNumber; this guarantees uniqueness even for identical sub-calls (A→B then A→C), right? Just thinking of ways to further protect against double spend.

// compute total cost
uint256 claimCost = CLAIM_OVERHEAD * block.basefee;
uint256 cost = relayCost + claimCost;
require(balanceOf[txOrigin] >= cost);
Copy link
Contributor

Choose a reason for hiding this comment

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

Gave this some more thought - If the relayer spent gas it should be able to drain whatever funds are present if the cost > what's in the vault I feel. Should we add PartialClaim ability? do a min(cost, balance) and emit a PartialClaim event for accounting maybe? curious what you think! this should really only be an edge case since we estimate that there is enough to fund the relay.


1. The relayer must simulate the call and ensure the emitted tx origin has a deposit that will cover the cost on the originating chain.
2. The relayer should also track the pending claimable `RelayedMessageGasReceipt` of the `rootMsgHash` callstack to maximize the likelihood that the held deposit is sufficient.
3. The relayer should claim the receipt within a reasonable time frame to ensure they are compensated. An unrelated relayer not checking (2) might claim newer transitive message that depletes the funds.
Copy link
Contributor

Choose a reason for hiding this comment

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

should we add blockTimestamp to RelayedMessageGasReceipt and let the gas tank owner set maxReceiptAge?

uint256 newBalance = balanceOf[msg.sender] + amount;
require(newBalance < MAX_DEPOSIT);

balanceOf[msg.sender] = newBalance;
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 it would be good to emit a deposit event

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.

3 participants