Skip to content

Commit cc855f4

Browse files
committed
unroll: Make VTXO reliving evidence-based
Treat legacy started checkpoints and ambiguous broadcast failures as unsafe. Only an explicit no-broadcast proof may clear the relive guard, so a failed exit cannot restore a coin that may have crossed the chain boundary.
1 parent 3e610df commit cc855f4

11 files changed

Lines changed: 132 additions & 20 deletions

File tree

txconfirm/messages.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ type EnsureConfirmedResp struct {
171171
// Created is true when the request created a new tracking entry and
172172
// false when it attached to existing state.
173173
Created bool
174+
175+
// DefinitelyNotBroadcast is true only when the responder can prove
176+
// that no broadcast attempt crossed the chain boundary. Callers may
177+
// use this to distinguish a locally rejected request from an ambiguous
178+
// broadcast failure. The conservative default is false.
179+
DefinitelyNotBroadcast bool
174180
}
175181

176182
// MessageType returns the stable message type identifier.

unroll/AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/unroll.<
7171
`ExitOutcomeRecoverable` (roll back to live), a completed exit →
7272
`ExitOutcomeConfirmed` (retire to spent). The live actor sets
7373
`ReliveUnsafe` before every chain-boundary attempt; only objective
74-
canonical-absence evidence may clear it. A mere rejection, absence, or
75-
timeout therefore leaves the VTXO held in unilateral exit.
74+
canonical-absence evidence or an explicit `DefinitelyNotBroadcast`
75+
response may clear it. An ambiguous rejection, absence, or timeout
76+
therefore leaves the VTXO held in unilateral exit.
7677
`UnrollTerminatedMsg` carries `HadOnChainFootprint`, computed by
7778
`jobHadOnChainFootprint` (any confirmed/in-flight proof node or a
7879
non-pending sweep), and the durable `ReliveUnsafe` guard. It also carries

unroll/CLAUDE.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,9 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/unroll.<
7171
`ExitOutcomeRecoverable` (roll back to live), a completed exit →
7272
`ExitOutcomeConfirmed` (retire to spent). The live actor sets
7373
`ReliveUnsafe` before every chain-boundary attempt; only objective
74-
canonical-absence evidence may clear it. A mere rejection, absence, or
75-
timeout therefore leaves the VTXO held in unilateral exit.
74+
canonical-absence evidence or an explicit `DefinitelyNotBroadcast`
75+
response may clear it. An ambiguous rejection, absence, or timeout
76+
therefore leaves the VTXO held in unilateral exit.
7677
`UnrollTerminatedMsg` carries `HadOnChainFootprint`, computed by
7778
`jobHadOnChainFootprint` (any confirmed/in-flight proof node or a
7879
non-pending sweep), and the durable `ReliveUnsafe` guard. It also carries

