From ee32b9d96c905857717635392b0e4568a7d7ba49 Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Wed, 26 Aug 2026 07:00:10 +0530 Subject: [PATCH 1/2] fix(uexecutor): abort unsignable inbound reverts instead of queueing them buildRevertOutbound failed open: when the gas metadata lookup failed it returned a PENDING outbound with empty gas fields, which attachOutboundsToUtx indexed into PendingOutbounds unconditionally. UVs refuse to sign it, so the row sat there forever, and non-CEA rescue was gated on a REVERTED inbound-revert so the user had no way out either. - buildRevertOutbound returns (outbound, error) - on gas-metadata failure the revert is marked ABORTED with an AbortReason - attachOutboundsToUtx indexes only PENDING outbounds, and emits outbound_aborted for the rest - the non-CEA rescue gate accepts REVERTED or ABORTED --- x/uexecutor/keeper/admin_revert.go | 16 +++- x/uexecutor/keeper/build_revert_outbound.go | 73 ++++++++++++++++--- x/uexecutor/keeper/create_outbound.go | 61 ++++++++++++---- x/uexecutor/keeper/execute_inbound_funds.go | 13 +++- .../execute_inbound_funds_and_payload.go | 13 +++- x/uexecutor/keeper/execute_inbound_gas.go | 13 +++- .../keeper/execute_inbound_gas_and_payload.go | 13 +++- .../handle_failed_inbound_validation.go | 13 +++- 8 files changed, 183 insertions(+), 32 deletions(-) diff --git a/x/uexecutor/keeper/admin_revert.go b/x/uexecutor/keeper/admin_revert.go index d7a60694..d986cb52 100644 --- a/x/uexecutor/keeper/admin_revert.go +++ b/x/uexecutor/keeper/admin_revert.go @@ -70,9 +70,20 @@ func (k Keeper) RevertStuckInbound(ctx context.Context, inbound types.Inbound) ( return "", "", fmt.Errorf("failed to create utx for revert: %w", cErr) } - revertOutbound := k.buildRevertOutbound(sdkCtx, &inbound) + revertOutbound, buildErr := k.buildRevertOutbound(sdkCtx, &inbound) if revertOutbound == nil { - return "", "", fmt.Errorf("failed to build revert outbound for inbound %s", universalTxKey) + return "", "", fmt.Errorf("failed to build revert outbound for inbound %s: %w", universalTxKey, buildErr) + } + if buildErr != nil { + // Gas metadata was unresolvable, so the revert is recorded ABORTED instead of + // entering the signing queue. It is still attached: the attempt stays auditable + // and it makes the UTX eligible for RESCUE_FUNDS, which is the remaining route + // back to the user. + k.Logger().Error("admin revert: revert outbound recorded without gas metadata", + "utx_id", universalTxKey, + "outbound_id", revertOutbound.Id, + "error", buildErr.Error(), + ) } if attachErr := k.attachOutboundsToUtx(sdkCtx, universalTxKey, []*types.OutboundTx{revertOutbound}, "admin revert: stuck ballot expired"); attachErr != nil { @@ -82,6 +93,7 @@ func (k Keeper) RevertStuckInbound(ctx context.Context, inbound types.Inbound) ( k.Logger().Info("admin revert: inbound revert outbound created", "utx_id", universalTxKey, "outbound_id", revertOutbound.Id, + "status", revertOutbound.OutboundStatus.String(), "source_chain", inbound.SourceChain, "recipient", revertOutbound.Recipient, "amount", revertOutbound.Amount, diff --git a/x/uexecutor/keeper/build_revert_outbound.go b/x/uexecutor/keeper/build_revert_outbound.go index 967adeb3..b9dc44de 100644 --- a/x/uexecutor/keeper/build_revert_outbound.go +++ b/x/uexecutor/keeper/build_revert_outbound.go @@ -1,13 +1,37 @@ package keeper import ( + "fmt" + sdk "github.com/cosmos/cosmos-sdk/types" "github.com/pushchain/push-chain-node/x/uexecutor/types" ) -// buildRevertOutbound creates an INBOUND_REVERT outbound with gas fields populated -// from the UniversalCore contract via getOutboundTxGasAndFees. -func (k Keeper) buildRevertOutbound(sdkCtx sdk.Context, inbound *types.Inbound) *types.OutboundTx { +// buildRevertOutbound creates an INBOUND_REVERT outbound that returns a failed +// inbound's funds on the source chain. +// +// The gas fields (gas token / fee / price / limit) are resolved from the +// UniversalCore contract and are mandatory: the universal validators refuse to +// sign an outbound whose gas price is zero or missing, so a revert built without +// them can never be broadcast, and re-resolving the metadata later does not +// rewrite the fields already stored on the outbound. +// +// Failure to resolve them is therefore never silent. The outbound is returned +// marked Status_ABORTED with an AbortReason instead of Status_PENDING, together +// with a non-nil error describing what failed: +// +// - it is still worth recording. The attempt stays in the audit trail and it +// makes the universal tx eligible for RESCUE_FUNDS, which is the recovery +// route for funds that never made it back to the user. +// - it must never be queued for signing. attachOutboundsToUtx enforces that by +// indexing only PENDING outbounds into PendingOutbounds. +// +// A nil outbound together with a non-nil error means nothing could be built at all. +func (k Keeper) buildRevertOutbound(sdkCtx sdk.Context, inbound *types.Inbound) (*types.OutboundTx, error) { + if inbound == nil { + return nil, fmt.Errorf("cannot build revert outbound: inbound is nil") + } + recipient := inbound.Sender if inbound.RevertInstructions != nil && inbound.RevertInstructions.FundRecipient != "" { recipient = inbound.RevertInstructions.FundRecipient @@ -27,24 +51,40 @@ func (k Keeper) buildRevertOutbound(sdkCtx sdk.Context, inbound *types.Inbound) // Look up the PRC20 address for this external token tokenCfg, err := k.uregistryKeeper.GetTokenConfig(sdkCtx, inbound.SourceChain, inbound.AssetAddr) if err != nil || tokenCfg.NativeRepresentation == nil || tokenCfg.NativeRepresentation.ContractAddress == "" { - k.Logger().Warn("failed to get PRC20 for revert outbound gas lookup, proceeding without gas fields", + lookupErr := err + if lookupErr == nil { + lookupErr = fmt.Errorf("token config has no native representation") + } + abortErr := fmt.Errorf("failed to resolve PRC20 for revert outbound of %s on %s: %w", + inbound.AssetAddr, inbound.SourceChain, lookupErr) + + k.Logger().Error("revert outbound aborted: PRC20 lookup failed", "chain", inbound.SourceChain, "asset", inbound.AssetAddr, - "error", err, + "outbound_id", outbound.Id, + "error", abortErr.Error(), ) - return outbound + + abortRevertOutbound(outbound, abortErr) + return outbound, abortErr } // Fetch gas fields from UniversalCore.getOutboundTxGasAndFees(prc20, 0) // 0 means use the contract's baseLimit for this chain gasToken, gasFee, gasPrice, gasLimit, err := k.GetGasFeeInfoForRevertOutbound(sdkCtx, tokenCfg.NativeRepresentation.ContractAddress) if err != nil { - k.Logger().Warn("failed to fetch gas fee info for revert outbound, proceeding without gas fields", + abortErr := fmt.Errorf("failed to fetch gas fee info for revert outbound of PRC20 %s on %s: %w", + tokenCfg.NativeRepresentation.ContractAddress, inbound.SourceChain, err) + + k.Logger().Error("revert outbound aborted: gas fee lookup failed", "chain", inbound.SourceChain, "prc20", tokenCfg.NativeRepresentation.ContractAddress, - "error", err, + "outbound_id", outbound.Id, + "error", abortErr.Error(), ) - return outbound + + abortRevertOutbound(outbound, abortErr) + return outbound, abortErr } outbound.GasToken = gasToken @@ -52,5 +92,18 @@ func (k Keeper) buildRevertOutbound(sdkCtx sdk.Context, inbound *types.Inbound) outbound.GasPrice = gasPrice outbound.GasLimit = gasLimit - return outbound + return outbound, nil +} + +// abortRevertOutbound marks a half-built revert outbound as ABORTED with a reason. +// It mirrors the shape AbortOutbound writes for outbounds that are already attached +// to a universal tx; the matching outbound_aborted event is emitted by +// attachOutboundsToUtx, which is where the universal tx id is known. +func abortRevertOutbound(outbound *types.OutboundTx, reason error) { + outbound.OutboundStatus = types.Status_ABORTED + outbound.AbortReason = reason.Error() + outbound.GasToken = "" + outbound.GasFee = "" + outbound.GasPrice = "" + outbound.GasLimit = "" } diff --git a/x/uexecutor/keeper/create_outbound.go b/x/uexecutor/keeper/create_outbound.go index 43e99694..f8995ae2 100644 --- a/x/uexecutor/keeper/create_outbound.go +++ b/x/uexecutor/keeper/create_outbound.go @@ -245,22 +245,28 @@ func (k Keeper) AttachRescueOutboundFromReceipt( // never arrived on Push Chain and are still locked on the source chain. // // Non-CEA inbounds: the auto-generated INBOUND_REVERT outbound must exist and - // have reached REVERTED status, meaning TSS could not return the funds to the - // source chain and they are stuck (held by the gateway contract or in escrow). + // have reached REVERTED or ABORTED status. REVERTED means TSS tried and could + // not return the funds to the source chain; ABORTED means the revert could not + // even be built (its gas metadata was unresolvable) so it was never queued for + // signing. Either way the funds never came back and are stuck (held by the + // gateway contract or in escrow), which is exactly what rescue exists for. if originalUtx.InboundTx.IsCEA { if len(originalUtx.PcTx) == 0 || originalUtx.PcTx[0] == nil || originalUtx.PcTx[0].Status != "FAILED" { return fmt.Errorf("rescue: UTX %s CEA deposit did not fail", originalUtxId) } } else { - hasRevertedAutoRevert := false + hasUnrecoveredAutoRevert := false for _, ob := range originalUtx.OutboundTx { - if ob != nil && ob.TxType == types.TxType_INBOUND_REVERT && ob.OutboundStatus == types.Status_REVERTED { - hasRevertedAutoRevert = true + if ob == nil || ob.TxType != types.TxType_INBOUND_REVERT { + continue + } + if ob.OutboundStatus == types.Status_REVERTED || ob.OutboundStatus == types.Status_ABORTED { + hasUnrecoveredAutoRevert = true break } } - if !hasRevertedAutoRevert { - return fmt.Errorf("rescue: UTX %s has no reverted inbound-revert outbound", originalUtxId) + if !hasUnrecoveredAutoRevert { + return fmt.Errorf("rescue: UTX %s has no reverted or aborted inbound-revert outbound", originalUtxId) } } @@ -363,14 +369,28 @@ func (k Keeper) attachOutboundsToUtx( } } - // Write to pending outbounds index (inside UpdateUniversalTx closure for atomicity) - if err := k.PendingOutbounds.Set(ctx, outbound.Id, types.PendingOutboundEntry{ - OutboundId: outbound.Id, - UniversalTxId: utxId, - CreatedAt: ctx.BlockHeight(), - SigningDeadline: signingDeadline, - }); err != nil { - return fmt.Errorf("failed to set pending outbound index for %s: %w", outbound.Id, err) + // Only PENDING outbounds belong in the signing queue. Anything already + // ABORTED (e.g. a revert whose gas metadata could not be resolved) is + // recorded on the universal tx for the audit trail, but indexing it would + // park a row that can never be signed: no ballot forms for it, nothing + // removes it, and there is no admin abort for outbounds. + if outbound.OutboundStatus == types.Status_PENDING { + // Write to pending outbounds index (inside UpdateUniversalTx closure for atomicity) + if err := k.PendingOutbounds.Set(ctx, outbound.Id, types.PendingOutboundEntry{ + OutboundId: outbound.Id, + UniversalTxId: utxId, + CreatedAt: ctx.BlockHeight(), + SigningDeadline: signingDeadline, + }); err != nil { + return fmt.Errorf("failed to set pending outbound index for %s: %w", outbound.Id, err) + } + } else { + k.Logger().Warn("outbound attached without entering the signing queue", + "utx_id", utxId, + "outbound_id", outbound.Id, + "status", outbound.OutboundStatus.String(), + "abort_reason", outbound.AbortReason, + ) } var pcTxHash string @@ -403,6 +423,17 @@ func (k Keeper) attachOutboundsToUtx( if err == nil { ctx.EventManager().EmitEvent(evt) } + + // Mirror AbortOutbound's monitoring signal for outbounds that arrive + // already aborted, so alerting sees them the same way. + if outbound.OutboundStatus == types.Status_ABORTED { + ctx.EventManager().EmitEvent(sdk.NewEvent( + "outbound_aborted", + sdk.NewAttribute("utx_id", utxId), + sdk.NewAttribute("outbound_id", outbound.Id), + sdk.NewAttribute("abort_reason", outbound.AbortReason), + )) + } } return nil diff --git a/x/uexecutor/keeper/execute_inbound_funds.go b/x/uexecutor/keeper/execute_inbound_funds.go index fa3901ef..5241f6aa 100644 --- a/x/uexecutor/keeper/execute_inbound_funds.go +++ b/x/uexecutor/keeper/execute_inbound_funds.go @@ -74,7 +74,18 @@ func (k Keeper) ExecuteInboundFunds(ctx context.Context, utx types.UniversalTx) // isCEA failures never create an INBOUND_REVERT outbound // (consistent with execute_inbound_funds_and_payload.go and execute_inbound_gas_and_payload.go) if err != nil && !inbound.IsCEA { - revertOutbound := k.buildRevertOutbound(sdkCtx, inbound) + revertOutbound, buildErr := k.buildRevertOutbound(sdkCtx, inbound) + if buildErr != nil { + // The revert is still attached (recorded ABORTED) so the attempt stays + // auditable and the UTX becomes eligible for rescue. + k.Logger().Error("revert outbound could not be fully built", + "utx_id", utx.Id, + "error", buildErr.Error(), + ) + } + if revertOutbound == nil { + return nil + } if attachErr := k.attachOutboundsToUtx(sdkCtx, utx.Id, []*types.OutboundTx{revertOutbound}, err.Error()); attachErr != nil { if storeErr := k.UpdateUniversalTx(sdkCtx, utx.Id, func(u *types.UniversalTx) error { u.RevertError = attachErr.Error() diff --git a/x/uexecutor/keeper/execute_inbound_funds_and_payload.go b/x/uexecutor/keeper/execute_inbound_funds_and_payload.go index 09f06fce..fadd3837 100644 --- a/x/uexecutor/keeper/execute_inbound_funds_and_payload.go +++ b/x/uexecutor/keeper/execute_inbound_funds_and_payload.go @@ -187,7 +187,18 @@ func (k Keeper) ExecuteInboundFundsAndPayload(ctx context.Context, utx types.Uni // If deposit failed, stop here. if execErr != nil { if shouldRevert { - revertOutbound := k.buildRevertOutbound(sdkCtx, utx.InboundTx) + revertOutbound, buildErr := k.buildRevertOutbound(sdkCtx, utx.InboundTx) + if buildErr != nil { + // The revert is still attached (recorded ABORTED) so the attempt stays + // auditable and the UTX becomes eligible for rescue. + k.Logger().Error("revert outbound could not be fully built", + "utx_id", universalTxKey, + "error", buildErr.Error(), + ) + } + if revertOutbound == nil { + return nil + } if attachErr := k.attachOutboundsToUtx( sdkCtx, universalTxKey, diff --git a/x/uexecutor/keeper/execute_inbound_gas.go b/x/uexecutor/keeper/execute_inbound_gas.go index 9a9d194d..132ec126 100644 --- a/x/uexecutor/keeper/execute_inbound_gas.go +++ b/x/uexecutor/keeper/execute_inbound_gas.go @@ -190,7 +190,18 @@ func (k Keeper) ExecuteInboundGas(ctx context.Context, inbound types.Inbound) er } if execErr != nil && shouldRevert { - revertOutbound := k.buildRevertOutbound(sdkCtx, &inbound) + revertOutbound, buildErr := k.buildRevertOutbound(sdkCtx, &inbound) + if buildErr != nil { + // The revert is still attached (recorded ABORTED) so the attempt stays + // auditable and the UTX becomes eligible for rescue. + k.Logger().Error("revert outbound could not be fully built", + "utx_id", universalTxKey, + "error", buildErr.Error(), + ) + } + if revertOutbound == nil { + return nil + } if attachErr := k.attachOutboundsToUtx( sdkCtx, diff --git a/x/uexecutor/keeper/execute_inbound_gas_and_payload.go b/x/uexecutor/keeper/execute_inbound_gas_and_payload.go index 34f24479..158c9f4e 100644 --- a/x/uexecutor/keeper/execute_inbound_gas_and_payload.go +++ b/x/uexecutor/keeper/execute_inbound_gas_and_payload.go @@ -189,7 +189,18 @@ func (k Keeper) ExecuteInboundGasAndPayload(ctx context.Context, utx types.Unive // --- create revert ONLY for pre-deposit / deposit failures (non-isCEA path) if execErr != nil && shouldRevert { - revertOutbound := k.buildRevertOutbound(sdkCtx, utx.InboundTx) + revertOutbound, buildErr := k.buildRevertOutbound(sdkCtx, utx.InboundTx) + if buildErr != nil { + // The revert is still attached (recorded ABORTED) so the attempt stays + // auditable and the UTX becomes eligible for rescue. + k.Logger().Error("revert outbound could not be fully built", + "utx_id", universalTxKey, + "error", buildErr.Error(), + ) + } + if revertOutbound == nil { + return nil + } if attachErr := k.attachOutboundsToUtx( sdkCtx, diff --git a/x/uexecutor/keeper/handle_failed_inbound_validation.go b/x/uexecutor/keeper/handle_failed_inbound_validation.go index 0713aaa3..1c00f470 100644 --- a/x/uexecutor/keeper/handle_failed_inbound_validation.go +++ b/x/uexecutor/keeper/handle_failed_inbound_validation.go @@ -44,7 +44,18 @@ func (k Keeper) handleFailedInboundValidation(sdkCtx sdk.Context, utx types.Univ "source_chain", inbound.SourceChain, "amount", inbound.Amount, ) - revertOutbound := k.buildRevertOutbound(sdkCtx, inbound) + revertOutbound, buildErr := k.buildRevertOutbound(sdkCtx, inbound) + if buildErr != nil { + // The revert is still attached (recorded ABORTED) so the attempt stays + // auditable and the UTX becomes eligible for rescue. + k.Logger().Error("revert outbound could not be fully built", + "utx_key", universalTxKey, + "error", buildErr.Error(), + ) + } + if revertOutbound == nil { + return nil + } if attachErr := k.attachOutboundsToUtx( sdkCtx, From d88b6aa86071cd2779230c5dfb68dbe51a5906e6 Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Wed, 26 Aug 2026 07:00:17 +0530 Subject: [PATCH 2/2] test(uexecutor): cover aborted inbound-revert and rescue recovery - keeper unit tests drive buildRevertOutbound with the gas lookup mocked both ways: resolvable stays PENDING with exact gas fields and is indexed, unresolvable aborts with a reason and is not - integration tests assert the revert is ABORTED, absent from PendingOutbounds, and that a non-CEA RESCUE_FUNDS is then accepted - fix the unit fixture's auth store key (authtypes.StoreKey != ModuleName) and wire the real account keeper so UniversalCore calls work --- .../uexecutor/execute_inbound_gas_test.go | 21 +- .../uexecutor/inbound_revert_abort_test.go | 158 ++++++++++++ .../uexecutor/rescue_funds_test.go | 4 +- .../uexecutor/revert_stuck_inbound_test.go | 20 +- .../uexecutor/vote_inbound_validation_test.go | 13 +- .../keeper/build_revert_outbound_test.go | 241 ++++++++++++++++++ x/uexecutor/keeper/export_test.go | 4 + x/uexecutor/keeper/keeper_test.go | 12 +- 8 files changed, 454 insertions(+), 19 deletions(-) create mode 100644 test/integration/uexecutor/inbound_revert_abort_test.go create mode 100644 x/uexecutor/keeper/build_revert_outbound_test.go diff --git a/test/integration/uexecutor/execute_inbound_gas_test.go b/test/integration/uexecutor/execute_inbound_gas_test.go index b01de007..f946f1cd 100644 --- a/test/integration/uexecutor/execute_inbound_gas_test.go +++ b/test/integration/uexecutor/execute_inbound_gas_test.go @@ -292,12 +292,16 @@ func TestInboundGas(t *testing.T) { "revert outbound amount must match inbound amount") require.Equal(t, inbound.AssetAddr, ob.ExternalAssetAddr, "revert outbound asset must match inbound asset") - require.Equal(t, uexecutortypes.Status_PENDING, ob.OutboundStatus, - "revert outbound should start in PENDING status") - - // Gas fields are populated from UniversalCore if chain meta is set. - // In test env without VoteChainMeta, they may be zero/empty — that's OK, - // the outbound is still created (graceful degradation). + // The UniversalCore stub deployed by the integration harness cannot + // serve getOutboundTxGasAndFees, so the revert's gas metadata is + // unresolvable here and the outbound is recorded ABORTED rather than + // queued for a signature it could never receive. The resolvable + // (PENDING) path is covered by + // x/uexecutor/keeper/build_revert_outbound_test.go. + require.Equal(t, uexecutortypes.Status_ABORTED, ob.OutboundStatus, + "a revert with unresolvable gas metadata must be ABORTED, not PENDING") + require.NotEmpty(t, ob.AbortReason, "ABORTED revert must carry a reason") + requireNotQueuedForSigning(t, chainApp, ctx, ob.Id) // When chain meta IS set, these will be populated. break } @@ -464,7 +468,10 @@ func TestInboundGas(t *testing.T) { if ob.TxType == uexecutortypes.TxType_INBOUND_REVERT { foundRevert = true require.Equal(t, inbound.SourceChain, ob.DestinationChain) - require.Equal(t, uexecutortypes.Status_PENDING, ob.OutboundStatus) + // Gas metadata is unresolvable against the harness's UniversalCore stub, + // so the revert is recorded ABORTED instead of entering the signing queue. + require.Equal(t, uexecutortypes.Status_ABORTED, ob.OutboundStatus) + requireNotQueuedForSigning(t, chainApp, ctx, ob.Id) break } } diff --git a/test/integration/uexecutor/inbound_revert_abort_test.go b/test/integration/uexecutor/inbound_revert_abort_test.go new file mode 100644 index 00000000..90a10e46 --- /dev/null +++ b/test/integration/uexecutor/inbound_revert_abort_test.go @@ -0,0 +1,158 @@ +package integrationtest + +import ( + "math/big" + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/app" + utils "github.com/pushchain/push-chain-node/test/utils" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" +) + +// Regression coverage for F-2026-18823. +// +// buildRevertOutbound used to fail open: when it could not resolve the revert's +// gas metadata it logged "proceeding without gas fields" and returned the +// outbound anyway, still marked PENDING. attachOutboundsToUtx then indexed it +// into PendingOutbounds unconditionally, where the universal validators refused +// to sign it ("gas price is zero or missing"). The row could never leave the +// queue: no ballot forms for an unsignable outbound and there is no admin abort +// for outbounds. Worse, non-CEA rescue was gated on an INBOUND_REVERT having +// reached REVERTED, so the user had no recovery route either. +// +// The revert is now recorded ABORTED with a reason, kept off the signing queue, +// and accepted by the rescue gate. +// +// NOTE ON THIS ENVIRONMENT: the UniversalCore contract deployed by the test +// harness cannot serve getOutboundTxGasAndFees (its PRC20 stub has no +// SOURCE_CHAIN_NAMESPACE), so every INBOUND_REVERT built here takes the abort +// path. That makes the failure realistic end-to-end but means the resolvable +// path cannot be exercised at this level; it is covered by +// x/uexecutor/keeper/build_revert_outbound_test.go, which drives the same +// function with the gas lookup mocked both ways. + +// requireNotQueuedForSigning asserts that an outbound was never indexed into +// PendingOutbounds, i.e. it will not be picked up for TSS signing. +func requireNotQueuedForSigning(t *testing.T, chainApp *app.ChainApp, ctx sdk.Context, outboundId string) { + t.Helper() + has, err := chainApp.UexecutorKeeper.PendingOutbounds.Has(ctx, outboundId) + require.NoError(t, err) + require.False(t, has, + "outbound %s must not be indexed in PendingOutbounds: it can never be signed and nothing would ever remove it", outboundId) +} + +// findInboundRevert returns the INBOUND_REVERT outbound on a UTX, if any. +func findInboundRevert(utx uexecutortypes.UniversalTx) *uexecutortypes.OutboundTx { + for _, ob := range utx.OutboundTx { + if ob != nil && ob.TxType == uexecutortypes.TxType_INBOUND_REVERT { + return ob + } + } + return nil +} + +// driveNonCEAInboundToAbortedRevert votes a non-CEA FUNDS inbound with an empty +// recipient to quorum. Execution validation rejects it, so an INBOUND_REVERT is +// built — and since the harness cannot serve gas metadata, that revert aborts. +// +// The token/chain config is deliberately left registered so the failure is the +// gas lookup alone; the PRC20-not-found variant is covered in +// vote_inbound_validation_test.go. +func driveNonCEAInboundToAbortedRevert(t *testing.T, txHash string) (*app.ChainApp, sdk.Context, string) { + t.Helper() + + chainApp, ctx, vals, inbound, coreVals := setupInboundBridgeTest(t, 4) + inbound.TxHash = txHash + inbound.IsCEA = false + inbound.Recipient = "" // FUNDS requires a recipient — fails ValidateForExecution post-quorum + + for i := 0; i < 3; i++ { + valAddr, err := sdk.ValAddressFromBech32(coreVals[i].OperatorAddress) + require.NoError(t, err) + require.NoError(t, utils.ExecVoteInbound(t, ctx, chainApp, vals[i], sdk.AccAddress(valAddr).String(), inbound)) + } + + return chainApp, ctx, uexecutortypes.GetInboundUniversalTxKey(*inbound) +} + +// TestInboundRevert_UnresolvableGasMetadata_AbortsInsteadOfQueueing is the +// headline regression test: the revert must be recorded ABORTED with a reason +// and must never reach PendingOutbounds. +func TestInboundRevert_UnresolvableGasMetadata_AbortsInsteadOfQueueing(t *testing.T) { + chainApp, ctx, utxId := driveNonCEAInboundToAbortedRevert(t, "0xabortrevert01") + + utx, found, err := chainApp.UexecutorKeeper.GetUniversalTx(ctx, utxId) + require.NoError(t, err) + require.True(t, found, "UTX must exist after quorum") + + revert := findInboundRevert(utx) + require.NotNil(t, revert, "a failed non-CEA inbound must still record an INBOUND_REVERT attempt") + + require.Equal(t, uexecutortypes.Status_ABORTED, revert.OutboundStatus, + "a revert whose gas metadata could not be resolved must be ABORTED, never PENDING") + require.NotEmpty(t, revert.AbortReason, "the abort reason must say why the revert could not be built") + require.Contains(t, revert.AbortReason, "gas fee info", + "the reason must name the lookup that failed") + + // Fail-closed: the gas fields stay empty rather than being half-written. + require.Empty(t, revert.GasToken) + require.Empty(t, revert.GasFee) + require.Empty(t, revert.GasPrice) + require.Empty(t, revert.GasLimit) + + requireNotQueuedForSigning(t, chainApp, ctx, revert.Id) + + // The whole queue stays clean, not just this id. + err = chainApp.UexecutorKeeper.PendingOutbounds.Walk(ctx, nil, func(id string, _ uexecutortypes.PendingOutboundEntry) (bool, error) { + t.Fatalf("PendingOutbounds must be empty, found %s", id) + return true, nil + }) + require.NoError(t, err) +} + +// TestInboundRevert_AbortedRevert_UnlocksRescue proves the other half of the +// fix: skipping the queue is not enough on its own, because non-CEA rescue used +// to require a REVERTED inbound-revert. An ABORTED one must now be accepted, or +// the user is left with a clean queue and no way out. +func TestInboundRevert_AbortedRevert_UnlocksRescue(t *testing.T) { + chainApp, ctx, utxId := driveNonCEAInboundToAbortedRevert(t, "0xabortrevert02") + + utx, found, err := chainApp.UexecutorKeeper.GetUniversalTx(ctx, utxId) + require.NoError(t, err) + require.True(t, found) + revert := findInboundRevert(utx) + require.NotNil(t, revert) + require.Equal(t, uexecutortypes.Status_ABORTED, revert.OutboundStatus, + "precondition: the revert must have aborted for this test to mean anything") + + prc20Addr := utils.GetDefaultAddresses().PRC20USDCAddr + senderAddr := common.HexToAddress(utils.GetDefaultAddresses().DefaultTestAddr) + log := buildRescueFundsLog(t, utxId, prc20Addr, senderAddr, + "eip155", big.NewInt(333), big.NewInt(1_000_000_000), big.NewInt(200_000)) + + err = chainApp.UexecutorKeeper.AttachRescueOutboundFromReceipt( + ctx, + makeRescueReceipt(t, "0xrescueafterabort", log), + uexecutortypes.PCTx{TxHash: "0xrescueafterabort", Status: "SUCCESS"}, + ) + require.NoError(t, err, "rescue must be accepted when the auto-revert aborted; the funds never came back") + + utx, _, err = chainApp.UexecutorKeeper.GetUniversalTx(ctx, utxId) + require.NoError(t, err) + + rescue := findRescueOutbound(utx) + require.NotNil(t, rescue, "a RESCUE_FUNDS outbound must be attached") + require.Equal(t, uexecutortypes.Status_PENDING, rescue.OutboundStatus, + "the rescue itself is signable and must be queued") + require.Equal(t, "333", rescue.GasFee) + + // The rescue is queued; the aborted revert still is not. + has, err := chainApp.UexecutorKeeper.PendingOutbounds.Has(ctx, rescue.Id) + require.NoError(t, err) + require.True(t, has, "the rescue outbound must be indexed for UV pickup") + requireNotQueuedForSigning(t, chainApp, ctx, revert.Id) +} diff --git a/test/integration/uexecutor/rescue_funds_test.go b/test/integration/uexecutor/rescue_funds_test.go index 0fa05436..ba25b367 100644 --- a/test/integration/uexecutor/rescue_funds_test.go +++ b/test/integration/uexecutor/rescue_funds_test.go @@ -226,7 +226,7 @@ func TestRescueFunds(t *testing.T) { "eip155", big.NewInt(111), big.NewInt(1_000_000_000), big.NewInt(200_000)) err := chainApp.UexecutorKeeper.AttachRescueOutboundFromReceipt(ctx, makeRescueReceipt(t, "0xrescuetx03", log), uexecutortypes.PCTx{TxHash: "0xrescuetx03", Status: "SUCCESS"}) require.Error(t, err) - require.Contains(t, err.Error(), "no reverted inbound-revert outbound") + require.Contains(t, err.Error(), "no reverted or aborted inbound-revert outbound") }) t.Run("rescue is rejected for non-CEA inbound when auto-revert is PENDING", func(t *testing.T) { @@ -255,7 +255,7 @@ func TestRescueFunds(t *testing.T) { "eip155", big.NewInt(111), big.NewInt(1_000_000_000), big.NewInt(200_000)) err = chainApp.UexecutorKeeper.AttachRescueOutboundFromReceipt(ctx, makeRescueReceipt(t, "0xrescuetx03b", log), uexecutortypes.PCTx{TxHash: "0xrescuetx03b", Status: "SUCCESS"}) require.Error(t, err) - require.Contains(t, err.Error(), "no reverted inbound-revert outbound") + require.Contains(t, err.Error(), "no reverted or aborted inbound-revert outbound") }) t.Run("rescue succeeds for non-CEA inbound with reverted auto-revert", func(t *testing.T) { diff --git a/test/integration/uexecutor/revert_stuck_inbound_test.go b/test/integration/uexecutor/revert_stuck_inbound_test.go index d5dc8e8c..826e67f9 100644 --- a/test/integration/uexecutor/revert_stuck_inbound_test.go +++ b/test/integration/uexecutor/revert_stuck_inbound_test.go @@ -125,7 +125,15 @@ func TestRevertStuckInbound_HappyPath_ExpiredBallot_CreatesRevertOutbound(t *tes require.Equal(t, uexecutortypes.GetOutboundRevertId(inbound.SourceChain, inbound.TxHash, inbound.LogIndex), ob.Id, "outbound id must follow the canonical revert-id format") require.Equal(t, uexecutortypes.TxType_INBOUND_REVERT, ob.TxType, "outbound type must be INBOUND_REVERT") - require.Equal(t, uexecutortypes.Status_PENDING, ob.OutboundStatus, "outbound must start PENDING so UVs sign it") + // The harness's UniversalCore stub cannot serve getOutboundTxGasAndFees, so the + // revert's gas metadata is unresolvable and it is recorded ABORTED rather than + // queued for a signature it could never receive. The admin message still reports + // the outbound it created, and the UTX becomes eligible for RESCUE_FUNDS. The + // resolvable (PENDING) path is covered by + // x/uexecutor/keeper/build_revert_outbound_test.go. + require.Equal(t, uexecutortypes.Status_ABORTED, ob.OutboundStatus, + "a revert with unresolvable gas metadata must be ABORTED, not PENDING") + require.NotEmpty(t, ob.AbortReason, "ABORTED revert must record why it could not be built") require.Equal(t, inbound.SourceChain, ob.DestinationChain, "revert goes back to the source chain") require.Equal(t, inbound.RevertInstructions.FundRecipient, ob.Recipient, "recipient must use RevertInstructions.FundRecipient when set") @@ -134,10 +142,12 @@ func TestRevertStuckInbound_HappyPath_ExpiredBallot_CreatesRevertOutbound(t *tes require.Equal(t, chainutils.LenientCanonicalizeEVMAddress(inbound.Sender), ob.Sender, "sender field carries original depositor") // --- PendingOutbounds index assertions --- - pending, err := chainApp.UexecutorKeeper.PendingOutbounds.Get(ctx, ob.Id) - require.NoError(t, err, "revert outbound must be indexed in PendingOutbounds for UV pickup") - require.Equal(t, ob.Id, pending.OutboundId) - require.Equal(t, utx.Id, pending.UniversalTxId) + // An ABORTED revert must stay out of the signing queue: no ballot can ever form + // for it and there is no admin abort for outbounds, so an indexed row would be + // permanently stuck. + has, err := chainApp.UexecutorKeeper.PendingOutbounds.Has(ctx, ob.Id) + require.NoError(t, err) + require.False(t, has, "an ABORTED revert must not be indexed in PendingOutbounds") } // TestRevertStuckInbound_RecipientFallback_UsesSender covers the case where diff --git a/test/integration/uexecutor/vote_inbound_validation_test.go b/test/integration/uexecutor/vote_inbound_validation_test.go index 4e833dec..ac1978fd 100644 --- a/test/integration/uexecutor/vote_inbound_validation_test.go +++ b/test/integration/uexecutor/vote_inbound_validation_test.go @@ -233,7 +233,11 @@ func TestVoteInboundValidation(t *testing.T) { foundRevert = true require.Equal(t, inbound.SourceChain, ob.DestinationChain) require.Equal(t, inbound.Amount, ob.Amount) - require.Equal(t, uexecutortypes.Status_PENDING, ob.OutboundStatus) + // The harness's UniversalCore stub cannot serve gas metadata, so the + // revert is unsignable and is recorded ABORTED instead of queued. + require.Equal(t, uexecutortypes.Status_ABORTED, ob.OutboundStatus) + require.NotEmpty(t, ob.AbortReason) + requireNotQueuedForSigning(t, chainApp, ctx, ob.Id) break } } @@ -356,7 +360,12 @@ func TestVoteInboundValidation(t *testing.T) { require.Equal(t, inbound.SourceChain, ob.DestinationChain) require.Equal(t, inbound.Amount, ob.Amount) require.Equal(t, inbound.AssetAddr, ob.ExternalAssetAddr) - require.Equal(t, uexecutortypes.Status_PENDING, ob.OutboundStatus) + // The token config was removed above, so the revert cannot resolve the + // PRC20 it needs for gas metadata. It is recorded ABORTED with the + // reason instead of being queued as an unsignable PENDING row. + require.Equal(t, uexecutortypes.Status_ABORTED, ob.OutboundStatus) + require.Contains(t, ob.AbortReason, "failed to resolve PRC20") + requireNotQueuedForSigning(t, chainApp, ctx, ob.Id) break } } diff --git a/x/uexecutor/keeper/build_revert_outbound_test.go b/x/uexecutor/keeper/build_revert_outbound_test.go new file mode 100644 index 00000000..890262c4 --- /dev/null +++ b/x/uexecutor/keeper/build_revert_outbound_test.go @@ -0,0 +1,241 @@ +package keeper_test + +import ( + "errors" + "math/big" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + + sdk "github.com/cosmos/cosmos-sdk/types" + evmtypes "github.com/cosmos/evm/x/vm/types" + "github.com/ethereum/go-ethereum/common" + + "github.com/pushchain/push-chain-node/x/uexecutor/types" + uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" +) + +const ( + revertSourceChain = "eip155:11155111" + revertAssetAddr = "0x0000000000000000000000000000000000000e07" + revertPRC20Addr = "0x0000000000000000000000000000000000000e06" + revertGasTokenHex = "0x0000000000000000000000000000000000001111" +) + +// revertTestInbound is a non-CEA FUNDS inbound whose execution failed, i.e. the +// input to buildRevertOutbound. +func revertTestInbound() *types.Inbound { + return &types.Inbound{ + SourceChain: revertSourceChain, + TxHash: "0xdeadbeef", + LogIndex: "1", + Sender: "0x778d3206374F8ac265728e18E3fE2Ae6b93E4ce4", + Recipient: "0x778d3206374F8ac265728e18E3fE2Ae6b93E4ce4", + Amount: "1000000", + AssetAddr: revertAssetAddr, + TxType: types.TxType_FUNDS, + RevertInstructions: &types.RevertInstructions{ + FundRecipient: "0x527F3692F5C53CfA83F7689885995606F93b6164", + }, + } +} + +func revertTestTokenConfig() uregistrytypes.TokenConfig { + return uregistrytypes.TokenConfig{ + Chain: revertSourceChain, + Address: revertAssetAddr, + Enabled: true, + NativeRepresentation: &uregistrytypes.NativeRepresentation{ + ContractAddress: revertPRC20Addr, + }, + } +} + +// expectGasFeeCall stubs UniversalCore.getOutboundTxGasAndFees to return a +// well-formed 6-output response, i.e. the healthy path. +func expectGasFeeCall(t *testing.T, f *testFixture, gasFee, gasPrice, gasLimit *big.Int) { + t.Helper() + + ucABI, err := types.ParseUniversalCoreABI() + require.NoError(t, err) + + packed, err := ucABI.Methods["getOutboundTxGasAndFees"].Outputs.Pack( + common.HexToAddress(revertGasTokenHex), // gasToken + gasFee, // gasFee + big.NewInt(0), // protocolFee + gasPrice, // gasPrice + "eip155", // chainNamespace + gasLimit, // gasLimitUsed + ) + require.NoError(t, err) + + f.mockEVMKeeper.EXPECT(). + CallEVM(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Eq("getOutboundTxGasAndFees"), gomock.Any(), gomock.Any()). + Return(&evmtypes.MsgEthereumTxResponse{Ret: packed}, nil). + AnyTimes() +} + +// attachRevert stores a UTX and runs the revert outbound through the same attach +// path the production callers use, so PendingOutbounds indexing is exercised. +func attachRevert(t *testing.T, f *testFixture, utxId string, ob *types.OutboundTx) { + t.Helper() + + require.NoError(t, f.k.UniversalTx.Set(f.ctx, utxId, types.UniversalTx{ + Id: utxId, + InboundTx: revertTestInbound(), + })) + f.mockUregistryKeeper.EXPECT(). + GetChainConfig(gomock.Any(), revertSourceChain). + Return(uregistrytypes.ChainConfig{Chain: revertSourceChain}, nil). + AnyTimes() + + require.NoError(t, f.k.TestAttachOutboundsToUtx(f.ctx, utxId, []*types.OutboundTx{ob}, "execution failed")) +} + +func hasEvent(events sdk.Events, evtType string) bool { + for _, e := range events { + if e.Type == evtType { + return true + } + } + return false +} + +// TestBuildRevertOutbound_HealthyPath is the regression guard for the untouched +// path: when the gas metadata resolves, the revert is PENDING, carries the exact +// values UniversalCore returned, and is indexed for universal-validator pickup. +func TestBuildRevertOutbound_HealthyPath(t *testing.T) { + f := setupPendingOutboundFixture(t) + + f.mockUregistryKeeper.EXPECT(). + GetTokenConfig(gomock.Any(), revertSourceChain, revertAssetAddr). + Return(revertTestTokenConfig(), nil). + AnyTimes() + expectGasFeeCall(t, f, big.NewInt(123_456), big.NewInt(1_000_000_000), big.NewInt(200_000)) + + inbound := revertTestInbound() + ob, err := f.k.TestBuildRevertOutbound(f.ctx, inbound) + require.NoError(t, err, "healthy gas metadata must not produce an error") + require.NotNil(t, ob) + + require.Equal(t, types.Status_PENDING, ob.OutboundStatus, "healthy revert must stay PENDING so UVs sign it") + require.Empty(t, ob.AbortReason, "healthy revert must carry no abort reason") + require.Equal(t, types.TxType_INBOUND_REVERT, ob.TxType) + require.Equal(t, revertSourceChain, ob.DestinationChain) + require.Equal(t, inbound.Amount, ob.Amount) + require.Equal(t, inbound.AssetAddr, ob.ExternalAssetAddr) + require.Equal(t, inbound.RevertInstructions.FundRecipient, ob.Recipient) + + // Gas fields exactly as UniversalCore returned them. + require.Equal(t, common.HexToAddress(revertGasTokenHex).Hex(), ob.GasToken) + require.Equal(t, "123456", ob.GasFee) + require.Equal(t, "1000000000", ob.GasPrice) + require.Equal(t, "200000", ob.GasLimit) + + // ...and it still enters the signing queue. + attachRevert(t, f, "utx-healthy", ob) + entry, err := f.k.PendingOutbounds.Get(f.ctx, ob.Id) + require.NoError(t, err, "a PENDING revert must be indexed in PendingOutbounds") + require.Equal(t, "utx-healthy", entry.UniversalTxId) +} + +// TestBuildRevertOutbound_GasFeeLookupFails is the headline case: the +// UniversalCore call reverts, so the outbound must be recorded ABORTED and must +// never reach the signing queue. +func TestBuildRevertOutbound_GasFeeLookupFails(t *testing.T) { + f := setupPendingOutboundFixture(t) + + f.mockUregistryKeeper.EXPECT(). + GetTokenConfig(gomock.Any(), revertSourceChain, revertAssetAddr). + Return(revertTestTokenConfig(), nil). + AnyTimes() + f.mockEVMKeeper.EXPECT(). + CallEVM(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + gomock.Eq("getOutboundTxGasAndFees"), gomock.Any(), gomock.Any()). + Return(nil, errors.New("execution reverted: ZeroGasPrice")). + AnyTimes() + + ob, err := f.k.TestBuildRevertOutbound(f.ctx, revertTestInbound()) + require.Error(t, err, "a gas-metadata failure must be reported, not swallowed") + require.Contains(t, err.Error(), "gas fee info") + require.NotNil(t, ob, "the aborted attempt is still returned so it can be recorded") + + require.Equal(t, types.Status_ABORTED, ob.OutboundStatus, + "an unsignable revert must be ABORTED, never PENDING") + require.NotEmpty(t, ob.AbortReason, "abort reason must explain why the revert could not be built") + require.Contains(t, ob.AbortReason, "ZeroGasPrice") + require.Empty(t, ob.GasFee) + require.Empty(t, ob.GasPrice) + require.Empty(t, ob.GasLimit) + require.Empty(t, ob.GasToken) + + // Recorded on the UTX for the audit trail... + attachRevert(t, f, "utx-aborted", ob) + utx, found, err := f.k.GetUniversalTx(f.ctx, "utx-aborted") + require.NoError(t, err) + require.True(t, found) + require.Len(t, utx.OutboundTx, 1) + require.Equal(t, types.Status_ABORTED, utx.OutboundTx[0].OutboundStatus) + + // ...but NOT queued for signing: an unsignable row here would sit forever. + has, err := f.k.PendingOutbounds.Has(f.ctx, ob.Id) + require.NoError(t, err) + require.False(t, has, "an ABORTED revert must never be indexed in PendingOutbounds") + + require.True(t, hasEvent(f.ctx.EventManager().Events(), "outbound_aborted"), + "an outbound_aborted event must be emitted so monitoring sees the failure") +} + +// TestBuildRevertOutbound_TokenConfigMissing covers the other fail-open branch: +// the PRC20 for the inbound asset cannot be resolved at all. +func TestBuildRevertOutbound_TokenConfigMissing(t *testing.T) { + f := setupPendingOutboundFixture(t) + + f.mockUregistryKeeper.EXPECT(). + GetTokenConfig(gomock.Any(), revertSourceChain, revertAssetAddr). + Return(uregistrytypes.TokenConfig{}, errors.New("token config not found")). + AnyTimes() + + ob, err := f.k.TestBuildRevertOutbound(f.ctx, revertTestInbound()) + require.Error(t, err) + require.Contains(t, err.Error(), "PRC20") + require.NotNil(t, ob) + require.Equal(t, types.Status_ABORTED, ob.OutboundStatus) + require.Contains(t, ob.AbortReason, "token config not found") + + attachRevert(t, f, "utx-no-token-config", ob) + has, err := f.k.PendingOutbounds.Has(f.ctx, ob.Id) + require.NoError(t, err) + require.False(t, has, "an ABORTED revert must never be indexed in PendingOutbounds") +} + +// TestBuildRevertOutbound_TokenConfigWithoutNativeRepresentation covers a token +// config that resolves but carries no PRC20 — the lookup returns no error, so the +// abort reason has to be synthesised. +func TestBuildRevertOutbound_TokenConfigWithoutNativeRepresentation(t *testing.T) { + f := setupPendingOutboundFixture(t) + + f.mockUregistryKeeper.EXPECT(). + GetTokenConfig(gomock.Any(), revertSourceChain, revertAssetAddr). + Return(uregistrytypes.TokenConfig{Chain: revertSourceChain, Address: revertAssetAddr}, nil). + AnyTimes() + + ob, err := f.k.TestBuildRevertOutbound(f.ctx, revertTestInbound()) + require.Error(t, err) + require.NotNil(t, ob) + require.Equal(t, types.Status_ABORTED, ob.OutboundStatus) + require.Contains(t, ob.AbortReason, "no native representation") +} + +// TestBuildRevertOutbound_NilInbound proves the (outbound, error) contract: a nil +// outbound only ever comes back with a non-nil error, which is what the admin +// revert path checks before it claims a revert was created. +func TestBuildRevertOutbound_NilInbound(t *testing.T) { + f := setupPendingOutboundFixture(t) + + ob, err := f.k.TestBuildRevertOutbound(f.ctx, nil) + require.Error(t, err) + require.Nil(t, ob) +} diff --git a/x/uexecutor/keeper/export_test.go b/x/uexecutor/keeper/export_test.go index 2a406003..641345fa 100644 --- a/x/uexecutor/keeper/export_test.go +++ b/x/uexecutor/keeper/export_test.go @@ -8,3 +8,7 @@ import ( func (k Keeper) TestAttachOutboundsToUtx(ctx sdk.Context, utxId string, outbounds []*types.OutboundTx, revertMsg string) error { return k.attachOutboundsToUtx(ctx, utxId, outbounds, revertMsg) } + +func (k Keeper) TestBuildRevertOutbound(ctx sdk.Context, inbound *types.Inbound) (*types.OutboundTx, error) { + return k.buildRevertOutbound(ctx, inbound) +} diff --git a/x/uexecutor/keeper/keeper_test.go b/x/uexecutor/keeper/keeper_test.go index d9910862..793162f3 100755 --- a/x/uexecutor/keeper/keeper_test.go +++ b/x/uexecutor/keeper/keeper_test.go @@ -50,6 +50,9 @@ var maccPerms = map[string][]string{ stakingtypes.NotBondedPoolName: {authtypes.Burner, authtypes.Staking}, minttypes.ModuleName: {authtypes.Minter}, govtypes.ModuleName: {authtypes.Burner}, + // The uexecutor module account is resolved by Keeper.GetUeModuleAddress, which + // every UniversalCore call goes through. + types.ModuleName: nil, } type testFixture struct { @@ -117,10 +120,10 @@ func SetupTest(t *testing.T) *testFixture { registerBaseSDKModules(logger, f, encCfg, keys, accountAddressCodec, validatorAddressCodec, consensusAddressCodec) // Setup Keeper. - f.k = keeper.NewKeeper(encCfg.Codec, runtime.NewKVStoreService(keys[types.ModuleName]), logger, f.govModAddr, f.mockEVMKeeper, &feemarketkeeper.Keeper{}, f.mockBankKeeper, authkeeper.AccountKeeper{}, f.mockUregistryKeeper, &uvalidatorKeeper.Keeper{}) + f.k = keeper.NewKeeper(encCfg.Codec, runtime.NewKVStoreService(keys[types.ModuleName]), logger, f.govModAddr, f.mockEVMKeeper, &feemarketkeeper.Keeper{}, f.mockBankKeeper, f.accountkeeper, f.mockUregistryKeeper, &uvalidatorKeeper.Keeper{}) f.msgServer = keeper.NewMsgServerImpl(f.k) f.queryServer = keeper.NewQuerier(f.k) - f.appModule = module.NewAppModule(encCfg.Codec, f.k, f.mockEVMKeeper, &feemarketkeeper.Keeper{}, f.mockBankKeeper, authkeeper.AccountKeeper{}, f.mockUregistryKeeper, &uvalidatorKeeper.Keeper{}) + f.appModule = module.NewAppModule(encCfg.Codec, f.k, f.mockEVMKeeper, &feemarketkeeper.Keeper{}, f.mockBankKeeper, f.accountkeeper, f.mockUregistryKeeper, &uvalidatorKeeper.Keeper{}) return f } @@ -146,8 +149,11 @@ func registerBaseSDKModules( registerModuleInterfaces(encCfg) // Auth Keeper. + // NOTE: keys is built from module names, and authtypes.StoreKey ("acc") is not + // authtypes.ModuleName ("auth") — looking up the wrong one yields a nil store key + // and panics the first time the account keeper is actually touched. f.accountkeeper = authkeeper.NewAccountKeeper( - encCfg.Codec, runtime.NewKVStoreService(keys[authtypes.StoreKey]), + encCfg.Codec, runtime.NewKVStoreService(keys[authtypes.ModuleName]), authtypes.ProtoBaseAccount, maccPerms, ac, app.Bech32PrefixAccAddr,