-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathspend_actor.go
More file actions
693 lines (582 loc) · 20.4 KB
/
Copy pathspend_actor.go
File metadata and controls
693 lines (582 loc) · 20.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
package chainsource
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/btcsuite/btcd/chainhash/v2"
"github.com/btcsuite/btcd/wire/v2"
"github.com/btcsuite/btclog/v2"
"github.com/lightninglabs/wavelength/baselib/actor"
"github.com/lightninglabs/wavelength/build"
fn "github.com/lightningnetwork/lnd/fn/v2"
)
// SpendActorConfig holds configuration for SpendActor.
type SpendActorConfig struct {
// Backend is the blockchain backend used to monitor spends.
Backend ChainBackend
// Log is an optional logger for this actor instance. If None, the actor
// falls back to extracting a logger from context via LoggerFromContext,
// or uses btclog.Disabled if no logger is found.
Log fn.Option[btclog.Logger]
// FinalityDepth is the number of confirmations past the first
// observed Spend event that the actor uses to synthesize a Done
// signal when the backend cannot deliver one. See the matching
// field on ConfActorConfig for the rationale; the same constraint
// applies on the spend watch — lnd's chainntnfs.SpendEvent.Done
// does not survive the lndclient gRPC transport, so consumers
// that gate eviction on Done would otherwise leak per-spend state.
FinalityDepth uint32
}
// WithLogger returns a new config with the given logger set.
func (c SpendActorConfig) WithLogger(log btclog.Logger) SpendActorConfig {
c.Log = fn.Some(log)
return c
}
// SpendActor is a single-subscription actor that monitors outpoint spends and
// delivers events when outputs are consumed by confirmed transactions. Each
// instance serves exactly one subscription.
//
// The actor supports dual-mode operation: Future mode for blocking await
// (exits after first event), and Actor mode for asynchronous event delivery
// (continues monitoring for re-orgs).
type SpendActor struct {
// cfg holds all actor configuration including backend and optional
// logger.
cfg SpendActorConfig
// outpoint is the output being monitored.
outpoint *wire.OutPoint
// pkScript is the public key script being monitored.
pkScript []byte
// heightHint is the earliest block that could contain a spending tx.
heightHint uint32
// promise is used in Future mode to complete the future when the spend
// is detected.
promise fn.Option[actor.Promise[SpendEvent]]
// notifyActor is used in Actor mode to send events. None in Future
// mode.
notifyActor fn.Option[actor.TellOnlyRef[SpendEvent]]
// notifyReorged receives spend reorg events in Actor mode.
notifyReorged fn.Option[actor.TellOnlyRef[SpendReorgedEvent]]
// notifyDone receives spend finality events in Actor mode.
notifyDone fn.Option[actor.TellOnlyRef[SpendDoneEvent]]
// registration is the backend registration for this watch.
registration *SpendRegistration
// blockReg is the block-epoch subscription used by height-based
// finality synthesis. Allocated lazily after the first Spend
// event when FinalityDepth > 0, torn down when the actor exits.
blockReg *BlockRegistration
// spendHeight records the block height the most recent Spend
// event arrived at. Zero means there is no active spend to count
// from (either we have not yet seen one, or the last one was
// reorged out).
spendHeight int32
// ctx is the actor's internal context for cancellation, created from
// context.Background() to ensure it outlives any request context.
//nolint:containedctx
ctx context.Context
// cancel cancels the actor's context.
cancel context.CancelFunc
// wg tracks background goroutines for graceful shutdown.
wg sync.WaitGroup
}
// logger returns the configured logger or falls back to extracting from
// context. If no logger is found in either location, returns btclog.Disabled.
func (a *SpendActor) logger(ctx context.Context) btclog.Logger {
return a.cfg.Log.UnwrapOr(build.LoggerFromContext(ctx))
}
// NewSpendActor creates a new SpendActor instance with the given configuration.
// The config must include Backend; use WithLogger() to inject a specific
// logger.
func NewSpendActor(cfg SpendActorConfig) *SpendActor {
// Use background context for internal cancellation since the actor
// needs to outlive any request context. Logger is passed via config.
ctx, cancel := context.WithCancel(context.Background())
return &SpendActor{
cfg: cfg,
ctx: ctx,
cancel: cancel,
}
}
// Receive processes incoming messages for the SpendActor.
func (a *SpendActor) Receive(actorCtx context.Context,
msg SpendMsg) fn.Result[SpendResp] {
switch m := msg.(type) {
case *RegisterSpendRequest:
return a.handleRegisterSpend(actorCtx, m)
default:
return fn.Err[SpendResp](
fmt.Errorf("unknown message type: %T", msg),
)
}
}
// handleRegisterSpend processes a spend registration request. It configures
// the actor and starts monitoring.
func (a *SpendActor) handleRegisterSpend(actorCtx context.Context,
req *RegisterSpendRequest) fn.Result[SpendResp] {
// Each SpendActor instance serves exactly one subscription. Reject
// duplicate registrations.
if a.registration != nil {
return fn.Err[SpendResp](
fmt.Errorf("actor already has an active subscription"),
)
}
// Validate request parameters.
if req.Outpoint == nil && len(req.PkScript) == 0 {
return fn.Err[SpendResp](
fmt.Errorf("either outpoint or pkScript must be " +
"provided"),
)
}
if req.NotifyActor.IsNone() &&
(req.NotifyReorged.IsSome() || req.NotifyDone.IsSome()) {
return fn.Err[SpendResp](
fmt.Errorf("spend reorg/done notifications require " +
"actor-mode NotifyActor"),
)
}
// Configure the actor with request parameters.
a.outpoint = req.Outpoint
a.pkScript = req.PkScript
a.heightHint = req.HeightHint
a.notifyActor = req.NotifyActor
a.notifyReorged = req.NotifyReorged
a.notifyDone = req.NotifyDone
// Create promise for Future mode.
var promise fn.Option[actor.Promise[SpendEvent]]
if req.NotifyActor.IsNone() {
// Future mode: create a promise.
promise = fn.Some(actor.NewPromise[SpendEvent]())
} else {
// Actor mode: no promise needed.
promise = fn.None[actor.Promise[SpendEvent]]()
}
a.promise = promise
// Register with the backend to receive spend notifications. We do this
// before starting the goroutine so we can return an error to the
// caller if registration fails. The bounded timeout mirrors
// ConfActor.handleRegisterConf: a backend (LND) that is slow under
// heavy block processing load must not pin the parent Receive call
// indefinitely, since that would back-pressure the chainsource
// factory actor onto every other in-flight registration.
regCtx, regCancel := context.WithTimeout(a.ctx, 10*time.Second)
defer regCancel()
//nolint:contextcheck // actor root context owns registration lifetime
registration, err := a.cfg.Backend.RegisterSpend(
regCtx, a.outpoint, a.pkScript, a.heightHint,
)
if err != nil {
return fn.Err[SpendResp](
fmt.Errorf("failed to register for spends: %w", err),
)
}
a.registration = registration
// Start monitoring in background.
a.wg.Add(1)
go a.monitorSpend()
// Build response.
resp := &RegisterSpendResponse{}
// Add Future for blocking mode.
promise.WhenSome(func(p actor.Promise[SpendEvent]) {
resp.Future = p.Future()
})
return fn.Ok[SpendResp](resp)
}
// monitorSpend runs in a background goroutine and waits for spend events from
// the backend. It continues monitoring to handle re-orgs until cancelled or
// (for Future mode) the first event is delivered.
func (a *SpendActor) monitorSpend() {
defer a.wg.Done()
defer a.cancel()
// Clean up registration when done.
defer func() {
if a.registration != nil {
a.registration.Cancel()
}
if a.blockReg != nil {
a.blockReg.Cancel()
}
}()
log := a.logger(a.ctx)
// Monitor for spends indefinitely until cancelled or shutdown.
// This allows us to catch re-org events where a spend is replaced.
var lastEvent *SpendEvent
var doneOrder PositiveDoneOrder
// reorgAware reports whether the caller opted into the multi-shot
// reorg lifecycle (at least one of NotifyReorged/NotifyDone). When
// false the watch is single-shot for backwards compatibility: the
// actor exits after the first spend, mirroring ConfActor. Without
// this gate a plain actor-mode spend watch would run forever and, with
// FinalityDepth > 0, arm a block subscription it never asked for.
reorgAware := a.notifyReorged.IsSome() || a.notifyDone.IsSome()
// lastSeq is the highest backend forwarder sequence applied so far.
// Spend and Reorged signals arrive on separate channels and a select
// cannot order two ready channels, so we order them by the shared
// sequence instead: an event whose Seq does not exceed lastSeq lost a
// cross-channel race to a newer signal and is discarded. This makes
// the actor's view correct regardless of delivery interleaving. Seq 0
// means the backend does not stamp sequences (it never reorgs); those
// events are always applied.
var lastSeq uint64
// blockEpochs is rebound when height-based finality synthesis
// arms a block subscription. Until then a nil channel keeps the
// select arm parked.
var blockEpochs <-chan *BlockEpoch
// blockRegCh hands a finality block subscription from the off-loop
// arming goroutine back to this loop; arming guards against launching
// more than one armer at a time. See armFinalityAsync for why arming
// runs off the select loop.
blockRegCh := make(chan finalityArmResult)
var arming bool
for {
select {
case spend, ok := <-a.registration.Spend:
if !ok || spend == nil {
a.failSpend(
errors.New("spend subscription closed"),
)
return
}
// Discard a spend that lost a cross-channel race to a
// newer reorg: an event whose sequence does not exceed
// the highest applied is stale.
if spend.Seq != 0 && spend.Seq <= lastSeq {
continue
}
if spend.Seq > lastSeq {
lastSeq = spend.Seq
}
event, err := buildSpendEvent(spend, a)
if err != nil {
a.failSpend(err)
return
}
// Deliver the event.
a.deliverSpend(event)
lastEvent = &event
a.spendHeight = event.SpendingHeight
// Exit after the first event in Future mode, and in
// actor mode that did not opt into the reorg lifecycle
// (single-shot backwards-compatible contract). Only a
// reorg-aware actor watch keeps monitoring.
if !reorgAware || a.promise.IsSome() {
return
}
if doneOrder.ObservePositive() {
a.deliverSpendDone(lastEvent)
return
}
// Arm height-based finality synthesis on the first
// spend if requested, off the select loop so the
// bounded RegisterBlocks retries cannot stall delivery
// of Reorged/Done/ctx.Done on this watch. A nil
// blockEpochs channel keeps the synthesis arm parked
// until the registration is handed back on blockRegCh.
if a.cfg.FinalityDepth > 0 && a.blockReg == nil &&
!arming {
arming = true
a.armFinalityAsync(blockRegCh, log)
}
case armed := <-blockRegCh:
// Finality arming completed. Clear the flag; a nil reg
// only happens when the watch context was cancelled
// (arming otherwise retries until it succeeds).
arming = false
if armed.reg == nil {
continue
}
a.blockReg = armed.reg
blockEpochs = armed.reg.Epochs
// The block-epoch subscription only delivers FUTURE
// epochs, but the spend that armed it may already be
// buried past FinalityDepth (it confirmed several
// blocks ago, or we re-armed after a restart). Use the
// tip observed at arm time to synthesize Done at once
// rather than hang until a fresh block is mined. The
// spendHeight==0 / FinalityDepth==0 guards mirror the
// epoch handler below.
if a.spendHeight == 0 || a.cfg.FinalityDepth == 0 {
continue
}
if armed.height-a.spendHeight+1 <
int32(a.cfg.FinalityDepth) {
continue
}
log.InfoS(a.ctx, "Synthesizing spend done on arm from "+
"height-based safety depth",
"spend_height", a.spendHeight,
"current_height", armed.height,
"finality_depth", int(a.cfg.FinalityDepth),
)
a.deliverSpendDone(lastEvent)
return
case seq, ok := <-a.registration.Reorged:
if !ok {
a.registration.Reorged = nil
continue
}
// Discard a stale reorg that lost a cross-channel race
// to a newer spend.
if seq != 0 && seq <= lastSeq {
continue
}
if seq > lastSeq {
lastSeq = seq
}
a.deliverSpendReorged(lastEvent)
// The previous spend is no longer on the canonical
// chain. Clear the cached event so a later Done cannot
// report the reorged-out outpoint, and reset the depth
// counter so the next re-spend starts a fresh window.
lastEvent = nil
a.spendHeight = 0
doneOrder.ObserveReorg()
case _, ok := <-a.registration.Done:
if !ok {
a.registration.Done = nil
continue
}
if !doneOrder.ObserveDone() {
// Done is retained; wait for spender identity.
a.registration.Done = nil
continue
}
a.deliverSpendDone(lastEvent)
return
case epoch, ok := <-blockEpochs:
if !ok || epoch == nil {
a.clearFinalityRegistration()
blockEpochs = nil
if a.spendHeight != 0 &&
a.cfg.FinalityDepth > 0 && !arming {
arming = true
a.armFinalityAsync(blockRegCh, log)
}
continue
}
// Coalesce any epochs already queued behind this one
// and evaluate finality against the most recent height
// only. With rapid-fire blocks the channel can hold
// several epochs at once; processing them one per loop
// iteration would re-check the same monotonic Done
// condition repeatedly and risk synthesizing against a
// stale height. If the channel closed during the drain,
// park it so we stop selecting on it.
var closed bool
epoch, closed = drainToLatestEpoch(blockEpochs, epoch)
if closed {
a.clearFinalityRegistration()
blockEpochs = nil
}
// The spendHeight==0 guard is load-bearing: a reorg
// resets spendHeight to 0 (the Reorged arm above), so
// a fresh epoch arriving before the re-spend would
// otherwise compute depth against a zero base and
// could synthesize Done prematurely. While
// spendHeight==0 there is no active spend to count
// from, so the depth comparison is meaningless.
// FinalityDepth==0 disables synthesis entirely.
if a.spendHeight == 0 ||
a.cfg.FinalityDepth == 0 {
continue
}
depth := epoch.Height - a.spendHeight + 1
if depth < int32(a.cfg.FinalityDepth) {
if closed && !arming {
arming = true
a.armFinalityAsync(blockRegCh, log)
}
continue
}
log.InfoS(a.ctx, "Synthesizing spend done from "+
"height-based safety depth",
"spend_height", a.spendHeight,
"current_height", epoch.Height,
"finality_depth", int(a.cfg.FinalityDepth),
)
a.deliverSpendDone(lastEvent)
return
case <-a.ctx.Done():
// Actor was cancelled.
a.failSpend(a.ctx.Err())
return
}
}
}
// clearFinalityRegistration cancels and forgets a dead block-epoch
// registration so the monitor loop can arm a replacement.
func (a *SpendActor) clearFinalityRegistration() {
if a.blockReg == nil {
return
}
a.blockReg.Cancel()
a.blockReg = nil
}
// armFinalityAsync registers a block-epoch subscription for height-based
// finality synthesis off the actor's select loop. registerBlocksForFinality
// retries with a bounded backoff that can run for tens of seconds; doing it
// inline would block delivery of Reorged/Done/ctx.Done on this watch for the
// whole window. The registration (or nil on failure) is handed back on regCh,
// or cancelled if the actor exits before the loop reads it. The goroutine is
// tracked by the actor's wait group so Stop drains it.
func (a *SpendActor) armFinalityAsync(regCh chan<- finalityArmResult,
log btclog.Logger) {
a.wg.Go(func() {
reg, err := registerBlocksForFinality(a.ctx, a.cfg.Backend, log)
if err != nil {
log.WarnS(a.ctx, "Giving up on height-based finality "+
"synthesis; spend sub-actor will rely on "+
"backend Done", err)
reg = nil
}
// Capture the tip at arm time so the loop can finalize
// immediately when the arming spend is already buried past
// FinalityDepth (the block-epoch sub only delivers future
// epochs). A read failure is non-fatal: height stays zero and
// the loop falls back to waiting for the next epoch.
var height int32
if reg != nil {
h, _, hErr := a.cfg.Backend.BestBlock(a.ctx)
if hErr != nil {
log.WarnS(a.ctx, "Failed to read best height "+
"for on-arm finality check; will wait "+
"for next epoch", hErr)
} else {
height = h
}
}
select {
case regCh <- finalityArmResult{reg: reg, height: height}:
case <-a.ctx.Done():
if reg != nil {
reg.Cancel()
}
}
})
}
// deliverSpend delivers a spend event to the subscriber. In Future mode, it
// completes the promise. In Actor mode, it sends to the registered actor.
func (a *SpendActor) deliverSpend(event SpendEvent) {
a.promise.WhenSome(func(p actor.Promise[SpendEvent]) {
// Future mode: complete the promise.
p.Complete(fn.Ok(event))
})
a.notifyActor.WhenSome(func(ref actor.TellOnlyRef[SpendEvent]) {
log := a.logger(a.ctx)
// Actor mode: send to the registered actor.
if err := ref.Tell(a.ctx, event); err != nil {
log.WarnS(a.ctx, "Failed to deliver spend event", err)
}
})
}
// deliverSpendReorged delivers a spend reorg event to actor-mode
// subscribers. The correlation Outpoint is the registration's configured
// outpoint when set, since that is the identifier the caller asked us to
// watch; pkScript-only watches fall back to the outpoint carried on the
// most recent positive SpendEvent.
func (a *SpendActor) deliverSpendReorged(lastEvent *SpendEvent) {
var event SpendReorgedEvent
switch {
case a.outpoint != nil:
event.Outpoint = *a.outpoint
case lastEvent != nil:
event.Outpoint = lastEvent.Outpoint
}
a.notifyReorged.WhenSome(
func(ref actor.TellOnlyRef[SpendReorgedEvent]) {
log := a.logger(a.ctx)
if err := ref.Tell(a.ctx, event); err != nil {
log.WarnS(
a.ctx,
"Failed to deliver spend reorg",
err,
)
}
},
)
}
// deliverSpendDone delivers a spend finality event to actor-mode subscribers.
// Outpoint follows the same precedence as deliverSpendReorged.
func (a *SpendActor) deliverSpendDone(lastEvent *SpendEvent) {
var event SpendDoneEvent
switch {
case a.outpoint != nil:
event.Outpoint = *a.outpoint
case lastEvent != nil:
event.Outpoint = lastEvent.Outpoint
}
a.notifyDone.WhenSome(func(ref actor.TellOnlyRef[SpendDoneEvent]) {
log := a.logger(a.ctx)
if err := ref.Tell(a.ctx, event); err != nil {
log.WarnS(a.ctx, "Failed to deliver spend done", err)
}
})
}
// failSpend completes the promise with an error (Future mode) or does nothing
// (Actor mode - errors are not delivered in async mode).
func (a *SpendActor) failSpend(err error) {
a.promise.WhenSome(func(p actor.Promise[SpendEvent]) {
p.Complete(fn.Err[SpendEvent](err))
})
}
// Stop gracefully shuts down the SpendActor. It cancels the context and waits
// for the monitoring goroutine to complete.
func (a *SpendActor) Stop() {
// Cancel the context to signal shutdown.
a.cancel()
// Wait for the monitoring goroutine to complete.
a.wg.Wait()
}
// OnStop implements actor.Stoppable for proper cleanup when stopped via actor
// system. This is called after the actor's message loop exits.
func (a *SpendActor) OnStop(ctx context.Context) error {
// Cancel internal context to signal background goroutine.
a.cancel()
// Wait for goroutine with timeout from cleanup context.
done := make(chan struct{})
go func() {
a.wg.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// buildSpendEvent converts the backend SpendDetail into a SpendEvent,
// filling in missing fields where possible.
func buildSpendEvent(spend *SpendDetail,
watch *SpendActor) (SpendEvent, error) {
spenderHash, err := spendTxHash(spend)
if err != nil {
return SpendEvent{}, err
}
event := SpendEvent{
SpendingTxid: spenderHash,
SpendingTx: spend.SpendingTx,
SpenderInputIndex: spend.SpenderInputIndex,
SpendingHeight: spend.SpendingHeight,
}
if spend.SpentOutPoint != nil {
event.Outpoint = *spend.SpentOutPoint
} else if watch.outpoint != nil {
event.Outpoint = *watch.outpoint
}
return event, nil
}
// spendTxHash determines the spending transaction hash, falling back to the
// transaction contents when needed.
func spendTxHash(spend *SpendDetail) (chainhash.Hash, error) {
switch {
case spend.SpenderTxHash != nil:
return *spend.SpenderTxHash, nil
case spend.SpendingTx != nil:
return spend.SpendingTx.TxHash(), nil
default:
return chainhash.Hash{}, fmt.Errorf("spend event missing " +
"transaction hash")
}
}