From 51d47dab271bff5a53d4a356e32773c0463b77e9 Mon Sep 17 00:00:00 2001 From: Vitaly Andrianov Date: Fri, 29 May 2026 11:20:52 +0300 Subject: [PATCH] fix(inference): refund client escrow for VOTING inferences on timeout expireInferences filtered timed-out inferences by Status == STARTED only. A failing MsgValidation moves an inference to VOTING; if the resulting x/group proposals miss quorum within the voting window, timeout cleanup silently skipped it -- the InferenceTimeout entry was removed, the inference stayed VOTING forever, and the client's escrow was stranded in the inference module account. Add a VOTING branch: on timeout, mark the inference EXPIRED and refund the client via expireInferenceAndIssueRefund (default-to-refund on quorum miss). The x/group proposals are pruned by x/group EndBlock. Adds TestExpireInferences_VotingInferenceRefundedOnTimeout, which drives the real expireInferences (via an export_test.go wrapper) and fails on current main (no refund, inference stays VOTING), passing with the fix. Surfaced by simulation/fuzz testing for inference-chain (#982). Closes #1265. Signed-off-by: Vitaly Andrianov --- .../module/expire_voting_stuck_test.go | 87 +++++++++++++++++++ .../x/inference/module/export_test.go | 20 +++++ inference-chain/x/inference/module/module.go | 5 ++ 3 files changed, 112 insertions(+) create mode 100644 inference-chain/x/inference/module/expire_voting_stuck_test.go create mode 100644 inference-chain/x/inference/module/export_test.go diff --git a/inference-chain/x/inference/module/expire_voting_stuck_test.go b/inference-chain/x/inference/module/expire_voting_stuck_test.go new file mode 100644 index 000000000..8eabe51bf --- /dev/null +++ b/inference-chain/x/inference/module/expire_voting_stuck_test.go @@ -0,0 +1,87 @@ +package inference_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + keepertest "github.com/productscience/inference/testutil/keeper" + "github.com/productscience/inference/testutil/sample" + inference "github.com/productscience/inference/x/inference/module" + "github.com/productscience/inference/x/inference/types" +) + +// TestExpireInferences_VotingInferenceRefundedOnTimeout exercises the real +// expireInferences filter (module.go) and asserts that a VOTING inference +// whose timeout fires is refunded and marked EXPIRED. +// +// Bug (pre-fix): the filter handled Status == STARTED only. When a failing +// MsgValidation moves an inference to VOTING and the resulting x/group +// proposals miss quorum, the timeout cleanup silently skips it — the timeout +// entry is removed but the inference stays VOTING forever and the client's +// escrow is stranded in the inference module account. +// +// Without the fix this test FAILS twice over: the BankKeeper refund call is +// never made (unmet gomock expectation) and the inference stays VOTING. With +// the fix the VOTING branch calls expireInferenceAndIssueRefund → refund + +// EXPIRED. +// +// expireInferences is unexported, so the test drives it through the +// ExpireInferencesForTest wrapper (export_test.go). currentEpoch is passed as +// nil: the VOTING path never consults the epoch, and +// GetLatestPoCOrCPoCRangeWithData short-circuits to an inactive PoC range when +// currentEpoch == nil — so the expiry context builds without any epoch/PoC +// state and the real STARTED/VOTING filter runs. +func TestExpireInferences_VotingInferenceRefundedOnTimeout(t *testing.T) { + k, sdkCtx, mocks := keepertest.InferenceKeeperReturningMocks(t) + am := inference.NewAppModule(nil, k, nil, nil, nil, nil) + + // Refund path: IssueRefund -> PayParticipantFromEscrow -> + // SendCoinsFromModuleToAccount (+ best-effort LogSubAccountTransaction). + // Times(1) is the fail-without-fix guard: pre-fix the VOTING inference is + // skipped and this expectation goes unmet. + mocks.BankKeeper.EXPECT(). + SendCoinsFromModuleToAccount(gomock.Any(), types.ModuleName, gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil).Times(1) + mocks.BankKeeper.EXPECT().LogSubAccountTransaction( + gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), + ).AnyTimes() + + const inferenceID = "stuck-in-voting" + const blockHeight = int64(100) + const escrowAmount = int64(500) + clientAddr := sample.AccAddress() + executorAddr := sample.AccAddress() + + require.NoError(t, k.SetInference(sdkCtx, types.Inference{ + InferenceId: inferenceID, + Index: inferenceID, + Status: types.InferenceStatus_VOTING, + EpochId: 1, + EscrowAmount: escrowAmount, + ExecutedBy: executorAddr, + RequestedBy: clientAddr, + AssignedTo: executorAddr, + })) + require.NoError(t, k.SetInferenceTimeout(sdkCtx, types.InferenceTimeout{ + InferenceId: inferenceID, + ExpirationHeight: uint64(blockHeight), + })) + + params, err := k.GetParams(sdkCtx) + require.NoError(t, err) + + timeouts := k.GetAllInferenceTimeoutForHeight(sdkCtx, uint64(blockHeight)) + require.Len(t, timeouts, 1, "expected the one queued timeout") + + // The operation under test — the real production filter. + require.NoError(t, am.ExpireInferencesForTest(sdkCtx, timeouts, blockHeight, nil, ¶ms)) + + after, found := k.GetInference(sdkCtx, inferenceID) + require.True(t, found) + require.Equalf(t, types.InferenceStatus_EXPIRED, after.Status, + "expireInferences must mark a timed-out VOTING inference EXPIRED and refund "+ + "the client escrow; pre-fix it was silently dropped (finding #3 / #1265)") + require.Equal(t, int64(0), after.ActualCost) +} diff --git a/inference-chain/x/inference/module/export_test.go b/inference-chain/x/inference/module/export_test.go new file mode 100644 index 000000000..2dd9b4f01 --- /dev/null +++ b/inference-chain/x/inference/module/export_test.go @@ -0,0 +1,20 @@ +package inference + +import ( + "context" + + "github.com/productscience/inference/x/inference/types" +) + +// ExpireInferencesForTest exposes the unexported expireInferences method for +// black-box tests in package inference_test. It is only compiled during tests +// (file ends in _test.go) and does not appear in the public API. +func (am AppModule) ExpireInferencesForTest( + ctx context.Context, + timeouts []types.InferenceTimeout, + blockHeight int64, + currentEpoch *types.Epoch, + params *types.Params, +) error { + return am.expireInferences(ctx, timeouts, blockHeight, currentEpoch, params) +} diff --git a/inference-chain/x/inference/module/module.go b/inference-chain/x/inference/module/module.go index 4c144b583..5e26e944e 100644 --- a/inference-chain/x/inference/module/module.go +++ b/inference-chain/x/inference/module/module.go @@ -228,6 +228,11 @@ func (am AppModule) expireInferences( } if inference.Status == types.InferenceStatus_STARTED { am.handleExpiredInferenceWithContext(ctx, inference, expiryCtx) + } else if inference.Status == types.InferenceStatus_VOTING { + // VOTING inferences whose x/group proposals missed quorum + // would otherwise be silently dropped here, stranding client + // escrow in the inference module account. + am.expireInferenceAndIssueRefund(ctx, inference) } } return nil