Skip to content

Commit

Permalink
Add MsgInitiateERC20Withdrawal
Browse files Browse the repository at this point in the history
Add a message type to initiate ERC-20 withdrawals on L2. The ERC-20
token will be burned on L2 and will encode messages to send to the
L1CrossDomainMessenger and L1StandardBridge to release the deposited
ERC-20 token on L1 after the withdrawal is successfully proven and
finalized.
  • Loading branch information
natebeauregard committed Dec 10, 2024
1 parent ac1ab50 commit 8912829
Show file tree
Hide file tree
Showing 14 changed files with 946 additions and 75 deletions.
17 changes: 12 additions & 5 deletions builder/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,9 @@ func (b *Builder) parseWithdrawalMessages(
}
for _, msg := range cosmosTx.GetBody().GetMessages() {
userWithdrawalMsgType := cdctypes.MsgTypeURL(new(rolluptypes.MsgInitiateWithdrawal))
erc20WithdrawalMsgType := cdctypes.MsgTypeURL(new(rolluptypes.MsgInitiateERC20Withdrawal))
feeWithdrawalMsgType := cdctypes.MsgTypeURL(new(rolluptypes.MsgInitiateFeeWithdrawal))
if msg.TypeUrl == userWithdrawalMsgType || msg.TypeUrl == feeWithdrawalMsgType {
if msg.TypeUrl == userWithdrawalMsgType || msg.TypeUrl == feeWithdrawalMsgType || msg.TypeUrl == erc20WithdrawalMsgType {
// Store the withdrawal message hash in the monomer EVM state db and populate the nonce in the event data.
err := b.storeWithdrawalMsgInEVM(execTxResult, ethState, header)
if err != nil {
Expand Down Expand Up @@ -362,11 +363,17 @@ func parseWithdrawalEventAttributes(withdrawalEvent *abcitypes.Event) (*crossdom
for _, attr := range withdrawalEvent.Attributes {
switch attr.Key {
case rolluptypes.AttributeKeySender:
senderCosmosAddress, err := sdk.AccAddressFromBech32(attr.Value)
if err != nil {
return nil, fmt.Errorf("convert sender to cosmos address: %v", err)
// check whether the sender address should be encoded as an Ethereum address or a Cosmos address
var sender common.Address
if common.IsHexAddress(attr.Value) {
sender = common.HexToAddress(attr.Value)
} else {
senderCosmosAddress, err := sdk.AccAddressFromBech32(attr.Value)
if err != nil {
return nil, fmt.Errorf("convert sender to cosmos address: %v", err)
}
sender = common.BytesToAddress(senderCosmosAddress.Bytes())
}
sender := common.BytesToAddress(senderCosmosAddress.Bytes())
params.Sender = &sender
case rolluptypes.AttributeKeyL1Target:
target := common.HexToAddress(attr.Value)
Expand Down
33 changes: 31 additions & 2 deletions proto/rollup/v1/tx.proto
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ service Msg {
// ApplyUserDeposit defines a method for applying a user deposit tx.
rpc ApplyUserDeposit(MsgApplyUserDeposit) returns (MsgApplyUserDepositResponse);

// InitiateWithdrawal defines a method for initiating a withdrawal from L2 to L1.
// InitiateWithdrawal defines a method for initiating an ETH withdrawal from L2 to L1.
rpc InitiateWithdrawal(MsgInitiateWithdrawal) returns (MsgInitiateWithdrawalResponse);

// InitiateERC20Withdrawal defines a method for initiating an ERC-20 withdrawal from L2 to L1.
rpc InitiateERC20Withdrawal(MsgInitiateERC20Withdrawal) returns (MsgInitiateERC20WithdrawalResponse);

// InitiateFeeWithdrawal defines a method for initiating a withdrawal of fees from L2 to the L1 fee recipient address.
rpc InitiateFeeWithdrawal(MsgInitiateFeeWithdrawal) returns (MsgInitiateFeeWithdrawalResponse);

Expand Down Expand Up @@ -54,7 +57,7 @@ message MsgApplyUserDeposit {
// MsgApplyUserDepositResponse defines the ApplyUserDeposit response type.
message MsgApplyUserDepositResponse {}

// MsgInitiateWithdrawal defines the message for initiating an L2 withdrawal.
// MsgInitiateWithdrawal defines the message for initiating an L2 ETH withdrawal.
message MsgInitiateWithdrawal {
option (cosmos.msg.v1.signer) = "sender";

Expand All @@ -78,6 +81,32 @@ message MsgInitiateWithdrawal {
// MsgInitiateWithdrawalResponse defines the Msg/InitiateWithdrawal response type.
message MsgInitiateWithdrawalResponse {}

// MsgInitiateERC20Withdrawal defines the message for initiating an L2 ERC-20 withdrawal.
message MsgInitiateERC20Withdrawal {
option (cosmos.msg.v1.signer) = "sender";

// The cosmos address of the user who wants to withdraw from L2.
string sender = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"];
// The ethereum address on L1 that the user wants to withdraw to.
string target = 2;
// The address of the ERC-20 token contract on L1.
string token_address = 3;
// The amount of ERC-20 tokens that the user wants to withdraw.
string value = 4 [
(cosmos_proto.scalar) = "cosmos.Int",
(gogoproto.customtype) = "cosmossdk.io/math.Int",
(gogoproto.nullable) = false,
(amino.dont_omitempty) = true
];
// Minimum gas limit for executing the message on L1.
bytes gas_limit = 5;
// Extra data to forward to L1 target.
bytes extra_data = 6;
}

// MsgInitiateERC20WithdrawalResponse defines the Msg/InitiateERC20Withdrawal response type.
message MsgInitiateERC20WithdrawalResponse {}

// MsgInitiateFeeWithdrawal defines the message for initiating an L2 fee withdrawal to the L1 fee recipient address.
message MsgInitiateFeeWithdrawal {
option (cosmos.msg.v1.signer) = "sender";
Expand Down
2 changes: 1 addition & 1 deletion x/rollup/keeper/deposits.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func (k *Keeper) mintERC20(
amount sdkmath.Int,
) (*sdk.Event, error) {
// use the "erc20/{l1erc20addr}" format for the coin denom
coin := sdk.NewCoin("erc20/"+erc20addr[2:], amount)
coin := sdk.NewCoin(getERC20Denom(erc20addr), amount)
if err := k.bankkeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(coin)); err != nil {
return nil, fmt.Errorf("failed to mint ERC-20 deposit coins to the rollup module: %v", err)
}
Expand Down
6 changes: 6 additions & 0 deletions x/rollup/keeper/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,9 @@ func (k *Keeper) SetParams(ctx sdk.Context, params *types.Params) error { //noli
}
return nil
}

// getERC20Denom returns the Monomer L2 coin denom for the given ERC-20 L1 address.
// The "erc20/{l1erc20addr}" format is used for the L2 coin denom.
func getERC20Denom(erc20Addr string) string {
return "erc20/" + erc20Addr[2:]
}
4 changes: 2 additions & 2 deletions x/rollup/keeper/keeper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,12 @@ func (s *KeeperTestSuite) setup() {
s.eventManger = sdkCtx.EventManager()
}

func (s *KeeperTestSuite) mockBurnETH() {
func (s *KeeperTestSuite) mockBurn() {
s.bankKeeper.EXPECT().SendCoinsFromAccountToModule(s.ctx, gomock.Any(), types.ModuleName, gomock.Any()).Return(nil).AnyTimes()
s.bankKeeper.EXPECT().BurnCoins(s.ctx, types.ModuleName, gomock.Any()).Return(nil).AnyTimes()
}

func (s *KeeperTestSuite) mockMintETH() {
func (s *KeeperTestSuite) mockMint() {
s.bankKeeper.EXPECT().MintCoins(s.ctx, types.ModuleName, gomock.Any()).Return(nil).AnyTimes()
s.bankKeeper.EXPECT().SendCoinsFromModuleToAccount(s.ctx, types.ModuleName, gomock.Any(), gomock.Any()).Return(nil).AnyTimes()
}
Expand Down
97 changes: 96 additions & 1 deletion x/rollup/keeper/msg_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import (
"context"
"fmt"
"math/big"
"strings"

"cosmossdk.io/math"
sdk "github.com/cosmos/cosmos-sdk/types"
authtypes "github.com/cosmos/cosmos-sdk/x/auth/types"
opbindings "github.com/ethereum-optimism/optimism/op-bindings/bindings"
"github.com/ethereum-optimism/optimism/op-bindings/predeploys"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/polymerdao/monomer/x/rollup/types"
)
Expand Down Expand Up @@ -47,7 +52,7 @@ func (k *Keeper) InitiateWithdrawal(
msg *types.MsgInitiateWithdrawal,
) (*types.MsgInitiateWithdrawalResponse, error) {
ctx := sdk.UnwrapSDKContext(goCtx)
ctx.Logger().Debug("Withdrawing L2 assets", "sender", msg.Sender, "value", msg.Value)
ctx.Logger().Debug("Withdrawing ETH L2 assets", "sender", msg.Sender, "value", msg.Value)

cosmAddr, err := sdk.AccAddressFromBech32(msg.Sender)
if err != nil {
Expand Down Expand Up @@ -80,6 +85,96 @@ func (k *Keeper) InitiateWithdrawal(
return &types.MsgInitiateWithdrawalResponse{}, nil
}

func (k *Keeper) InitiateERC20Withdrawal(
goCtx context.Context,
msg *types.MsgInitiateERC20Withdrawal,
) (*types.MsgInitiateERC20WithdrawalResponse, error) {
ctx := sdk.UnwrapSDKContext(goCtx)
ctx.Logger().Debug("Withdrawing ERC-20 L2 assets", "sender", msg.Sender, "value", msg.Value, "tokenAddress", msg.TokenAddress)

cosmAddr, err := sdk.AccAddressFromBech32(msg.Sender)
if err != nil {
return nil, types.WrapError(types.ErrInvalidSender, "failed to create cosmos address for sender: %v; error: %v", msg.Sender, err)
}

if err = k.burnERC20(ctx, cosmAddr, msg.TokenAddress, msg.Value); err != nil {
return nil, types.WrapError(
types.ErrInitiateERC20Withdrawal,
"failed to burn ERC-20 for cosmosAddress: %v, tokenAddress: %v; err: %v",
cosmAddr,
msg.TokenAddress,
err,
)
}

params, err := k.GetParams(ctx)
if err != nil {
return nil, types.WrapError(types.ErrInitiateERC20Withdrawal, "failed to get params: %v", err)
}

// Pack the finalizeBridgeERC20 message to forward to the L1StandardBridge contract on Ethereum.
// see https://github.com/ethereum-optimism/optimism/blob/24a8d3e/packages/contracts-bedrock/src/universal/StandardBridge.sol#L267
standardBridgeABI, err := abi.JSON(strings.NewReader(opbindings.StandardBridgeMetaData.ABI))
if err != nil {
return nil, types.WrapError(types.ErrInitiateERC20Withdrawal, "failed to parse StandardBridge ABI: %v", err)
}
finalizeBridgeERC20Bz, err := standardBridgeABI.Pack(
"finalizeBridgeERC20",
common.HexToAddress(msg.TokenAddress), // local token
common.HexToAddress(msg.TokenAddress), // remote token
common.HexToAddress(msg.Sender), // from
common.HexToAddress(msg.Target), // to
msg.Value.BigInt(), // amount
msg.ExtraData, // extra data
)
if err != nil {
return nil, types.WrapError(types.ErrInitiateERC20Withdrawal, "failed to pack finalizeBridgeERC20: %v", err)
}

// Pack the relayMessage to forward to the L1CrossDomainMessenger contract on Ethereum.
// see https://github.com/ethereum-optimism/optimism/blob/24a8d3e/packages/contracts-bedrock/src/universal/CrossDomainMessenger.sol#L207
crossDomainMessengerABI, err := abi.JSON(strings.NewReader(opbindings.CrossDomainMessengerMetaData.ABI))
if err != nil {
return nil, types.WrapError(types.ErrInitiateERC20Withdrawal, "failed to parse CrossDomainMessenger ABI: %v", err)
}
relayMessageBz, err := crossDomainMessengerABI.Pack(
"relayMessage",
big.NewInt(0), // nonce
common.HexToAddress(predeploys.L2StandardBridge), // sender
common.HexToAddress(params.L1StandardBridge), // target
big.NewInt(0), // value
big.NewInt(0), // min gas limit
finalizeBridgeERC20Bz, // message
)
if err != nil {
return nil, types.WrapError(types.ErrInitiateERC20Withdrawal, "failed to pack relayMessage: %v", err)
}

withdrawalValueHex := hexutil.EncodeBig(msg.Value.BigInt())
k.EmitEvents(ctx, sdk.Events{
sdk.NewEvent(
types.EventTypeWithdrawalInitiated,
// To forward the ERC-20 withdrawal to L1, we need to use the L2CrossDomainMessenger address as the msg.sender
sdk.NewAttribute(types.AttributeKeySender, predeploys.L2CrossDomainMessenger),
sdk.NewAttribute(types.AttributeKeyL1Target, params.L1CrossDomainMessenger),
// The ERC-20 withdrawal value is stored in the data field
sdk.NewAttribute(types.AttributeKeyValue, hexutil.EncodeBig(big.NewInt(0))),
sdk.NewAttribute(types.AttributeKeyGasLimit, hexutil.EncodeBig(new(big.Int).SetBytes(msg.GasLimit))),
sdk.NewAttribute(types.AttributeKeyData, hexutil.Encode(relayMessageBz)),
// The nonce attribute will be set by Monomer
),
sdk.NewEvent(
types.EventTypeBurnERC20,
sdk.NewAttribute(types.AttributeKeyL2WithdrawalTx, types.EventTypeWithdrawalInitiated),
sdk.NewAttribute(types.AttributeKeyFromCosmosAddress, msg.Sender),
sdk.NewAttribute(types.AttributeKeyERC20Address, msg.TokenAddress),
sdk.NewAttribute(types.AttributeKeyValue, withdrawalValueHex),
),
})

return &types.MsgInitiateERC20WithdrawalResponse{}, nil
}

func (k *Keeper) InitiateFeeWithdrawal(
goCtx context.Context,
_ *types.MsgInitiateFeeWithdrawal,
Expand Down
80 changes: 76 additions & 4 deletions x/rollup/keeper/msg_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ func (s *KeeperTestSuite) TestApplyUserDeposit() {
if test.setupMocks != nil {
test.setupMocks()
}
s.mockMintETH()
s.mockMint()

resp, err := s.rollupKeeper.ApplyUserDeposit(s.ctx, &types.MsgApplyUserDeposit{
Tx: test.txBytes,
Expand All @@ -131,6 +131,7 @@ func (s *KeeperTestSuite) TestInitiateWithdrawal() {
l1Target := "0x12345abcde"
withdrawalAmount := math.NewInt(1000000)

//nolint:dupl
tests := map[string]struct {
sender string
setupMocks func()
Expand All @@ -146,14 +147,14 @@ func (s *KeeperTestSuite) TestInitiateWithdrawal() {
},
"bank keeper insufficient funds failure": {
setupMocks: func() {
s.bankKeeper.EXPECT().SendCoinsFromAccountToModule(s.ctx, gomock.Any(), types.ModuleName, gomock.Any()).Return(types.ErrBurnETH)
s.bankKeeper.EXPECT().SendCoinsFromAccountToModule(s.ctx, gomock.Any(), types.ModuleName, gomock.Any()).Return(types.ErrBurnETH).AnyTimes()
},
sender: sender,
shouldError: true,
},
"bank keeper burn coins failure": {
setupMocks: func() {
s.bankKeeper.EXPECT().BurnCoins(s.ctx, types.ModuleName, gomock.Any()).Return(sdkerrors.ErrUnknownRequest)
s.bankKeeper.EXPECT().BurnCoins(s.ctx, types.ModuleName, gomock.Any()).Return(sdkerrors.ErrUnknownRequest).AnyTimes()
},
sender: sender,
shouldError: true,
Expand All @@ -165,7 +166,7 @@ func (s *KeeperTestSuite) TestInitiateWithdrawal() {
if test.setupMocks != nil {
test.setupMocks()
}
s.mockBurnETH()
s.mockBurn()

resp, err := s.rollupKeeper.InitiateWithdrawal(s.ctx, &types.MsgInitiateWithdrawal{
Sender: test.sender,
Expand Down Expand Up @@ -194,6 +195,77 @@ func (s *KeeperTestSuite) TestInitiateWithdrawal() {
}
}

func (s *KeeperTestSuite) TestInitiateERC20Withdrawal() {
sender := sdk.AccAddress("addr").String()
l1Target := "0x12345abcde"
erc20TokenAddress := "0x0123456789abcdef"
withdrawalAmount := math.NewInt(1000000)

//nolint:dupl
tests := map[string]struct {
sender string
setupMocks func()
shouldError bool
}{
"successful message": {
sender: sender,
shouldError: false,
},
"invalid sender addr": {
sender: "invalid",
shouldError: true,
},
"bank keeper insufficient funds failure": {
setupMocks: func() {
s.bankKeeper.EXPECT().SendCoinsFromAccountToModule(s.ctx, gomock.Any(), types.ModuleName, gomock.Any()).Return(sdkerrors.ErrUnknownRequest).AnyTimes()
},
sender: sender,
shouldError: true,
},
"bank keeper burn coins failure": {
setupMocks: func() {
s.bankKeeper.EXPECT().BurnCoins(s.ctx, types.ModuleName, gomock.Any()).Return(sdkerrors.ErrUnknownRequest).AnyTimes()
},
sender: sender,
shouldError: true,
},
}

for name, test := range tests {
s.Run(name, func() {
if test.setupMocks != nil {
test.setupMocks()
}
s.mockBurn()

resp, err := s.rollupKeeper.InitiateERC20Withdrawal(s.ctx, &types.MsgInitiateERC20Withdrawal{
Sender: test.sender,
Target: l1Target,
TokenAddress: erc20TokenAddress,
Value: withdrawalAmount,
})

if test.shouldError {
s.Require().Error(err)
s.Require().Nil(resp)
} else {
s.Require().NoError(err)
s.Require().NotNil(resp)

// Verify that the expected event types are emitted
expectedEventTypes := []string{
sdk.EventTypeMessage,
types.EventTypeWithdrawalInitiated,
types.EventTypeBurnERC20,
}
for i, event := range s.eventManger.Events() {
s.Require().Equal(expectedEventTypes[i], event.Type)
}
}
})
}
}

func (s *KeeperTestSuite) TestInitiateFeeWithdrawal() {
tests := map[string]struct {
setupMocks func()
Expand Down
21 changes: 21 additions & 0 deletions x/rollup/keeper/withdrawals.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,24 @@ func (k *Keeper) burnETH(ctx sdk.Context, addr sdk.AccAddress, amount sdkmath.In

return nil
}

// burnERC20 burns a bridged ERC-20 token from an L2 account.
func (k *Keeper) burnERC20(
ctx sdk.Context, //nolint:gocritic // hugeParam
userAddr sdk.AccAddress,
erc20addr string,
amount sdkmath.Int,
) error {
coins := sdk.NewCoins(sdk.NewCoin(getERC20Denom(erc20addr), amount))

// Transfer the coins to withdraw from the user account to the rollup module
if err := k.bankkeeper.SendCoinsFromAccountToModule(ctx, userAddr, types.ModuleName, coins); err != nil {
return fmt.Errorf("failed to send withdrawal coins from user account %v to rollup module: %v", userAddr, err)
}

if err := k.bankkeeper.BurnCoins(ctx, types.ModuleName, coins); err != nil {
return fmt.Errorf("failed to burn ERC-20 coins from the rollup module: %v", err)
}

return nil
}
Loading

0 comments on commit 8912829

Please sign in to comment.