unroll/actor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,6 +1002,8 @@ func (b *behavior) ensureNodeConfirmed(ctx context.Context,
10021002

10031003
return b.driveEvent(ctx, ax, &TxFailedEvent{
10041004
Txid: txid,
1005+
DefinitelyNotBroadcast: ensureResp.
1006+
DefinitelyNotBroadcast,
10051007
Reason: b.failureReasonForTx(
10061008
txid, "txconfirm returned failed state",
10071009
),

unroll/actor_test.go

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,11 @@ func (m *mockVTXOStore) DeleteVTXO(context.Context, wire.OutPoint) error {
137137
type 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.
347368
func (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

unroll/fsm_logic.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,6 +501,9 @@ func applyFailedEvent(job *JobState, event *TxFailedEvent) {
501501
job.DeferredCheckpoints = removeDeferredCheckpoint(
502502
job.DeferredCheckpoints, event.Txid,
503503
)
504+
if event.DefinitelyNotBroadcast {
505+
job.ReliveUnsafe = false
506+
}
504507

505508
job.FailReason = event.Reason
506509
}

unroll/fsm_types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,11 @@ type TxFailedEvent struct {
235235

236236
// Reason is the stable human-readable failure reason.
237237
Reason string
238+
239+
// DefinitelyNotBroadcast proves that the failed transaction never
240+
// crossed the chain boundary. Only this explicit evidence may clear
241+
// the fail-closed relive guard.
242+
DefinitelyNotBroadcast bool
238243
}
239244

240245
// eventSealed marks TxFailedEvent as an FSM event.

unroll/reorg_safety_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,12 @@ func (r *recordingRegistryRef) Tell(_ context.Context, msg RegistryMsg) error {
214214
return nil
215215
}
216216

217+
func (r *recordingRegistryRef) TryTell(ctx context.Context,
218+
msg RegistryMsg) error {
219+
220+
return r.Tell(ctx, msg)
221+
}
222+
217223
// terminatedCount returns how many UnrollTerminatedMsg messages have
218224
// been delivered so far.
219225
func (r *recordingRegistryRef) terminatedCount() int {

unroll/snapshot.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,11 @@ const (
121121
// recoverable failure.
122122
checkpointExternalSpendFinalizedRecordType tlv.Type = 27
123123

124-
// checkpointReliveUnsafeRecordType is optional; present (value 1)
125-
// before a job issues or reissues chain-boundary work. Until
126-
// authoritative negative evidence clears the guard, a later failure
127-
// must hold the VTXO in exit rather than relive it.
124+
// checkpointReliveUnsafeRecordType is present for every checkpoint
125+
// whose actor has started. A value of 1 means chain-boundary work may
126+
// have escaped; 0 records an explicit clear by authoritative negative
127+
// evidence. Started checkpoints written before this record existed
128+
// omit it and decode fail-closed as unsafe.
128129
checkpointReliveUnsafeRecordType tlv.Type = 29
129130
)
130131

@@ -329,8 +330,11 @@ func encodeCheckpoint(value *actorCheckpoint) ([]byte, error) {
329330
)
330331
}
331332

332-
if value.ReliveUnsafe {
333-
unsafe := uint8(1)
333+
if value.Started {
334+
unsafe := uint8(0)
335+
if value.ReliveUnsafe {
336+
unsafe = 1
337+
}
334338
records = append(
335339
records, tlv.MakePrimitiveRecord(
336340
checkpointReliveUnsafeRecordType, &unsafe,
@@ -514,6 +518,12 @@ func decodeCheckpoint(raw []byte) (*actorCheckpoint, error) {
514518

515519
if _, ok := parsed[checkpointReliveUnsafeRecordType]; ok {
516520
checkpoint.ReliveUnsafe = reliveUnsafe != 0
521+
} else if checkpoint.Started {
522+
// Checkpoints written before the guard was added have no
523+
// record. A started job may already have crossed a chain
524+
// boundary, so absence cannot be treated as evidence that
525+
// reliving is safe.
526+
checkpoint.ReliveUnsafe = true
517527
}
518528

519529
return checkpoint, nil

unroll/snapshot_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,33 @@ func TestCheckpointCodecVersionMismatch(t *testing.T) {
222222
require.ErrorContains(t, err, "unsupported checkpoint version")
223223
}
224224

225+
// TestCheckpointCodecLegacyStartedDefaultsReliveUnsafe proves an in-flight
226+
// checkpoint written before the relive guard existed cannot decode fail-open
227+
// at the upgrade boundary.
228+
func TestCheckpointCodecLegacyStartedDefaultsReliveUnsafe(t *testing.T) {
229+
checkpoint := &actorCheckpoint{
230+
Version: checkpointVersion,
231+
Started: true,
232+
Trigger: TriggerManual,
233+
}
234+
raw, err := encodeCheckpoint(checkpoint)
235+
require.NoError(t, err)
236+
237+
// Record type 29 is the final canonical record and uses one-byte TLV
238+
// type/length encodings. Removing it reproduces the pre-field wire
239+
// shape while retaining Started=true.
240+
reliveRecord := []byte{
241+
byte(checkpointReliveUnsafeRecordType), 1, 0,
242+
}
243+
require.True(t, bytes.HasSuffix(raw, reliveRecord))
244+
raw = raw[:len(raw)-len(reliveRecord)]
245+
246+
decoded, err := decodeCheckpoint(raw)
247+
require.NoError(t, err)
248+
require.True(t, decoded.Started)
249+
require.True(t, decoded.ReliveUnsafe)
250+
}
251+
225252
// TestCheckpointCodecCorruptDataRejected asserts that malformed input (empty,
226253
// truncated, random garbage) is rejected with an error rather than panicking
227254
// or returning a zero-value checkpoint.

0 commit comments

Comments
 (0)