Skip to content

EVM Callbacks #150

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

Merged
merged 40 commits into from
Jun 10, 2025
Merged

EVM Callbacks #150

merged 40 commits into from
Jun 10, 2025

Conversation

AdityaSripal
Copy link
Member

@AdityaSripal AdityaSripal commented May 16, 2025

Description

TODO for EVM team:

  • Approve Receiver funds from isolated address to contract onRecvPacket
  • Build successful test case to prove that EVM call works
  • Ensure that state is properly reverted when a packet is genuinely sent t the chain and the callback fails

Closes: #XXXX


Author Checklist

All items are required. Please add a note to the item if the item is not applicable and
please add links to any relevant follow up issues.

I have...

  • tackled an existing issue or discussed with a team member
  • left instructions on how to review the changes
  • targeted the main branch

Reviewers Checklist

All items are required.
Please add a note if the item is not applicable
and please add your handle next to the items reviewed
if you only reviewed selected items.

I have...

  • added a relevant changelog entry to the Unreleased section in CHANGELOG.md
  • confirmed all author checklist items have been addressed
  • confirmed that this PR does not change production code
  • reviewed content
  • tested instructions (if applicable)
  • confirmed all CI checks have passed

@@ -111,7 +111,7 @@ func (s *StateDB) MultiStoreSnapshot() storetypes.CacheMultiStore {
// the cacheCtx multi store is already a CacheMultiStore
// so we need to pass a copy of the current state of it
cms := s.cacheCtx.MultiStore().(storetypes.CacheMultiStore)
snapshot := cms.Copy()
snapshot := cms.CacheMultiStore()
Copy link
Member Author

Choose a reason for hiding this comment

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

Had to change this to fix the SDK upgrade. Not sure the correct way to fix this

Copy link
Contributor

Choose a reason for hiding this comment

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

This should fixed on main already


// can only call callback if the receiver is the module address
receiver := sdk.MustAccAddressFromBech32(data.Receiver)
if !receiver.Equals(k.authKeeper.GetModuleAddress(types.ModuleName)) {
Copy link
Member Author

Choose a reason for hiding this comment

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

Only continue callback logic on recvPacket if the receiver of the tokens is the module account address. This is because we set the msg.sender on the contract callback to the module address

contractAddr := common.HexToAddress(contractAddress)

// TODO: Do something with the response
_, err = k.evmKeeper.CallEVMWithData(cachedCtx, sender, &contractAddr, cbData.Calldata, true)
Copy link
Member Author

Choose a reason for hiding this comment

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

Here the contract is getting the calldata in the packet regardless of what the acknowledgment is. Question: Should we allow packet senders to provide different calldata for successful and unsuccessful acknowledgements

Copy link
Contributor

Choose a reason for hiding this comment

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

I assume people will need different behaviors to either finalize or unwind application logic here, but we should get some more product feedback around exactly what is required since I don't have a clear picture of what that would look like.

Copy link

Choose a reason for hiding this comment

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

What currently sets the calldata used here? Is it the contract that handles the receive packet?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is all being set by the original packet sender at the start of the packet lifecycle

Copy link

Choose a reason for hiding this comment

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

Hmm maybe I'm missing something, but if the source calldata is chosen prior to packet creation, how does a contract determine for which packet this source callback is associated with? For example, our entrypoint contract has a usecase where when handling a timed out or failed transfer, it will send the recovered assets to an address that is stored in state with an association to the packet seq no / channel of the transfer. How would our entrypoint contract determine what packet is failing in the evm callbacks case?

return nil
}

func (k ContractKeeper) IBCOnAcknowledgementPacketCallback(
Copy link
Member Author

Choose a reason for hiding this comment

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

Note: Here we can use the packetsenderAddress safely as msg.sender in AcknowledgePacket and TimeoutPacket

Copy link
Contributor

Choose a reason for hiding this comment

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

To clarify, we can use msg.sender here because we assume there will be no logic around who is sending the ack or timeout?

Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure I understand this question

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think there's a question here. It seems like msg.sender is not a "trustable" field. So we can't actually use this field for anything, which is why it seems prudent to have that field be the hashed value of something.

Copy link
Member Author

Choose a reason for hiding this comment

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

Here we do not want the packet sender to spoof a msg.sender on the receiving side

But on the source side, we can allow the packet sender to approve contract executions on behalf of himself. So there is no need for a hashed value here imo

contractAddr := common.HexToAddress(contractAddress)

// TODO: Do something with the response
_, err = k.evmKeeper.CallEVMWithData(cachedCtx, sender, &contractAddr, cbData.Calldata, true)
Copy link
Member Author

Choose a reason for hiding this comment

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

If we support different success and failure calldata for acknowledgement, we can use the failure calldata here as well.

return nil
}

func (k ContractKeeper) IBCOnAcknowledgementPacketCallback(
Copy link
Contributor

Choose a reason for hiding this comment

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

To clarify, we can use msg.sender here because we assume there will be no logic around who is sending the ack or timeout?

@@ -111,7 +111,7 @@ func (s *StateDB) MultiStoreSnapshot() storetypes.CacheMultiStore {
// the cacheCtx multi store is already a CacheMultiStore
// so we need to pass a copy of the current state of it
cms := s.cacheCtx.MultiStore().(storetypes.CacheMultiStore)
snapshot := cms.Copy()
snapshot := cms.CacheMultiStore()
Copy link
Contributor

Choose a reason for hiding this comment

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

This should fixed on main already

Comment on lines 22 to 24
UnorderedTransactionsEnabled() bool
RemoveExpiredUnorderedNonces(ctx sdk.Context) error
TryAddUnorderedNonce(ctx sdk.Context, sender []byte, timestamp time.Time) error
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we now depend on unordered transactions? I don't see it being used in this PR.

contractAddr := common.HexToAddress(contractAddress)

// TODO: Do something with the response
_, err = k.evmKeeper.CallEVMWithData(cachedCtx, sender, &contractAddr, cbData.Calldata, true)
Copy link
Contributor

Choose a reason for hiding this comment

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

I assume people will need different behaviors to either finalize or unwind application logic here, but we should get some more product feedback around exactly what is required since I don't have a clear picture of what that would look like.

Copy link
Member Author

@AdityaSripal AdityaSripal left a comment

Choose a reason for hiding this comment

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

Self-review to create TODOs and further explanations

Copy link
Member Author

Choose a reason for hiding this comment

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

All of these files get deleted if you call:

make contracts-all

If you want to add them back, you can just call:

make contracts-compile

evmd/app.go Outdated
- IBC Transfer

SendPacket, since it is originating from the application to core IBC:
transferKeeper.SendPacket -> erc20.SendPacket -> channel.SendPacket
transferKeeper.SendPacket -> callbacks.SendPacket -> erc20.SendPacket -> channel.SendPacket
Copy link
Member Author

Choose a reason for hiding this comment

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

The SendPacket flow doesn't happen this way because the ICS4 stack is not wired correctly.

ERC20Keeper must implement ICS4Wrapper

and then we can wire this correctly.

It doesn't have an effect at the moment, but we can make this fix later

*/

// create IBC module from top to bottom of stack
var transferStack porttypes.IBCModule

transferStack = transfer.NewIBCModule(app.TransferKeeper)
maxCallbackGas := uint64(1_000_000)
Copy link
Member Author

Choose a reason for hiding this comment

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

Not used in EVM contractKeeper

// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.8.18;

interface ICallbacks {
Copy link
Member Author

Choose a reason for hiding this comment

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

Here is the ABI interface for source side callbacks. Looking for feedback here

Copy link
Member Author

Choose a reason for hiding this comment

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

This file ended up being unnecessary. It was generated by abigen. We can remove if desired

return nil
}

func (k ContractKeeper) IBCOnAcknowledgementPacketCallback(
Copy link
Member Author

Choose a reason for hiding this comment

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

Here we do not want the packet sender to spoof a msg.sender on the receiving side

But on the source side, we can allow the packet sender to approve contract executions on behalf of himself. So there is no need for a hashed value here imo

Comment on lines 74 to 75
receiver := sdk.MustAccAddressFromBech32(data.Receiver)
isolatedAddr := types.GenerateIsolatedAddress(packet.GetDestChannel(), data.Sender)
Copy link
Member Author

Choose a reason for hiding this comment

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

This should be converted into an ETH address but not sure exactly how this should work.

Copy link
Member

Choose a reason for hiding this comment

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

Added: 5b01591

ethReceiver := common.BytesToAddress(receiver)
contractAddr := common.HexToAddress(contractAddress)

// TODO: Approve the ERC20 tokens in the transfer packet for the contract on behalf of the receiver
Copy link
Member Author

Choose a reason for hiding this comment

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

Wasn't sure how this should happen in cosmos/evm so I left as a TODO now

Copy link
Member

Choose a reason for hiding this comment

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

Added: 5b01591

{
"success",
func() {},
nil, // TODO: This is unexpected. Not sure why this is passing without a valid contract address
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't understand why the EVM didn't throw an error here

Copy link
Member

Choose a reason for hiding this comment

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

If you send a transaction with packed data to an address that has no code, the transaction will still succeed, but the data will be ignored.

Added a check that fails if there's no code at the contract account.

{
"success",
func() {},
nil, // TODO: This is unexpected. Not sure why this is passing without a valid contract address
Copy link
Member Author

Choose a reason for hiding this comment

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

I don't understand why this doesn't throw an error in EVM

Copy link
Member

Choose a reason for hiding this comment

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

If you send a transaction with packed data to an address that has no code, the transaction will still succeed, but the data will be ignored.

Added a check that fails if there's no code at the contract account.

// before calling the callback.

// TODO: Do something with the response
_, err = k.evmKeeper.CallEVMWithData(cachedCtx, ethReceiver, &contractAddr, cbData.Calldata, true)
Copy link
Member Author

Choose a reason for hiding this comment

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

TODO: Check if EVM reversion matches SDK transaction expectations:

  1. If the EVM tx reverts, does the EVM state also revert.
  2. If the SDK transaction fails at a later point after a successful EVM call, does the EVM state revert along with the Cosmos state


type ContractKeeper struct {
authKeeper types.AccountKeeper
evmKeeper types.EVMKeeper
Copy link
Member Author

Choose a reason for hiding this comment

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

TODO: We need to fail if the contract address has no code before calling into EVM since this will fail silently in the EVM side

Copy link
Member

Choose a reason for hiding this comment

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

Added a check that fails if there's no code at the contract account.

vladjdk and others added 13 commits June 3, 2025 14:11
Create ERC20 precompiles for tokens that have more than 1 hop
- checks if erc20 exists (should exist because this callback is after the erc20 callback)
- sets allowance for ibc'd erc20 for the full transfer amount to the contract on behalf of the isolated address
fails out if the contract address provided has no code. this prevents silent successes if the contract address is an EOA
include erc20 keeper
@vladjdk vladjdk marked this pull request as ready for review June 7, 2025 02:21
@@ -111,7 +111,7 @@ func (k Keeper) OnRecvPacket(
// IsNativeFromSourceChain will check if the coin is native from the source chain.
// If the coin denom starts with `factory/` then it is a token factory coin, and we should not convert it
// NOTE: Check https://docs.osmosis.zone/osmosis-core/modules/tokenfactory/ for more information
case !found && strings.HasPrefix(coin.Denom, "ibc/") && ibc.IsBaseDenomFromSourceChain(data.Denom):
case !found && strings.HasPrefix(coin.Denom, "ibc/"):
Copy link
Contributor

Choose a reason for hiding this comment

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

@vladjdk @AdityaSripal
Could you clarify why this condition was removed?
If we're removing it intentionally, we should also update the comments in Case 1 to reflect the change.

Copy link
Member

Choose a reason for hiding this comment

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

Let's update the docs here. I don't think it makes sense to only accept single hop tokens, given the fact that Eureka routes through the Hub and recipient chains may be using Eureka.

Comment on lines -32 to +42
GasTotalSupply = 2_477
GasBalanceOf = 2_851
GasAllowance = 3_246
GasTotalSupply = 2_480
GasBalanceOf = 2_870
GasAllowance = 3_225
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are we using more gas for these now?

Comment on lines -25 to +36
GasTransfer = 3_000_000
GasApprove = 30_956
GasIncreaseAllowance = 34_605
GasDecreaseAllowance = 34_519
// NOTE: These gas values have been derived from tests that have been concluded on a testing branch, which
// is not being merged to the main branch. The reason for this was to not clutter the repository with the
// necessary tests for this use case.
//
// The results can be inspected here:
// https://github.com/evmos/evmos/blob/malte/erc20-gas-tests/precompiles/erc20/plot_gas_values.ipynb

GasTransfer = 9_000
GasTransferFrom = 30_500
GasApprove = 8_100
GasIncreaseAllowance = 8_580
GasDecreaseAllowance = 3_620
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are these values changing so much?

Copy link
Member

Choose a reason for hiding this comment

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

Based on this: evmos/evmos#2142

Malte was saying that he missed this in Evmos/OS when he was cherry picking commits

cachedCtx = evmante.BuildEvmExecutionCtx(cachedCtx).
WithGasMeter(types2.NewInfiniteGasMeterWithLimit(cbData.CommitGasLimit))

receiver := sdk.MustAccAddressFromBech32(data.Receiver)
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is it ok to panic here?

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe because data.Receiver has already been validated somewhere before this in the stack?

Copy link
Member

Choose a reason for hiding this comment

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

Not ok - we should error out instead

return errorsmod.Wrap(types.ErrInvalidCalldata, "timeout callback data should not contain calldata")
}

sender := common.BytesToAddress(sdk.MustAccAddressFromBech32(packetSenderAddress))
Copy link
Contributor

Choose a reason for hiding this comment

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

Also panics here?

Copy link
Member

Choose a reason for hiding this comment

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

Same as above

Comment on lines 199 to 207
var allowance *big.Int
err = erc20.ABI.UnpackIntoInterface(&allowance, "allowance", res.Ret)
if err != nil {
return errorsmod.Wrapf(types.ErrAllowanceFailed, "failed to unpack allowance: %v", err)
}

if allowance.Cmp(big.NewInt(1)) != 0 {
return errorsmod.Wrapf(types.ErrAllowanceFailed, "failed to set allowance")
}
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 this is really confusing to read. It's not clear to me that the allowance value being returned here is actually a boolean for success and not the actual allowance value being set by the approve call.

Are we not able to actually unpack to a bool here instead?

Copy link
Member

Choose a reason for hiding this comment

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

Let's unpack a bool and rename it to success. The naming is an artifact of me initially believing that you got an allowance amount back, and then changing the check after I realized it was a success/fail

Comment on lines 224 to 232
// Check that the contract has received all the tokens.
// NOTE: contracts must implement an IERC20(token).transferFrom(msg.sender, address(this), amount)
// for the total amount, or the callback will fail.
// This check is here to prevent funds from getting stuck in the isolated address,
// since they would become irretrievable.
contractTokenBalance := k.erc20Keeper.BalanceOf(ctx, erc20.ABI, tokenPair.GetERC20Contract(), contractAddr) // here, we can use the original ctx and skip manually adding the gas
if contractTokenBalance.Cmp(amountInt.BigInt()) != 0 {
return errorsmod.Wrapf(erc20types.ErrEVMCall, "contract balance %d does not equal sent amount %d", contractTokenBalance, amountInt.BigInt())
}
Copy link
Contributor

Choose a reason for hiding this comment

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

How does this work if the callback contract already had tokens of this denom before the callback, or if the callback has already forwarded the tokens out to another account? I think you just need to check that the sender account has no tokens in it rather than assuming anything about the state of the callback contract.

Copy link
Member

Choose a reason for hiding this comment

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

Good call - current code would only allow callbacks on a contract one time and would break afterwards

@Eric-Warehime Eric-Warehime merged commit 5cfe96d into main Jun 10, 2025
17 checks passed
@Eric-Warehime Eric-Warehime deleted the aditya/evm-callbacks branch June 10, 2025 15:23
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