@@ -137,10 +137,11 @@ func (m *mockVTXOStore) DeleteVTXO(context.Context, wire.OutPoint) error {
137137type fakeTxConfirmRef struct {
138138 mu sync.Mutex
139139
140- requests []* txconfirm.EnsureConfirmedReq
141- responseStates map [chainhash.Hash ]txconfirm.TxState
142- confirmHeights map [chainhash.Hash ]int32
143- failureReasons map [chainhash.Hash ]string
140+ requests []* txconfirm.EnsureConfirmedReq
141+ responseStates map [chainhash.Hash ]txconfirm.TxState
142+ confirmHeights map [chainhash.Hash ]int32
143+ failureReasons map [chainhash.Hash ]string
144+ definitelyNotBroadcast map [chainhash.Hash ]bool
144145
145146 // onAsk, when set, is invoked with each EnsureConfirmedReq as it is
146147 // recorded (outside the store lock). Tests use it to assert ordering
@@ -188,6 +189,8 @@ func (f *fakeTxConfirmRef) Ask(_ context.Context,
188189 f .requests = append (f .requests , req )
189190 state := f .responseStates [req .Tx .TxHash ()]
190191 height := f .confirmHeights [req .Tx .TxHash ()]
192+ definitelyNotBroadcast :=
193+ f .definitelyNotBroadcast [req .Tx .TxHash ()]
191194 onAsk := f .onAsk
192195 f .mu .Unlock ()
193196
@@ -226,9 +229,10 @@ func (f *fakeTxConfirmRef) Ask(_ context.Context,
226229 promise .Complete (
227230 fn.Ok [txconfirm.Resp ](
228231 & txconfirm.EnsureConfirmedResp {
229- Txid : req .Tx .TxHash (),
230- State : state ,
231- Created : true ,
232+ Txid : req .Tx .TxHash (),
233+ State : state ,
234+ Created : true ,
235+ DefinitelyNotBroadcast : definitelyNotBroadcast ,
232236 },
233237 ),
234238 )
@@ -343,6 +347,23 @@ func (f *fakeTxConfirmRef) setImmediateFailed(txid chainhash.Hash,
343347 f .failureReasons [txid ] = reason
344348}
345349
350+ // setImmediateDefiniteNoBroadcast configures one txid to fail before any
351+ // broadcast attempt crosses the chain boundary.
352+ func (f * fakeTxConfirmRef ) setImmediateDefiniteNoBroadcast (txid chainhash.Hash ,
353+ reason string ) {
354+
355+ f .setImmediateFailed (txid , reason )
356+
357+ f .mu .Lock ()
358+ defer f .mu .Unlock ()
359+
360+ if f .definitelyNotBroadcast == nil {
361+ f .definitelyNotBroadcast = make (map [chainhash.Hash ]bool )
362+ }
363+
364+ f .definitelyNotBroadcast [txid ] = true
365+ }
366+
346367// emitConfirmed delivers a txconfirm success notification to the subscriber.
347368func (f * fakeTxConfirmRef ) emitConfirmed (t * testing.T , index int ,
348369 txid chainhash.Hash , height int32 ) {
@@ -2965,6 +2986,35 @@ func TestProofTxFailureTransitionsToFailed(t *testing.T) {
29652986 " failed: txconfirm returned failed state" ,
29662987 checkpoint .Fail ,
29672988 )
2989+ require .True (t , checkpoint .ReliveUnsafe )
2990+ }
2991+
2992+ // TestDefiniteNoBroadcastClearsReliveGuard proves that an explicit local
2993+ // rejection may recover the VTXO while an ambiguous TxStateFailed response
2994+ // remains fail-closed.
2995+ func TestDefiniteNoBroadcastClearsReliveGuard (t * testing.T ) {
2996+ proof := buildLinearProof (t )
2997+ desc := testDescriptor (t , proof .TargetOutpoint (), proof .CSVDelay ())
2998+ rootTxid := proof .RootTxids ()[0 ]
2999+ unrollActor , _ , txconfirmRef , store := newActorHarness (t , proof , desc )
3000+ txconfirmRef .setImmediateDefiniteNoBroadcast (rootTxid , "rejected" )
3001+
3002+ mustAsk (t , unrollActor .Ref (), & StartUnrollRequest {
3003+ Height : 100 ,
3004+ Trigger : TriggerManual ,
3005+ })
3006+
3007+ require .Eventually (t , func () bool {
3008+ stateResp , ok := mustAsk (
3009+ t , unrollActor .Ref (), & GetStateRequest {},
3010+ ).(* GetStateResp )
3011+ require .True (t , ok )
3012+
3013+ return stateResp .Phase == PhaseFailed
3014+ }, testTimeout , 10 * time .Millisecond )
3015+
3016+ checkpoint := mustDecodeCheckpoint (t , store , "unroll-test" )
3017+ require .False (t , checkpoint .ReliveUnsafe )
29683018}
29693019
29703020// TestResumeReissuesSweepConfirmation verifies that resume reattaches
0 commit comments