Skip to content

Commit 66c0dab

Browse files
committed
feat: added logic to refundGasFee in failure outbound txs as well
1 parent ff299b5 commit 66c0dab

File tree

4 files changed

+94
-89
lines changed

4 files changed

+94
-89
lines changed

test/integration/uexecutor/gas_fee_refund_test.go

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,28 @@ import (
1313

1414
func TestGasFeeRefund(t *testing.T) {
1515

16-
t.Run("success vote with empty gasFeeUsed is rejected", func(t *testing.T) {
17-
app, ctx, vals, utxId, outbound, coreVals :=
18-
setupOutboundVotingTest(t, 4)
16+
t.Run("vote with empty gasFeeUsed is always rejected", func(t *testing.T) {
17+
// gas_fee_used is mandatory for both success and failure votes.
18+
for _, success := range []bool{true, false} {
19+
app, ctx, vals, utxId, outbound, coreVals :=
20+
setupOutboundVotingTest(t, 4)
1921

20-
valAddr, err := sdk.ValAddressFromBech32(coreVals[0].OperatorAddress)
21-
require.NoError(t, err)
22-
coreAcc := sdk.AccAddress(valAddr).String()
23-
24-
err = utils.ExecVoteOutbound(
25-
t,
26-
ctx,
27-
app,
28-
vals[0],
29-
coreAcc,
30-
utxId,
31-
outbound,
32-
true,
33-
"",
34-
"", // gas_fee_used required when success=true → must be rejected
35-
)
36-
require.Error(t, err)
37-
require.Contains(t, err.Error(), "gas_fee_used required when success=true")
22+
valAddr, err := sdk.ValAddressFromBech32(coreVals[0].OperatorAddress)
23+
require.NoError(t, err)
24+
coreAcc := sdk.AccAddress(valAddr).String()
25+
26+
revertReason := ""
27+
if !success {
28+
revertReason = "execution failed"
29+
}
30+
31+
err = utils.ExecVoteOutbound(
32+
t, ctx, app, vals[0], coreAcc, utxId, outbound,
33+
success, revertReason, "", // empty gas_fee_used → must be rejected
34+
)
35+
require.Error(t, err)
36+
require.Contains(t, err.Error(), "observed_tx.gas_fee_used is required")
37+
}
3838
})
3939

4040
t.Run("no refund when gasFeeUsed equals gasFee", func(t *testing.T) {
@@ -196,11 +196,12 @@ func TestGasFeeRefund(t *testing.T) {
196196
require.Equal(t, uexecutortypes.Status_OBSERVED, ob.OutboundStatus)
197197
})
198198

199-
t.Run("failed outbound still performs revert not refund", func(t *testing.T) {
199+
t.Run("failed outbound performs both revert and gas refund", func(t *testing.T) {
200200
app, ctx, vals, utxId, outbound, coreVals :=
201201
setupOutboundVotingTest(t, 4)
202202

203-
// Vote as FAILED with gasFeeUsed set — refund should NOT run for failed outbounds
203+
// gasFee = 111 (mock), gasFeeUsed = 50 → 61 excess to refund.
204+
// Both the bridged funds revert AND the excess gas refund must happen.
204205
for i := 0; i < 3; i++ {
205206
valAddr, err := sdk.ValAddressFromBech32(coreVals[i].OperatorAddress)
206207
require.NoError(t, err)
@@ -216,7 +217,7 @@ func TestGasFeeRefund(t *testing.T) {
216217
outbound,
217218
false,
218219
"execution failed",
219-
"50", // gasFeeUsed provided but shouldn't trigger refund on failure
220+
"50", // gasFeeUsed=50 < gasFee=111 → 61 excess
220221
)
221222
require.NoError(t, err)
222223
}
@@ -227,12 +228,13 @@ func TestGasFeeRefund(t *testing.T) {
227228
ob := utx.OutboundTx[0]
228229
require.Equal(t, uexecutortypes.Status_REVERTED, ob.OutboundStatus)
229230

230-
// Revert was executed (funds minted back)
231+
// Revert: bridged funds minted back
231232
require.NotNil(t, ob.PcRevertExecution)
232233
require.Equal(t, "SUCCESS", ob.PcRevertExecution.Status)
233234

234-
// Gas refund must NOT run for failed outbounds
235-
require.Nil(t, ob.PcRefundExecution,
236-
"gas refund must not run when outbound failed")
235+
// Gas refund: excess gas must also be returned on failure
236+
require.NotNil(t, ob.PcRefundExecution,
237+
"excess gas must be refunded even when outbound failed")
238+
require.NotEmpty(t, ob.PcRefundExecution.Status)
237239
})
238240
}

test/integration/uexecutor/vote_outbound_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ func TestOutboundVoting(t *testing.T) {
240240
outbound,
241241
false,
242242
"execution reverted", // revert reason
243-
"",
243+
outbound.GasFee, // gas_fee_used required; use full fee → no excess refund
244244
)
245245
require.NoError(t, err)
246246
}
@@ -281,7 +281,7 @@ func TestOutboundVoting(t *testing.T) {
281281
outbound,
282282
false,
283283
"failed",
284-
"",
284+
outbound.GasFee, // gas_fee_used required; use full fee → no excess refund
285285
)
286286
require.NoError(t, err)
287287
}

x/uexecutor/keeper/outbound.go

Lines changed: 53 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -51,79 +51,86 @@ func (k Keeper) FinalizeOutbound(ctx context.Context, utxId string, outbound typ
5151
sdkCtx := sdk.UnwrapSDKContext(ctx)
5252

5353
if !obs.Success {
54-
return k.handleFailedOutbound(sdkCtx, utxId, outbound)
54+
return k.handleFailedOutbound(sdkCtx, utxId, outbound, obs)
5555
}
5656

5757
return k.handleSuccessfulOutbound(sdkCtx, utxId, outbound, obs)
5858
}
5959

60-
// handleFailedOutbound mints back the bridged tokens to the revert recipient.
61-
func (k Keeper) handleFailedOutbound(ctx sdk.Context, utxId string, outbound types.OutboundTx) error {
62-
// Only refund for funds-related tx types
63-
if outbound.TxType != types.TxType_FUNDS && outbound.TxType != types.TxType_GAS_AND_PAYLOAD &&
64-
outbound.TxType != types.TxType_FUNDS_AND_PAYLOAD {
65-
return nil
66-
}
60+
// handleFailedOutbound mints back the bridged tokens to the revert recipient,
61+
// then attempts to refund any excess gas (gasFee - gasFeeUsed) just like a
62+
// successful outbound would. Both operations are recorded on the outbound.
63+
func (k Keeper) handleFailedOutbound(ctx sdk.Context, utxId string, outbound types.OutboundTx, obs *types.OutboundObservation) error {
64+
// Only revert bridged funds for funds-related tx types
65+
if outbound.TxType == types.TxType_FUNDS || outbound.TxType == types.TxType_GAS_AND_PAYLOAD ||
66+
outbound.TxType == types.TxType_FUNDS_AND_PAYLOAD {
67+
68+
// Decide revert recipient safely
69+
recipient := outbound.Sender
70+
if outbound.RevertInstructions != nil &&
71+
outbound.RevertInstructions.FundRecipient != "" {
72+
recipient = outbound.RevertInstructions.FundRecipient
73+
}
6774

68-
// Decide refund recipient safely
69-
recipient := outbound.Sender
70-
if outbound.RevertInstructions != nil &&
71-
outbound.RevertInstructions.FundRecipient != "" {
72-
recipient = outbound.RevertInstructions.FundRecipient
73-
}
75+
amount := new(big.Int)
76+
amount, ok := amount.SetString(outbound.Amount, 10)
77+
if !ok {
78+
return fmt.Errorf("invalid amount: %s", outbound.Amount)
79+
}
80+
receipt, err := k.CallPRC20Deposit(ctx, common.HexToAddress(outbound.Prc20AssetAddr), common.HexToAddress(recipient), amount)
7481

75-
// Mint tokens back
76-
amount := new(big.Int)
77-
amount, ok := amount.SetString(outbound.Amount, 10)
78-
if !ok {
79-
return fmt.Errorf("invalid amount: %s", outbound.Amount)
82+
pcTx := types.PCTx{
83+
Sender: outbound.Sender,
84+
BlockHeight: uint64(ctx.BlockHeight()),
85+
}
86+
if err != nil {
87+
pcTx.Status = "FAILED"
88+
pcTx.ErrorMsg = err.Error()
89+
} else {
90+
pcTx.TxHash = receipt.Hash
91+
pcTx.GasUsed = receipt.GasUsed
92+
pcTx.Status = "SUCCESS"
93+
}
94+
outbound.PcRevertExecution = &pcTx
8095
}
81-
receipt, err := k.CallPRC20Deposit(ctx, common.HexToAddress(outbound.Prc20AssetAddr), common.HexToAddress(recipient), amount)
8296

83-
// Update outbound status
8497
outbound.OutboundStatus = types.Status_REVERTED
8598

86-
pcTx := types.PCTx{
87-
TxHash: "",
88-
Sender: outbound.Sender,
89-
GasUsed: 0,
90-
BlockHeight: uint64(ctx.BlockHeight()),
91-
}
92-
93-
if err != nil {
94-
pcTx.Status = "FAILED"
95-
pcTx.ErrorMsg = err.Error()
96-
} else {
97-
pcTx.TxHash = receipt.Hash
98-
pcTx.GasUsed = receipt.GasUsed
99-
pcTx.Status = "SUCCESS"
100-
}
101-
102-
outbound.PcRevertExecution = &pcTx
99+
// Refund excess gas regardless of tx type — gas was consumed on the external
100+
// chain whether the execution succeeded or failed.
101+
k.applyGasRefund(ctx, &outbound, obs)
103102

104103
return k.UpdateOutbound(ctx, utxId, outbound)
105104
}
106105

107106
// handleSuccessfulOutbound refunds unused gas fee when gasFee > gasFeeUsed.
108107
func (k Keeper) handleSuccessfulOutbound(ctx sdk.Context, utxId string, outbound types.OutboundTx, obs *types.OutboundObservation) error {
109-
// Only attempt refund if gas_fee_used is populated and gas token is known
108+
k.applyGasRefund(ctx, &outbound, obs)
109+
return k.UpdateOutbound(ctx, utxId, outbound)
110+
}
111+
112+
// applyGasRefund computes the excess gas (gasFee - gasFeeUsed) and, if positive,
113+
// calls UniversalCore refundUnusedGas. The result is recorded in outbound.PcRefundExecution.
114+
// It is called for both successful and failed outbounds — gas is consumed on the
115+
// external chain regardless of execution outcome.
116+
func (k Keeper) applyGasRefund(ctx sdk.Context, outbound *types.OutboundTx, obs *types.OutboundObservation) {
110117
if obs.GasFeeUsed == "" || outbound.GasFee == "" || outbound.GasToken == "" {
111-
return k.UpdateOutbound(ctx, utxId, outbound)
118+
return
112119
}
113120

114121
gasFee := new(big.Int)
115122
if _, ok := gasFee.SetString(outbound.GasFee, 10); !ok {
116-
return k.UpdateOutbound(ctx, utxId, outbound)
123+
return
117124
}
118125

119126
gasFeeUsed := new(big.Int)
120127
if _, ok := gasFeeUsed.SetString(obs.GasFeeUsed, 10); !ok {
121-
return k.UpdateOutbound(ctx, utxId, outbound)
128+
return
122129
}
123130

124-
// No refund needed
131+
// No excess gas to refund
125132
if gasFee.Cmp(gasFeeUsed) <= 0 {
126-
return k.UpdateOutbound(ctx, utxId, outbound)
133+
return
127134
}
128135

129136
refundAmount := new(big.Int).Sub(gasFee, gasFeeUsed)
@@ -144,7 +151,6 @@ func (k Keeper) handleSuccessfulOutbound(ctx sdk.Context, utxId string, outbound
144151
// Step 1: try refund with swap (gasToken → PC native)
145152
fee, swapErr := k.GetDefaultFeeTierForToken(ctx, gasToken)
146153
var swapFallbackReason string
147-
var receipt interface{ GetHash() string }
148154

149155
if swapErr == nil {
150156
quote, quoteErr := k.getSwapQuoteForRefund(ctx, gasToken, fee, refundAmount)
@@ -158,7 +164,7 @@ func (k Keeper) handleSuccessfulOutbound(ctx sdk.Context, utxId string, outbound
158164
refundPcTx.GasUsed = resp.GasUsed
159165
refundPcTx.Status = "SUCCESS"
160166
outbound.PcRefundExecution = refundPcTx
161-
return k.UpdateOutbound(ctx, utxId, outbound)
167+
return
162168
}
163169
swapFallbackReason = fmt.Sprintf("swap refund failed: %s", err.Error())
164170
} else {
@@ -168,10 +174,8 @@ func (k Keeper) handleSuccessfulOutbound(ctx sdk.Context, utxId string, outbound
168174
swapFallbackReason = fmt.Sprintf("fee tier fetch failed: %s", swapErr.Error())
169175
}
170176

171-
_ = receipt
172-
173177
// Step 2: fallback — refund without swap (deposit PRC20 directly to recipient)
174-
ctx.Logger().Error("FinalizeOutbound: swap refund failed, falling back to no-swap",
178+
ctx.Logger().Error("applyGasRefund: swap refund failed, falling back to no-swap",
175179
"outbound_id", outbound.Id,
176180
"reason", swapFallbackReason,
177181
)
@@ -188,8 +192,6 @@ func (k Keeper) handleSuccessfulOutbound(ctx sdk.Context, utxId string, outbound
188192

189193
outbound.PcRefundExecution = refundPcTx
190194
outbound.RefundSwapError = swapFallbackReason
191-
192-
return k.UpdateOutbound(ctx, utxId, outbound)
193195
}
194196

195197
// getSwapQuoteForRefund fetches a Uniswap quote for the gas token refund swap.

x/uexecutor/types/msg_vote_outbound.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,15 @@ func (msg *MsgVoteOutbound) ValidateBasic() error {
6868
// Validate observed_tx content
6969
obs := msg.ObservedTx
7070

71+
// gas_fee_used is always required — the external chain consumes gas regardless
72+
// of success or failure, and excess gas must be refundable in both cases.
73+
if strings.TrimSpace(obs.GasFeeUsed) == "" {
74+
return errors.Wrap(sdkerrors.ErrInvalidRequest,
75+
"observed_tx.gas_fee_used is required")
76+
}
77+
7178
if obs.Success {
72-
// Success requires tx_hash AND block_height > 0 AND gas_fee_used
79+
// Success additionally requires tx_hash and block_height.
7380
if strings.TrimSpace(obs.TxHash) == "" {
7481
return errors.Wrap(sdkerrors.ErrInvalidRequest,
7582
"observed_tx.tx_hash required when success=true")
@@ -78,14 +85,8 @@ func (msg *MsgVoteOutbound) ValidateBasic() error {
7885
return errors.Wrap(sdkerrors.ErrInvalidRequest,
7986
"observed_tx.block_height must be > 0 when success=true")
8087
}
81-
if strings.TrimSpace(obs.GasFeeUsed) == "" {
82-
return errors.Wrap(sdkerrors.ErrInvalidRequest,
83-
"observed_tx.gas_fee_used required when success=true")
84-
}
85-
8688
} else {
87-
// Failure case:
88-
// tx_hash MAY be empty.
89+
// Failure case: tx_hash MAY be empty.
8990
// BUT if tx_hash is present, block_height must be > 0.
9091
if strings.TrimSpace(obs.TxHash) != "" && obs.BlockHeight == 0 {
9192
return errors.Wrap(sdkerrors.ErrInvalidRequest,

0 commit comments

Comments
 (0)