@@ -54,10 +54,10 @@ func TestMonitorInvoiceAndHtlcTxReRegistersOnConfErr(t *testing.T) {
5454 loopIn .SetState (MonitorInvoiceAndHtlcTx )
5555
5656 // Seed the mock invoice store so LookupInvoice succeeds.
57- mockLnd .Invoices [ swapHash ] = & lndclient.Invoice {
57+ mockLnd .SetInvoice ( & lndclient.Invoice {
5858 Hash : swapHash ,
5959 State : invoices .ContractOpen ,
60- }
60+ })
6161
6262 cfg := & Config {
6363 AddressManager : & mockAddressManager {
@@ -270,6 +270,133 @@ func testValidateLoopInContract(_ int32, _ int32) error {
270270 return nil
271271}
272272
273+ // TestOriginalDepositOutpointUnavailableRequiresMissingTxOut verifies that a
274+ // present txout does not trigger the RBF cancellation path.
275+ func TestOriginalDepositOutpointUnavailableRequiresMissingTxOut (t * testing.T ) {
276+ originalOutpoint := wire.OutPoint {
277+ Hash : chainhash.Hash {1 },
278+ Index : 0 ,
279+ }
280+
281+ txOutChecker := & testTxOutChecker {
282+ txOut : & wire.TxOut {Value : 10_000 },
283+ }
284+ f := & FSM {
285+ cfg : & Config {
286+ TxOutChecker : txOutChecker ,
287+ },
288+ loopIn : & StaticAddressLoopIn {
289+ DepositOutpoints : []string {originalOutpoint .String ()},
290+ },
291+ }
292+
293+ unavailable , err := f .originalDepositOutpointUnavailable (t .Context ())
294+ require .NoError (t , err )
295+ require .False (t , unavailable )
296+ require .Equal (t , []wire.OutPoint {originalOutpoint }, txOutChecker .outpoints )
297+ require .Equal (t , []bool {true }, txOutChecker .includeMempool )
298+ }
299+
300+ // TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable verifies that a
301+ // pending loop-in is canceled before HTLC signing if GetTxOut with mempool
302+ // awareness reports that one of the originally selected outpoints is gone.
303+ func TestSignHtlcTxActionCancelsWhenOriginalOutpointUnavailable (t * testing.T ) {
304+ ctx , cancel := context .WithTimeout (t .Context (), 5 * time .Second )
305+ defer cancel ()
306+
307+ mockLnd := test .NewMockLnd ()
308+
309+ swapHash := lntypes.Hash {9 , 8 , 7 }
310+ originalOutpoint := wire.OutPoint {
311+ Hash : chainhash.Hash {1 },
312+ Index : 0 ,
313+ }
314+
315+ loopIn := & StaticAddressLoopIn {
316+ SwapHash : swapHash ,
317+ DepositOutpoints : []string {originalOutpoint .String ()},
318+ }
319+
320+ txOutChecker := & testTxOutChecker {}
321+ cfg := & Config {
322+ AddressManager : & mockAddressManager {
323+ params : & script.Parameters {
324+ ProtocolVersion : version .ProtocolVersion_V0 ,
325+ },
326+ },
327+ InvoicesClient : mockLnd .LndServices .Invoices ,
328+ TxOutChecker : txOutChecker ,
329+ }
330+
331+ f , err := NewFSM (ctx , loopIn , cfg , false )
332+ require .NoError (t , err )
333+
334+ event := f .SignHtlcTxAction (ctx , nil )
335+ require .Equal (t , fsm .OnError , event )
336+ require .ErrorContains (
337+ t , f .LastActionError , "original deposit outpoint no longer available" ,
338+ )
339+
340+ select {
341+ case hash := <- mockLnd .FailInvoiceChannel :
342+ require .Equal (t , swapHash , hash )
343+ case <- ctx .Done ():
344+ t .Fatalf ("invoice was not canceled: %v" , ctx .Err ())
345+ }
346+
347+ require .Equal (t , []wire.OutPoint {originalOutpoint }, txOutChecker .outpoints )
348+ require .Equal (t , []bool {true }, txOutChecker .includeMempool )
349+ }
350+
351+ // TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError verifies that lookup
352+ // failures are treated as errors, but do not cancel the invoice. The invoice is
353+ // only canceled when GetTxOut explicitly returns nil for an original outpoint.
354+ func TestSignHtlcTxActionDoesNotCancelOnTxOutLookupError (t * testing.T ) {
355+ ctx , cancel := context .WithTimeout (t .Context (), 5 * time .Second )
356+ defer cancel ()
357+
358+ mockLnd := test .NewMockLnd ()
359+
360+ swapHash := lntypes.Hash {9 , 8 , 6 }
361+ originalOutpoint := wire.OutPoint {
362+ Hash : chainhash.Hash {3 },
363+ Index : 0 ,
364+ }
365+
366+ loopIn := & StaticAddressLoopIn {
367+ SwapHash : swapHash ,
368+ DepositOutpoints : []string {originalOutpoint .String ()},
369+ }
370+
371+ txOutChecker := & testTxOutChecker {
372+ err : errors .New ("backend unavailable" ),
373+ }
374+ cfg := & Config {
375+ AddressManager : & mockAddressManager {
376+ params : & script.Parameters {
377+ ProtocolVersion : version .ProtocolVersion_V0 ,
378+ },
379+ },
380+ InvoicesClient : mockLnd .LndServices .Invoices ,
381+ TxOutChecker : txOutChecker ,
382+ }
383+
384+ f , err := NewFSM (ctx , loopIn , cfg , false )
385+ require .NoError (t , err )
386+
387+ event := f .SignHtlcTxAction (ctx , nil )
388+ require .Equal (t , fsm .OnError , event )
389+ require .ErrorContains (
390+ t , f .LastActionError , "unable to get txout" ,
391+ )
392+
393+ select {
394+ case hash := <- mockLnd .FailInvoiceChannel :
395+ t .Fatalf ("invoice should not have been canceled: %x" , hash )
396+ default :
397+ }
398+ }
399+
273400// TestInitHtlcActionCancelsInvoiceOnServerError verifies that an invoice
274401// created before a server-side rejection is canceled immediately.
275402func TestInitHtlcActionCancelsInvoiceOnServerError (t * testing.T ) {
@@ -541,6 +668,24 @@ func (r *recordingDepositManager) TransitionDeposits(_ context.Context,
541668 return r .err
542669}
543670
671+ type testTxOutChecker struct {
672+ txOut * wire.TxOut
673+ err error
674+
675+ outpoints []wire.OutPoint
676+ includeMempool []bool
677+ }
678+
679+ // GetTxOut records lookup parameters and returns the configured result.
680+ func (t * testTxOutChecker ) GetTxOut (_ context.Context ,
681+ outpoint wire.OutPoint , includeMempool bool ) (* wire.TxOut , error ) {
682+
683+ t .outpoints = append (t .outpoints , outpoint )
684+ t .includeMempool = append (t .includeMempool , includeMempool )
685+
686+ return t .txOut , t .err
687+ }
688+
544689// initHtlcTestServer lets InitHtlcAction tests inject a deterministic server
545690// response without standing up the full gRPC client.
546691type initHtlcTestServer struct {
0 commit comments