From a27f2812d5b423292ef7ec001bee03be466b54fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Wed, 17 Jun 2026 16:14:51 +0200 Subject: [PATCH 1/7] feat(v11): add elys xrp withdrawal --- app/upgrades.go | 2 ++ app/upgrades/v11/constants.go | 10 +++++++- app/upgrades/v11/keepers.go | 16 +++++++++++++ app/upgrades/v11/upgrades.go | 45 +++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/app/upgrades.go b/app/upgrades.go index 3ace767..34549e2 100644 --- a/app/upgrades.go +++ b/app/upgrades.go @@ -70,6 +70,8 @@ func (app *App) setupUpgradeHandlers() { app.mm, app.configurator, app.ICAHostKeeper, + app.BankKeeper, + app.TransferKeeper, ), ) diff --git a/app/upgrades/v11/constants.go b/app/upgrades/v11/constants.go index bab2749..d3ef3c2 100644 --- a/app/upgrades/v11/constants.go +++ b/app/upgrades/v11/constants.go @@ -1,5 +1,13 @@ package v11 const ( - UpgradeName = "v11.0.0" + UpgradeName = "v11.0.0" + ElysChannelID = "channel-1" + + // ElysEscrowAddress is the Elys channel's escrow account, pinned as a guard. + ElysEscrowAddress = "ethm1kq2rzz6fq2q7fsu75a9g7cpzjeanmk68nttack" + + // WithdrawalAddress receives the recovered elys XRP. + // TODO: Set correct withdrawal address. + WithdrawalAddress = "ethm1p95fctckyrxuxu6t47e2uuckjl9tfuxynuawsc" ) diff --git a/app/upgrades/v11/keepers.go b/app/upgrades/v11/keepers.go index 79ceb23..358820e 100644 --- a/app/upgrades/v11/keepers.go +++ b/app/upgrades/v11/keepers.go @@ -1,6 +1,8 @@ package v11 import ( + "context" + sdk "github.com/cosmos/cosmos-sdk/types" icahosttypes "github.com/cosmos/ibc-go/v10/modules/apps/27-interchain-accounts/host/types" ) @@ -10,3 +12,17 @@ import ( type ICAHostKeeper interface { SetParams(ctx sdk.Context, params icahosttypes.Params) } + +// BankKeeper is the narrow interface required by the v11 upgrade +// handler. It matches a subset of bankkeeper.Keeper. +type BankKeeper interface { + GetBalance(ctx context.Context, addr sdk.AccAddress, denom string) sdk.Coin + SendCoins(ctx context.Context, fromAddr, toAddr sdk.AccAddress, amt sdk.Coins) error +} + +// TransferKeeper is the narrow interface required by the v11 upgrade +// handler. It matches a subset of transferkeeper.Keeper. +type TransferKeeper interface { + GetTotalEscrowForDenom(ctx sdk.Context, denom string) sdk.Coin + SetTotalEscrowForDenom(ctx sdk.Context, coin sdk.Coin) +} diff --git a/app/upgrades/v11/upgrades.go b/app/upgrades/v11/upgrades.go index ab6425b..f18936a 100644 --- a/app/upgrades/v11/upgrades.go +++ b/app/upgrades/v11/upgrades.go @@ -2,17 +2,23 @@ package v11 import ( "context" + "fmt" + "cosmossdk.io/log" upgradetypes "cosmossdk.io/x/upgrade/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" + evmtypes "github.com/cosmos/evm/x/vm/types" icahosttypes "github.com/cosmos/ibc-go/v10/modules/apps/27-interchain-accounts/host/types" + transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" ) func CreateUpgradeHandler( mm *module.Manager, configurator module.Configurator, icaHostKeeper ICAHostKeeper, + bankKeeper BankKeeper, + transferKeeper TransferKeeper, ) upgradetypes.UpgradeHandler { return func(c context.Context, _ upgradetypes.Plan, vm module.VersionMap) (module.VersionMap, error) { ctx := sdk.UnwrapSDKContext(c) @@ -27,7 +33,46 @@ func CreateUpgradeHandler( logger.Info("Disabling ICA host module...") icaHostKeeper.SetParams(ctx, icahosttypes.NewParams(false, nil)) + + logger.Info("Withdrawing Elys escrow to provided address...") + if err := withdrawElysEscrow(ctx, logger, bankKeeper, transferKeeper); err != nil { + return nil, err + } + logger.Info("Finished v11 upgrade handler") return vm, nil } } + +// withdrawElysEscrow moves the XRP stranded in the Elys transfer-channel +// escrow to the recovery address and updates the total-escrow accounting. +func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeeper, transferKeeper TransferKeeper) error { + escrowAddr := transfertypes.GetEscrowAddress(transfertypes.PortID, ElysChannelID) + if escrowAddr.String() != ElysEscrowAddress { + return fmt.Errorf("elys escrow mismatch: derived %s for %s/%s, expected %s", + escrowAddr, transfertypes.PortID, ElysChannelID, ElysEscrowAddress) + } + + destAddr, err := sdk.AccAddressFromBech32(WithdrawalAddress) + if err != nil { + return fmt.Errorf("invalid withdrawal address %q: %w", WithdrawalAddress, err) + } + + // XRP base denom from the canonical EVM coin config. + xrpDenom := evmtypes.GetEVMCoinDenom() + escrowBalance := bankKeeper.GetBalance(ctx, escrowAddr, xrpDenom) + if escrowBalance.IsZero() { + logger.Info("Elys escrow already empty, nothing to withdraw", "escrow", escrowAddr.String()) + return nil + } + + if err := bankKeeper.SendCoins(ctx, escrowAddr, destAddr, sdk.NewCoins(escrowBalance)); err != nil { + return fmt.Errorf("failed to withdraw elys escrow: %w", err) + } + + totalEscrow := transferKeeper.GetTotalEscrowForDenom(ctx, xrpDenom) + transferKeeper.SetTotalEscrowForDenom(ctx, totalEscrow.Sub(escrowBalance)) + + logger.Info("Withdrew stranded Elys XRP", "amount", escrowBalance.String(), "from", escrowAddr.String(), "to", destAddr.String()) + return nil +} From e11fcf58e4b1f94d2c327f66650a284b3cb50344 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Thu, 18 Jun 2026 15:48:21 +0200 Subject: [PATCH 2/7] fix(v11): guard elys escrow withdrawal against total-escrow underflow --- app/upgrades/v11/upgrades.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/upgrades/v11/upgrades.go b/app/upgrades/v11/upgrades.go index f18936a..8846f9c 100644 --- a/app/upgrades/v11/upgrades.go +++ b/app/upgrades/v11/upgrades.go @@ -47,10 +47,10 @@ func CreateUpgradeHandler( // withdrawElysEscrow moves the XRP stranded in the Elys transfer-channel // escrow to the recovery address and updates the total-escrow accounting. func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeeper, transferKeeper TransferKeeper) error { - escrowAddr := transfertypes.GetEscrowAddress(transfertypes.PortID, ElysChannelID) - if escrowAddr.String() != ElysEscrowAddress { + elysEscrowAddr := transfertypes.GetEscrowAddress(transfertypes.PortID, ElysChannelID) + if elysEscrowAddr.String() != ElysEscrowAddress { return fmt.Errorf("elys escrow mismatch: derived %s for %s/%s, expected %s", - escrowAddr, transfertypes.PortID, ElysChannelID, ElysEscrowAddress) + elysEscrowAddr, transfertypes.PortID, ElysChannelID, ElysEscrowAddress) } destAddr, err := sdk.AccAddressFromBech32(WithdrawalAddress) @@ -60,19 +60,23 @@ func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeepe // XRP base denom from the canonical EVM coin config. xrpDenom := evmtypes.GetEVMCoinDenom() - escrowBalance := bankKeeper.GetBalance(ctx, escrowAddr, xrpDenom) - if escrowBalance.IsZero() { - logger.Info("Elys escrow already empty, nothing to withdraw", "escrow", escrowAddr.String()) + elysEscrowBalance := bankKeeper.GetBalance(ctx, elysEscrowAddr, xrpDenom) + if elysEscrowBalance.IsZero() { + logger.Info("Elys escrow already empty, nothing to withdraw", "escrow", elysEscrowAddr.String()) return nil } - if err := bankKeeper.SendCoins(ctx, escrowAddr, destAddr, sdk.NewCoins(escrowBalance)); err != nil { + if err := bankKeeper.SendCoins(ctx, elysEscrowAddr, destAddr, sdk.NewCoins(elysEscrowBalance)); err != nil { return fmt.Errorf("failed to withdraw elys escrow: %w", err) } totalEscrow := transferKeeper.GetTotalEscrowForDenom(ctx, xrpDenom) - transferKeeper.SetTotalEscrowForDenom(ctx, totalEscrow.Sub(escrowBalance)) + if totalEscrow.IsLT(elysEscrowBalance) { + return fmt.Errorf("invalid balances: elys balance %s is above the total escrow amount %s for denom %s", + elysEscrowBalance, totalEscrow, xrpDenom) + } + transferKeeper.SetTotalEscrowForDenom(ctx, totalEscrow.Sub(elysEscrowBalance)) - logger.Info("Withdrew stranded Elys XRP", "amount", escrowBalance.String(), "from", escrowAddr.String(), "to", destAddr.String()) + logger.Info("Withdrew stranded Elys XRP", "amount", elysEscrowBalance.String(), "from", elysEscrowAddr.String(), "to", destAddr.String()) return nil } From d91d002aadc029cd9e9c0f42b27fad40dce87579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Fri, 19 Jun 2026 16:09:59 +0200 Subject: [PATCH 3/7] feat(v11): withdraw full Elys escrow per network --- app/upgrades/v11/constants.go | 38 ++++++++++++--- app/upgrades/v11/keepers.go | 2 +- app/upgrades/v11/upgrades.go | 47 ++++++++++--------- .../msg_server_remove_validator_test.go | 2 +- 4 files changed, 59 insertions(+), 30 deletions(-) diff --git a/app/upgrades/v11/constants.go b/app/upgrades/v11/constants.go index d3ef3c2..a4c155c 100644 --- a/app/upgrades/v11/constants.go +++ b/app/upgrades/v11/constants.go @@ -1,13 +1,37 @@ package v11 const ( - UpgradeName = "v11.0.0" - ElysChannelID = "channel-1" + UpgradeName = "v11.0.0" - // ElysEscrowAddress is the Elys channel's escrow account, pinned as a guard. - ElysEscrowAddress = "ethm1kq2rzz6fq2q7fsu75a9g7cpzjeanmk68nttack" - - // WithdrawalAddress receives the recovered elys XRP. - // TODO: Set correct withdrawal address. + // mainnet + MainnetChainID = "xrplevm_1440000-1" + ElysChannelID = "channel-1" + // TODO: Set mainnet withdrawal address. WithdrawalAddress = "ethm1p95fctckyrxuxu6t47e2uuckjl9tfuxynuawsc" + + // testnet + TestnetChainID = "xrplevm_1449000-1" + TestnetElysChannelID = "channel-3" + // TODO: set testnet withdrawal address + TestnetWithdrawalAddress = "ethm1p95fctckyrxuxu6t47e2uuckjl9tfuxynuawsc" ) + +// ElysRecovery holds, for a single network, the Elys transfer channel whose +// escrow holds the stranded XRP and the address that should receive it. +type ElysRecovery struct { + ChannelID string + WithdrawalAddress string +} + +// ElysRecoveryByNetwork maps each network's Cosmos chain ID to its Elys recovery +// parameters. The v11 handler selects the entry matching ctx.ChainID(). +var ElysRecoveryByNetwork = map[string]ElysRecovery{ + MainnetChainID: { + ChannelID: ElysChannelID, + WithdrawalAddress: WithdrawalAddress, + }, + TestnetChainID: { + ChannelID: TestnetElysChannelID, + WithdrawalAddress: TestnetWithdrawalAddress, + }, +} diff --git a/app/upgrades/v11/keepers.go b/app/upgrades/v11/keepers.go index 358820e..d53da5d 100644 --- a/app/upgrades/v11/keepers.go +++ b/app/upgrades/v11/keepers.go @@ -16,7 +16,7 @@ type ICAHostKeeper interface { // BankKeeper is the narrow interface required by the v11 upgrade // handler. It matches a subset of bankkeeper.Keeper. type BankKeeper interface { - GetBalance(ctx context.Context, addr sdk.AccAddress, denom string) sdk.Coin + GetAllBalances(ctx context.Context, addr sdk.AccAddress) sdk.Coins SendCoins(ctx context.Context, fromAddr, toAddr sdk.AccAddress, amt sdk.Coins) error } diff --git a/app/upgrades/v11/upgrades.go b/app/upgrades/v11/upgrades.go index 8846f9c..8974214 100644 --- a/app/upgrades/v11/upgrades.go +++ b/app/upgrades/v11/upgrades.go @@ -8,7 +8,6 @@ import ( upgradetypes "cosmossdk.io/x/upgrade/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" - evmtypes "github.com/cosmos/evm/x/vm/types" icahosttypes "github.com/cosmos/ibc-go/v10/modules/apps/27-interchain-accounts/host/types" transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" ) @@ -44,39 +43,45 @@ func CreateUpgradeHandler( } } -// withdrawElysEscrow moves the XRP stranded in the Elys transfer-channel -// escrow to the recovery address and updates the total-escrow accounting. +// withdrawElysEscrow moves every coin stranded in the Elys transfer-channel +// escrow to the recovery address and updates the total-escrow accounting. It +// selects the channel and destination for the running network. func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeeper, transferKeeper TransferKeeper) error { - elysEscrowAddr := transfertypes.GetEscrowAddress(transfertypes.PortID, ElysChannelID) - if elysEscrowAddr.String() != ElysEscrowAddress { - return fmt.Errorf("elys escrow mismatch: derived %s for %s/%s, expected %s", - elysEscrowAddr, transfertypes.PortID, ElysChannelID, ElysEscrowAddress) + recovery, ok := ElysRecoveryByNetwork[ctx.ChainID()] + if !ok || recovery.ChannelID == "" { + logger.Info("no Elys escrow recovery configured for this network, skipping", "chainID", ctx.ChainID()) + return nil } - destAddr, err := sdk.AccAddressFromBech32(WithdrawalAddress) + escrowAddr := transfertypes.GetEscrowAddress(transfertypes.PortID, recovery.ChannelID) + + destAddr, err := sdk.AccAddressFromBech32(recovery.WithdrawalAddress) if err != nil { - return fmt.Errorf("invalid withdrawal address %q: %w", WithdrawalAddress, err) + return fmt.Errorf("invalid withdrawal address %q: %w", recovery.WithdrawalAddress, err) } - // XRP base denom from the canonical EVM coin config. - xrpDenom := evmtypes.GetEVMCoinDenom() - elysEscrowBalance := bankKeeper.GetBalance(ctx, elysEscrowAddr, xrpDenom) - if elysEscrowBalance.IsZero() { - logger.Info("Elys escrow already empty, nothing to withdraw", "escrow", elysEscrowAddr.String()) + balances := bankKeeper.GetAllBalances(ctx, escrowAddr) + if balances.Empty() { + logger.Info("Elys escrow already empty, nothing to withdraw", "escrow", escrowAddr.String()) return nil } - if err := bankKeeper.SendCoins(ctx, elysEscrowAddr, destAddr, sdk.NewCoins(elysEscrowBalance)); err != nil { + if err := bankKeeper.SendCoins(ctx, escrowAddr, destAddr, balances); err != nil { return fmt.Errorf("failed to withdraw elys escrow: %w", err) } - totalEscrow := transferKeeper.GetTotalEscrowForDenom(ctx, xrpDenom) - if totalEscrow.IsLT(elysEscrowBalance) { - return fmt.Errorf("invalid balances: elys balance %s is above the total escrow amount %s for denom %s", - elysEscrowBalance, totalEscrow, xrpDenom) + // SendCoins bypasses UnescrowCoin, so decrement the per-denom escrow counter + // ourselves. + for _, coin := range balances { + totalEscrow := transferKeeper.GetTotalEscrowForDenom(ctx, coin.Denom) + // Escrow may hold untracked coins and Sub panics on underflow. + decrement := coin + if totalEscrow.IsLT(coin) { + decrement = totalEscrow + } + transferKeeper.SetTotalEscrowForDenom(ctx, totalEscrow.Sub(decrement)) } - transferKeeper.SetTotalEscrowForDenom(ctx, totalEscrow.Sub(elysEscrowBalance)) - logger.Info("Withdrew stranded Elys XRP", "amount", elysEscrowBalance.String(), "from", elysEscrowAddr.String(), "to", destAddr.String()) + logger.Info("Withdrew stranded Elys escrow", "amount", balances.String(), "from", escrowAddr.String(), "to", destAddr.String()) return nil } diff --git a/x/poa/keeper/msg_server_remove_validator_test.go b/x/poa/keeper/msg_server_remove_validator_test.go index 83a1047..7ed5105 100644 --- a/x/poa/keeper/msg_server_remove_validator_test.go +++ b/x/poa/keeper/msg_server_remove_validator_test.go @@ -10,10 +10,10 @@ import ( sdk "github.com/cosmos/cosmos-sdk/types" govtypes "github.com/cosmos/cosmos-sdk/x/gov/types" stakingtypes "github.com/cosmos/cosmos-sdk/x/staking/types" - "github.com/golang/mock/gomock" "github.com/stretchr/testify/require" "github.com/xrplevm/node/v10/x/poa/testutil" "github.com/xrplevm/node/v10/x/poa/types" + "go.uber.org/mock/gomock" ) func TestMsgServer_RemoveValidator(t *testing.T) { From 7c393a50126571288ec55af37b54c2a076c74b7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Mon, 22 Jun 2026 16:48:29 +0200 Subject: [PATCH 4/7] refactor(v11): withdraw Elys escrow via UnescrowCoin --- app/upgrades/v11/keepers.go | 7 ++--- app/upgrades/v11/upgrades.go | 57 ++++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/app/upgrades/v11/keepers.go b/app/upgrades/v11/keepers.go index d53da5d..7eb6d87 100644 --- a/app/upgrades/v11/keepers.go +++ b/app/upgrades/v11/keepers.go @@ -16,13 +16,12 @@ type ICAHostKeeper interface { // BankKeeper is the narrow interface required by the v11 upgrade // handler. It matches a subset of bankkeeper.Keeper. type BankKeeper interface { - GetAllBalances(ctx context.Context, addr sdk.AccAddress) sdk.Coins - SendCoins(ctx context.Context, fromAddr, toAddr sdk.AccAddress, amt sdk.Coins) error + GetBalance(ctx context.Context, addr sdk.AccAddress, denom string) sdk.Coin } // TransferKeeper is the narrow interface required by the v11 upgrade // handler. It matches a subset of transferkeeper.Keeper. type TransferKeeper interface { - GetTotalEscrowForDenom(ctx sdk.Context, denom string) sdk.Coin - SetTotalEscrowForDenom(ctx sdk.Context, coin sdk.Coin) + IterateTokensInEscrow(ctx sdk.Context, storeprefix []byte, cb func(denomEscrow sdk.Coin) bool) + UnescrowCoin(ctx sdk.Context, escrowAddress, receiver sdk.AccAddress, coin sdk.Coin) error } diff --git a/app/upgrades/v11/upgrades.go b/app/upgrades/v11/upgrades.go index 8974214..dd92b9e 100644 --- a/app/upgrades/v11/upgrades.go +++ b/app/upgrades/v11/upgrades.go @@ -5,6 +5,7 @@ import ( "fmt" "cosmossdk.io/log" + sdkmath "cosmossdk.io/math" upgradetypes "cosmossdk.io/x/upgrade/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" @@ -43,45 +44,49 @@ func CreateUpgradeHandler( } } -// withdrawElysEscrow moves every coin stranded in the Elys transfer-channel -// escrow to the recovery address and updates the total-escrow accounting. It -// selects the channel and destination for the running network. +// withdrawElysEscrow releases the Elys channel escrow to the recovery address +// configured for the running network. func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeeper, transferKeeper TransferKeeper) error { - recovery, ok := ElysRecoveryByNetwork[ctx.ChainID()] - if !ok || recovery.ChannelID == "" { + recoveryCfg, ok := ElysRecoveryByNetwork[ctx.ChainID()] + if !ok || recoveryCfg.ChannelID == "" { logger.Info("no Elys escrow recovery configured for this network, skipping", "chainID", ctx.ChainID()) return nil } - escrowAddr := transfertypes.GetEscrowAddress(transfertypes.PortID, recovery.ChannelID) + escrowAddr := transfertypes.GetEscrowAddress(transfertypes.PortID, recoveryCfg.ChannelID) - destAddr, err := sdk.AccAddressFromBech32(recovery.WithdrawalAddress) + destAddr, err := sdk.AccAddressFromBech32(recoveryCfg.WithdrawalAddress) if err != nil { - return fmt.Errorf("invalid withdrawal address %q: %w", recovery.WithdrawalAddress, err) + return fmt.Errorf("invalid withdrawal address %q: %w", recoveryCfg.WithdrawalAddress, err) } - balances := bankKeeper.GetAllBalances(ctx, escrowAddr) - if balances.Empty() { - logger.Info("Elys escrow already empty, nothing to withdraw", "escrow", escrowAddr.String()) - return nil - } + var released sdk.Coins + var iterErr error + transferKeeper.IterateTokensInEscrow(ctx, []byte(transfertypes.KeyTotalEscrowPrefix), func(totalEscrowed sdk.Coin) bool { + // Cap at the Elys escrow balance so UnescrowCoin's subtraction can't + // underflow the all-channel total. + escrowBalance := bankKeeper.GetBalance(ctx, escrowAddr, totalEscrowed.Denom) + coin := sdk.NewCoin(totalEscrowed.Denom, sdkmath.MinInt(totalEscrowed.Amount, escrowBalance.Amount)) + if !coin.IsPositive() { + return false + } - if err := bankKeeper.SendCoins(ctx, escrowAddr, destAddr, balances); err != nil { - return fmt.Errorf("failed to withdraw elys escrow: %w", err) + if err := transferKeeper.UnescrowCoin(ctx, escrowAddr, destAddr, coin); err != nil { + iterErr = fmt.Errorf("failed to unescrow %s from elys escrow: %w", coin, err) + return true + } + released = released.Add(coin) + return false + }) + if iterErr != nil { + return iterErr } - // SendCoins bypasses UnescrowCoin, so decrement the per-denom escrow counter - // ourselves. - for _, coin := range balances { - totalEscrow := transferKeeper.GetTotalEscrowForDenom(ctx, coin.Denom) - // Escrow may hold untracked coins and Sub panics on underflow. - decrement := coin - if totalEscrow.IsLT(coin) { - decrement = totalEscrow - } - transferKeeper.SetTotalEscrowForDenom(ctx, totalEscrow.Sub(decrement)) + if released.Empty() { + logger.Info("Elys escrow already empty, nothing to withdraw", "escrow", escrowAddr.String()) + return nil } - logger.Info("Withdrew stranded Elys escrow", "amount", balances.String(), "from", escrowAddr.String(), "to", destAddr.String()) + logger.Info("Withdrew stranded Elys escrow", "amount", released.String(), "from", escrowAddr.String(), "to", destAddr.String()) return nil } From dd1ad24797e6774390a401bf24435c4cefa42310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Tue, 23 Jun 2026 07:53:39 +0200 Subject: [PATCH 5/7] test(poa): align AddValidator should_pass mock with delegator-form unbonding lookup PR #126 (fix/poa-unbonding-delegation-check) switched the AddValidator eligibility check to k.sk.GetUnbondingDelegations(ctx, accAddress, ...) and updated the interface, mock and keeper_test.go, but missed this msg_server test, which still mocked GetUnbondingDelegationsFromValidator. The stale expectation makes the real call unexpected, failing make test-poa and make coverage-unit on the PR-merge build. Co-Authored-By: Claude Opus 4.8 (1M context) --- x/poa/keeper/msg_server_add_validator_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x/poa/keeper/msg_server_add_validator_test.go b/x/poa/keeper/msg_server_add_validator_test.go index c9e9ad3..eaf782d 100644 --- a/x/poa/keeper/msg_server_add_validator_test.go +++ b/x/poa/keeper/msg_server_add_validator_test.go @@ -56,7 +56,7 @@ func TestMsgServer_AddValidator(t *testing.T) { stakingKeeper.EXPECT().GetAllValidators(ctx).Return([]stakingtypes.Validator{}, nil) stakingKeeper.EXPECT().GetValidator(ctx, gomock.Any()).Return(stakingtypes.Validator{Tokens: math.NewInt(0)}, nil) stakingKeeper.EXPECT().GetAllDelegatorDelegations(ctx, gomock.Any()).Return([]stakingtypes.Delegation{}, nil) - stakingKeeper.EXPECT().GetUnbondingDelegationsFromValidator(ctx, gomock.Any()).Return([]stakingtypes.UnbondingDelegation{}, nil) + stakingKeeper.EXPECT().GetUnbondingDelegations(ctx, gomock.Any(), gomock.Any()).Return([]stakingtypes.UnbondingDelegation{}, nil) }, bankMocks: func(ctx sdk.Context, bankKeeper *testutil.MockBankKeeper) { bankKeeper.EXPECT().GetBalance(ctx, gomock.Any(), gomock.Any()).Return(sdk.Coin{ From 928a00bc9acffc1c524283840fdccd737f616d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Tue, 23 Jun 2026 11:43:17 +0200 Subject: [PATCH 6/7] feat(v11): added correct unescrow values for devnet, testnet, and mainnet --- app/upgrades/v11/constants.go | 36 ++++++++++++++++++++++++------- app/upgrades/v11/keepers.go | 1 - app/upgrades/v11/upgrades.go | 40 +++++++++++------------------------ 3 files changed, 40 insertions(+), 37 deletions(-) diff --git a/app/upgrades/v11/constants.go b/app/upgrades/v11/constants.go index a4c155c..7de7e35 100644 --- a/app/upgrades/v11/constants.go +++ b/app/upgrades/v11/constants.go @@ -1,26 +1,39 @@ package v11 +import sdkmath "cosmossdk.io/math" + const ( UpgradeName = "v11.0.0" // mainnet - MainnetChainID = "xrplevm_1440000-1" - ElysChannelID = "channel-1" - // TODO: Set mainnet withdrawal address. - WithdrawalAddress = "ethm1p95fctckyrxuxu6t47e2uuckjl9tfuxynuawsc" + MainnetChainID = "xrplevm_1440000-1" + ElysChannelID = "channel-1" + WithdrawalAddress = "ethm1m2pp8zjwk3ystxyxvw5h3mrhhhnzcr2ltjntz9" // testnet - TestnetChainID = "xrplevm_1449000-1" - TestnetElysChannelID = "channel-3" - // TODO: set testnet withdrawal address + TestnetChainID = "xrplevm_1449000-1" + TestnetElysChannelID = "channel-17" TestnetWithdrawalAddress = "ethm1p95fctckyrxuxu6t47e2uuckjl9tfuxynuawsc" + + // devnet + DevnetChainID = "xrplevm_1449900-1" + DevnetElysChannelID = "channel-4" + DevnetWithdrawalAddress = "ethm1p95fctckyrxuxu6t47e2uuckjl9tfuxynuawsc" ) +// twoXRP expresses 2 XRP in axrp base units (axrp is atto-XRP — 18 decimals). +var twoXRP = sdkmath.NewIntWithDecimal(2, 18) + +// mainnetElysAmount is the exact XRP stranded in the mainnet Elys channel escrow. +var mainnetElysAmount, _ = sdkmath.NewIntFromString("6955539034646993768414") + // ElysRecovery holds, for a single network, the Elys transfer channel whose -// escrow holds the stranded XRP and the address that should receive it. +// escrow holds the stranded XRP, the address that should receive it, and the +// amount of XRP (in axrp base units) to unescrow. type ElysRecovery struct { ChannelID string WithdrawalAddress string + Amount sdkmath.Int } // ElysRecoveryByNetwork maps each network's Cosmos chain ID to its Elys recovery @@ -29,9 +42,16 @@ var ElysRecoveryByNetwork = map[string]ElysRecovery{ MainnetChainID: { ChannelID: ElysChannelID, WithdrawalAddress: WithdrawalAddress, + Amount: mainnetElysAmount, }, TestnetChainID: { ChannelID: TestnetElysChannelID, WithdrawalAddress: TestnetWithdrawalAddress, + Amount: twoXRP, + }, + DevnetChainID: { + ChannelID: DevnetElysChannelID, + WithdrawalAddress: DevnetWithdrawalAddress, + Amount: twoXRP, }, } diff --git a/app/upgrades/v11/keepers.go b/app/upgrades/v11/keepers.go index 19de23e..b757855 100644 --- a/app/upgrades/v11/keepers.go +++ b/app/upgrades/v11/keepers.go @@ -30,6 +30,5 @@ type BankKeeper interface { // TransferKeeper is the narrow interface required by the v11 upgrade // handler. It matches a subset of transferkeeper.Keeper. type TransferKeeper interface { - IterateTokensInEscrow(ctx sdk.Context, storeprefix []byte, cb func(denomEscrow sdk.Coin) bool) UnescrowCoin(ctx sdk.Context, escrowAddress, receiver sdk.AccAddress, coin sdk.Coin) error } diff --git a/app/upgrades/v11/upgrades.go b/app/upgrades/v11/upgrades.go index c3c8626..15e37c1 100644 --- a/app/upgrades/v11/upgrades.go +++ b/app/upgrades/v11/upgrades.go @@ -6,10 +6,10 @@ import ( "time" "cosmossdk.io/log" - sdkmath "cosmossdk.io/math" upgradetypes "cosmossdk.io/x/upgrade/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" + evmtypes "github.com/cosmos/evm/x/vm/types" icahosttypes "github.com/cosmos/ibc-go/v10/modules/apps/27-interchain-accounts/host/types" transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" ) @@ -57,8 +57,8 @@ func CreateUpgradeHandler( } } -// withdrawElysEscrow releases the Elys channel escrow to the recovery address -// configured for the running network. +// withdrawElysEscrow releases the configured amount of XRP from the Elys channel +// escrow to the recovery address configured for the running network. func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeeper, transferKeeper TransferKeeper) error { recoveryCfg, ok := ElysRecoveryByNetwork[ctx.ChainID()] if !ok || recoveryCfg.ChannelID == "" { @@ -66,6 +66,11 @@ func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeepe return nil } + if !recoveryCfg.Amount.IsPositive() { + logger.Info("Elys escrow recovery amount is zero, nothing to withdraw", "chainID", ctx.ChainID()) + return nil + } + escrowAddr := transfertypes.GetEscrowAddress(transfertypes.PortID, recoveryCfg.ChannelID) destAddr, err := sdk.AccAddressFromBech32(recoveryCfg.WithdrawalAddress) @@ -73,33 +78,12 @@ func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeepe return fmt.Errorf("invalid withdrawal address %q: %w", recoveryCfg.WithdrawalAddress, err) } - var released sdk.Coins - var iterErr error - transferKeeper.IterateTokensInEscrow(ctx, []byte(transfertypes.KeyTotalEscrowPrefix), func(totalEscrowed sdk.Coin) bool { - // Cap at the Elys escrow balance so UnescrowCoin's subtraction can't - // underflow the all-channel total. - escrowBalance := bankKeeper.GetBalance(ctx, escrowAddr, totalEscrowed.Denom) - coin := sdk.NewCoin(totalEscrowed.Denom, sdkmath.MinInt(totalEscrowed.Amount, escrowBalance.Amount)) - if !coin.IsPositive() { - return false - } - - if err := transferKeeper.UnescrowCoin(ctx, escrowAddr, destAddr, coin); err != nil { - iterErr = fmt.Errorf("failed to unescrow %s from elys escrow: %w", coin, err) - return true - } - released = released.Add(coin) - return false - }) - if iterErr != nil { - return iterErr - } + coin := sdk.NewCoin(evmtypes.GetEVMCoinDenom(), recoveryCfg.Amount) - if released.Empty() { - logger.Info("Elys escrow already empty, nothing to withdraw", "escrow", escrowAddr.String()) - return nil + if err := transferKeeper.UnescrowCoin(ctx, escrowAddr, destAddr, coin); err != nil { + return fmt.Errorf("failed to unescrow %s from elys escrow: %w", coin, err) } - logger.Info("Withdrew stranded Elys escrow", "amount", released.String(), "from", escrowAddr.String(), "to", destAddr.String()) + logger.Info("Withdrew stranded Elys escrow", "amount", coin.String(), "from", escrowAddr.String(), "to", destAddr.String()) return nil } From 0eb371d6c0d08571fe50f296fdb264cd7065a281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Luque?= Date: Tue, 23 Jun 2026 12:01:37 +0200 Subject: [PATCH 7/7] refactor(v11): define coin by chain instead of amount --- app/upgrades/v11/constants.go | 28 +++++++++++++++------------- app/upgrades/v11/upgrades.go | 11 ++++------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/upgrades/v11/constants.go b/app/upgrades/v11/constants.go index 7de7e35..8253f6e 100644 --- a/app/upgrades/v11/constants.go +++ b/app/upgrades/v11/constants.go @@ -1,9 +1,13 @@ package v11 -import sdkmath "cosmossdk.io/math" +import ( + sdkmath "cosmossdk.io/math" + sdk "github.com/cosmos/cosmos-sdk/types" +) const ( - UpgradeName = "v11.0.0" + UpgradeName = "v11.0.0" + EVMCoinDenom = "axrp" // mainnet MainnetChainID = "xrplevm_1440000-1" @@ -13,27 +17,25 @@ const ( // testnet TestnetChainID = "xrplevm_1449000-1" TestnetElysChannelID = "channel-17" - TestnetWithdrawalAddress = "ethm1p95fctckyrxuxu6t47e2uuckjl9tfuxynuawsc" + TestnetWithdrawalAddress = "ethm16gt28px9q0fp48eatecp7j032lm5vaxs2t29pa" // devnet DevnetChainID = "xrplevm_1449900-1" DevnetElysChannelID = "channel-4" - DevnetWithdrawalAddress = "ethm1p95fctckyrxuxu6t47e2uuckjl9tfuxynuawsc" + DevnetWithdrawalAddress = "ethm16gt28px9q0fp48eatecp7j032lm5vaxs2t29pa" ) -// twoXRP expresses 2 XRP in axrp base units (axrp is atto-XRP — 18 decimals). -var twoXRP = sdkmath.NewIntWithDecimal(2, 18) - -// mainnetElysAmount is the exact XRP stranded in the mainnet Elys channel escrow. +var devnetAmount, _ = sdkmath.NewIntFromString("2000000000000000000") +var testnetAmount, _ = sdkmath.NewIntFromString("2000000000000000000") var mainnetElysAmount, _ = sdkmath.NewIntFromString("6955539034646993768414") // ElysRecovery holds, for a single network, the Elys transfer channel whose // escrow holds the stranded XRP, the address that should receive it, and the -// amount of XRP (in axrp base units) to unescrow. +// coin (denom + amount of XRP in axrp base units) to unescrow. type ElysRecovery struct { ChannelID string WithdrawalAddress string - Amount sdkmath.Int + Coin sdk.Coin } // ElysRecoveryByNetwork maps each network's Cosmos chain ID to its Elys recovery @@ -42,16 +44,16 @@ var ElysRecoveryByNetwork = map[string]ElysRecovery{ MainnetChainID: { ChannelID: ElysChannelID, WithdrawalAddress: WithdrawalAddress, - Amount: mainnetElysAmount, + Coin: sdk.NewCoin(EVMCoinDenom, mainnetElysAmount), }, TestnetChainID: { ChannelID: TestnetElysChannelID, WithdrawalAddress: TestnetWithdrawalAddress, - Amount: twoXRP, + Coin: sdk.NewCoin(EVMCoinDenom, testnetAmount), }, DevnetChainID: { ChannelID: DevnetElysChannelID, WithdrawalAddress: DevnetWithdrawalAddress, - Amount: twoXRP, + Coin: sdk.NewCoin(EVMCoinDenom, devnetAmount), }, } diff --git a/app/upgrades/v11/upgrades.go b/app/upgrades/v11/upgrades.go index 15e37c1..3756873 100644 --- a/app/upgrades/v11/upgrades.go +++ b/app/upgrades/v11/upgrades.go @@ -9,7 +9,6 @@ import ( upgradetypes "cosmossdk.io/x/upgrade/types" sdk "github.com/cosmos/cosmos-sdk/types" "github.com/cosmos/cosmos-sdk/types/module" - evmtypes "github.com/cosmos/evm/x/vm/types" icahosttypes "github.com/cosmos/ibc-go/v10/modules/apps/27-interchain-accounts/host/types" transfertypes "github.com/cosmos/ibc-go/v10/modules/apps/transfer/types" ) @@ -66,7 +65,7 @@ func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeepe return nil } - if !recoveryCfg.Amount.IsPositive() { + if !recoveryCfg.Coin.IsPositive() { logger.Info("Elys escrow recovery amount is zero, nothing to withdraw", "chainID", ctx.ChainID()) return nil } @@ -78,12 +77,10 @@ func withdrawElysEscrow(ctx sdk.Context, logger log.Logger, bankKeeper BankKeepe return fmt.Errorf("invalid withdrawal address %q: %w", recoveryCfg.WithdrawalAddress, err) } - coin := sdk.NewCoin(evmtypes.GetEVMCoinDenom(), recoveryCfg.Amount) - - if err := transferKeeper.UnescrowCoin(ctx, escrowAddr, destAddr, coin); err != nil { - return fmt.Errorf("failed to unescrow %s from elys escrow: %w", coin, err) + if err := transferKeeper.UnescrowCoin(ctx, escrowAddr, destAddr, recoveryCfg.Coin); err != nil { + return fmt.Errorf("failed to unescrow %s from elys escrow: %w", recoveryCfg.Coin, err) } - logger.Info("Withdrew stranded Elys escrow", "amount", coin.String(), "from", escrowAddr.String(), "to", destAddr.String()) + logger.Info("Withdrew stranded Elys escrow", "amount", recoveryCfg.Coin.String(), "from", escrowAddr.String(), "to", destAddr.String()) return nil }