Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ dependency-graph.png

# Go
go.work.sum

# codex
.gocache/
4 changes: 4 additions & 0 deletions specs/migration/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ Replace OP Bridge with IBC Bridge infrastructure while maintaining backward comp
User calls MsgMigrateToken → Gets IBC tokens → Manual IBC transfer
```

- Failure handling (applies to both options)

If the outbound IBC transfer fails or times out, the middleware detects the refund packet and converts the returned IBC vouchers back into OP tokens for the user. For the explicit migration path, the user can re-run `MsgMigrateToken` to obtain IBC vouchers again before retrying.

#### Bridge Hook Preservation

```plaintext
Expand Down
13 changes: 12 additions & 1 deletion specs/migration/technical_specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@
#### IBC Middleware

- **Purpose**: Intercepts incoming IBC transfer packets and triggers IBC→L2 conversion
- **Key Function**: `OnRecvPacket` - automatically calls `HandleMigratedTokenDeposit`
- **Key Functions**:
- `OnRecvPacket` - convert IBC voucher to OP token by calling `HandleMigratedTokenDeposit`
- `OnAcknowledgementPacket` / `OnTimeoutPacket` - refund failed transfers back into OP tokens

### Data Structures

Expand Down Expand Up @@ -127,6 +129,15 @@ func (im IBCMiddleware) OnRecvPacket(ctx sdk.Context, packet channeltypes.Packet
}
```

#### Failure Handling (Acknowledgements & Timeouts)

- The middleware inspects failed acknowledgements and timeout callbacks for packets originating from migrated denoms.
- When the underlying transfer module mints/refunds IBC vouchers to the sender (following ibc-go’s `refundPacketToken` flow), the middleware:
1. Detects the balance increase on the sender in the hashed IBC denom.
2. Calls `HandleMigratedTokenDeposit` to burn the refunded voucher and mint OP tokens back to the user.
3. Emits `EventTypeHandleMigratedTokenRefund` capturing the receiver, IBC denom, and OP refund amount.
- This logic ensures L2 users never retain stranded IBC vouchers when a withdrawal attempt fails.

### OPHost IBC Transfer Integration

#### HandleMigratedTokenDeposit (OPHost)
Expand Down
12 changes: 10 additions & 2 deletions x/opchild/middleware/migration/common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,10 @@ func (m MockOPChildKeeper) HandleMigratedTokenDeposit(ctx context.Context, sende
}

type MockTransferApp struct {
ac address.Codec
bankKeeper BankKeeper
ac address.Codec
bankKeeper BankKeeper
onAcknowledgement func(ctx sdk.Context, packet channeltypes.Packet, acknowledgement []byte, relayer sdk.AccAddress) error
onTimeout func(ctx sdk.Context, packet channeltypes.Packet, relayer sdk.AccAddress) error
}

// OnChanOpenInit implements the IBCMiddleware interface
Expand Down Expand Up @@ -169,6 +171,9 @@ func (im MockTransferApp) OnAcknowledgementPacket(
acknowledgement []byte,
relayer sdk.AccAddress,
) error {
if im.onAcknowledgement != nil {
return im.onAcknowledgement(ctx, packet, acknowledgement, relayer)
}
return nil
}

Expand All @@ -178,6 +183,9 @@ func (im MockTransferApp) OnTimeoutPacket(
packet channeltypes.Packet,
relayer sdk.AccAddress,
) error {
if im.onTimeout != nil {
return im.onTimeout(ctx, packet, relayer)
}
return nil
}

Expand Down
1 change: 1 addition & 0 deletions x/opchild/middleware/migration/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package migration

const (
EventTypeHandleMigratedTokenDeposit = "handle_migrated_token_deposit"
EventTypeHandleMigratedTokenRefund = "handle_migrated_token_refund"
AttributeKeyReceiver = "receiver"
AttributeKeyAmount = "amount"
AttributeKeyIbcDenom = "ibc_denom"
Expand Down
207 changes: 173 additions & 34 deletions x/opchild/middleware/migration/ibc_module.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"cosmossdk.io/core/address"
errorsmod "cosmossdk.io/errors"

"github.com/cosmos/cosmos-sdk/codec"
sdk "github.com/cosmos/cosmos-sdk/types"

capabilitytypes "github.com/cosmos/ibc-go/modules/capability/types"
Expand All @@ -28,7 +29,9 @@ var _ porttypes.UpgradableModule = &IBCMiddleware{}
// It acts as a compatibility layer that ensures upgrade functionality is available even when the underlying
// IBC module doesn't support it directly.
type IBCMiddleware struct {
ac address.Codec
ac address.Codec
cdc codec.Codec

// app is the underlying IBC module that handles standard IBC operations
app porttypes.IBCModule
// ics4Wrapper provides packet sending/receiving capabilities for the middleware
Expand All @@ -50,13 +53,15 @@ type IBCMiddleware struct {
// - IBCMiddleware: A configured middleware instance
func NewIBCMiddleware(
ac address.Codec,
cdc codec.Codec,
app porttypes.IBCModule,
ics4Wrapper porttypes.ICS4Wrapper,
bankKeeper BankKeeper,
opChildKeeper OPChildKeeper,
) IBCMiddleware {
return IBCMiddleware{
ac: ac,
cdc: cdc,
app: app,
ics4Wrapper: ics4Wrapper,
bankKeeper: bankKeeper,
Expand Down Expand Up @@ -130,25 +135,6 @@ func (im IBCMiddleware) OnChanCloseConfirm(
return im.app.OnChanCloseConfirm(ctx, portID, channelID)
}

// OnAcknowledgementPacket implements the IBCMiddleware interface
func (im IBCMiddleware) OnAcknowledgementPacket(
ctx sdk.Context,
packet channeltypes.Packet,
acknowledgement []byte,
relayer sdk.AccAddress,
) error {
return im.app.OnAcknowledgementPacket(ctx, packet, acknowledgement, relayer)
}

// OnTimeoutPacket implements the IBCMiddleware interface
func (im IBCMiddleware) OnTimeoutPacket(
ctx sdk.Context,
packet channeltypes.Packet,
relayer sdk.AccAddress,
) error {
return im.app.OnTimeoutPacket(ctx, packet, relayer)
}

// SendPacket implements the ICS4 Wrapper interface
// Rate-limited SendPacket found in RateLimit Keeper
func (im IBCMiddleware) SendPacket(
Expand Down Expand Up @@ -232,23 +218,13 @@ func (im IBCMiddleware) OnRecvPacket(
packet channeltypes.Packet,
relayer sdk.AccAddress,
) ibcexported.Acknowledgement {
// if it is not a transfer packet, do nothing
var data transfertypes.FungibleTokenPacketData
if err := transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil {
return im.app.OnRecvPacket(ctx, packet, relayer)
}

// if the token is originated from the receiving chain, do nothing
if transfertypes.ReceiverChainIsSource(packet.GetSourcePort(), packet.GetSourceChannel(), data.Denom) {
// if it is not a transfer packet or receiver chain is source, then execute inner app
// without any further checks
data, ibcDenom, ok := lookupPacket(packet, true)
if !ok {
return im.app.OnRecvPacket(ctx, packet, relayer)
}

// compute the ibc denom
sourcePrefix := transfertypes.GetDenomPrefix(packet.GetDestPort(), packet.GetDestChannel())
prefixedDenom := sourcePrefix + data.Denom
denomTrace := transfertypes.ParseDenomTrace(prefixedDenom)
ibcDenom := denomTrace.IBCDenom()

// if the token is not registered for migration, do nothing
if hasMigration, err := im.opChildKeeper.HasIBCToL2DenomMap(ctx, ibcDenom); err != nil || !hasMigration {
return im.app.OnRecvPacket(ctx, packet, relayer)
Expand Down Expand Up @@ -295,6 +271,159 @@ func (im IBCMiddleware) OnRecvPacket(
return ack
}

// OnAcknowledgementPacket implements the IBCMiddleware interface
func (im IBCMiddleware) OnAcknowledgementPacket(
ctx sdk.Context,
packet channeltypes.Packet,
acknowledgement []byte,
relayer sdk.AccAddress,
) error {
// if it is not an error ack, just pass through
if !isAckError(im.cdc, acknowledgement) {
return im.app.OnAcknowledgementPacket(ctx, packet, acknowledgement, relayer)
}

// if it is not a transfer packet or sender chain is source, then execute inner app
// without any further checks
data, ibcDenom, ok := lookupPacket(packet, false)
if !ok {
return im.app.OnAcknowledgementPacket(ctx, packet, acknowledgement, relayer)
}

// if the token is not registered for migration, just pass through
if hasMigration, err := im.opChildKeeper.HasIBCToL2DenomMap(ctx, ibcDenom); err != nil || !hasMigration {
return im.app.OnAcknowledgementPacket(ctx, packet, acknowledgement, relayer)
}

// get the sender address
sender, err := im.ac.StringToBytes(data.Sender)
if err != nil {
return err
}

// get the before balance
beforeBalance := im.bankKeeper.GetBalance(ctx, sender, ibcDenom)

// call the underlying IBC module
if err := im.app.OnAcknowledgementPacket(ctx, packet, acknowledgement, relayer); err != nil {
return err
}

// if the balance is not changed, do nothing
afterBalance := im.bankKeeper.GetBalance(ctx, sender, ibcDenom)
if afterBalance.Amount.LTE(beforeBalance.Amount) {
return nil
}

// compute the difference
diff := afterBalance.Amount.Sub(beforeBalance.Amount)

// burn IBC token and mint L2 token
ibcCoin := sdk.NewCoin(ibcDenom, diff)
l2Coin, err := im.opChildKeeper.HandleMigratedTokenDeposit(ctx, sender, ibcCoin, "")
if err != nil {
return err
}

ctx.EventManager().EmitEvent(sdk.NewEvent(
EventTypeHandleMigratedTokenRefund,
sdk.NewAttribute(AttributeKeyReceiver, data.Sender),
sdk.NewAttribute(AttributeKeyIbcDenom, ibcDenom),
sdk.NewAttribute(AttributeKeyAmount, l2Coin.String()),
))

return nil
}

// OnTimeoutPacket implements the IBCMiddleware interface
func (im IBCMiddleware) OnTimeoutPacket(
ctx sdk.Context,
packet channeltypes.Packet,
relayer sdk.AccAddress,
) error {
// if it is not a transfer packet or sender chain is source, then execute inner app
// without any further checks
data, ibcDenom, ok := lookupPacket(packet, false)
if !ok {
return im.app.OnTimeoutPacket(ctx, packet, relayer)
}

// if the token is not registered for migration, just pass through
if hasMigration, err := im.opChildKeeper.HasIBCToL2DenomMap(ctx, ibcDenom); err != nil || !hasMigration {
return im.app.OnTimeoutPacket(ctx, packet, relayer)
}

// get the sender address
sender, err := im.ac.StringToBytes(data.Sender)
if err != nil {
return err
}

// get the before balance
beforeBalance := im.bankKeeper.GetBalance(ctx, sender, ibcDenom)

// call the underlying IBC module
if err := im.app.OnTimeoutPacket(ctx, packet, relayer); err != nil {
return err
}

// if the balance is not changed, do nothing
afterBalance := im.bankKeeper.GetBalance(ctx, sender, ibcDenom)
if afterBalance.Amount.LTE(beforeBalance.Amount) {
return nil
}

// compute the difference
diff := afterBalance.Amount.Sub(beforeBalance.Amount)

// burn IBC token and mint L2 token
ibcCoin := sdk.NewCoin(ibcDenom, diff)
l2Coin, err := im.opChildKeeper.HandleMigratedTokenDeposit(ctx, sender, ibcCoin, "")
if err != nil {
return err
}

ctx.EventManager().EmitEvent(sdk.NewEvent(
EventTypeHandleMigratedTokenRefund,
sdk.NewAttribute(AttributeKeyReceiver, data.Sender),
sdk.NewAttribute(AttributeKeyIbcDenom, ibcDenom),
sdk.NewAttribute(AttributeKeyAmount, l2Coin.String()),
))

return nil
}

// lookupPacket checks if the packet is a fungible token transfer packet and not originated from the
// receiving chain (if receive=true) or sending chain (if receive=false). If so, it computes the IBC denom
// and returns it along with the parsed packet data. Otherwise, it returns ok=false.
func lookupPacket(packet channeltypes.Packet, receive bool) (data transfertypes.FungibleTokenPacketData, ibcDenom string, needCheck bool) {
if err := transfertypes.ModuleCdc.UnmarshalJSON(packet.GetData(), &data); err != nil {
return data, "", false
}

// if the token is originated from the receiving chain, do nothing
if receive && transfertypes.ReceiverChainIsSource(packet.GetSourcePort(), packet.GetSourceChannel(), data.Denom) {
return data, "", false
}

// if the token is originated from the sending chain, do nothing
if !receive && transfertypes.SenderChainIsSource(packet.GetSourcePort(), packet.GetSourceChannel(), data.Denom) {
return data, "", false
}

// compute the prefixed ibc denom
prefixedDenom := data.Denom
if receive {
sourcePrefix := transfertypes.GetDenomPrefix(packet.GetDestPort(), packet.GetDestChannel())
prefixedDenom = sourcePrefix + data.Denom
}

// parse the denom and return IBCDenom()
ibcDenom = transfertypes.ParseDenomTrace(prefixedDenom).IBCDenom()

return data, ibcDenom, true
}

// newEmitErrorAcknowledgement creates a new error acknowledgement after having emitted an event with the
// details of the error.
func newEmitErrorAcknowledgement(err error) channeltypes.Acknowledgement {
Expand All @@ -304,3 +433,13 @@ func newEmitErrorAcknowledgement(err error) channeltypes.Acknowledgement {
},
}
}

// isAckError checks an IBC acknowledgement to see if it's an error.
func isAckError(appCodec codec.Codec, acknowledgement []byte) bool {
var ack channeltypes.Acknowledgement
if err := appCodec.UnmarshalJSON(acknowledgement, &ack); err == nil && !ack.Success() {
return true
}

return false
}
Loading
Loading