-
Notifications
You must be signed in to change notification settings - Fork 78
Expand file tree
/
Copy pathengine.go
More file actions
6491 lines (5776 loc) · 237 KB
/
Copy pathengine.go
File metadata and controls
6491 lines (5776 loc) · 237 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
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// The core package that manage and distribute and execute task
package taskengine
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/AvaProtocol/EigenLayer-AVS/core/apqueue"
"github.com/AvaProtocol/EigenLayer-AVS/core/chainio/aa"
"github.com/AvaProtocol/EigenLayer-AVS/core/config"
"github.com/AvaProtocol/EigenLayer-AVS/model"
"github.com/AvaProtocol/EigenLayer-AVS/pkg/gow"
"github.com/AvaProtocol/EigenLayer-AVS/storage"
sdklogging "github.com/Layr-Labs/eigensdk-go/logging"
"github.com/allegro/bigcache/v3"
badger "github.com/dgraph-io/badger/v4"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/math"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/oklog/ulid/v2"
"golang.org/x/sync/singleflight"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/types/known/structpb"
avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf"
)
// getTaskStatusString safely converts a TaskStatus to string, handling edge cases
func getTaskStatusString(status avsproto.TaskStatus) string {
// The crash was caused by calling .String() on an uninitialized enum
// This function provides a safe wrapper that ensures we always get a valid string
return status.String()
}
const (
JobTypeExecuteTask = "execute_task"
DefaultLimit = 50
MaxSecretNameLength = 255
EvmErc20TransferTopic0 = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
)
var (
rpcConn *ethclient.Client
// websocket client used for subscription
wsEthClient *ethclient.Client
wsRpcURL string
globalLogger sdklogging.Logger
// a global variable that we expose to our tasks. User can use `{{name}}` to access them
// These macro are define in our aggregator yaml config file under `macros`
macroVars map[string]string
macroSecrets map[string]string
cache *bigcache.BigCache
// Global token enrichment service for shared token metadata and chain detection
globalTokenService *TokenEnrichmentService
defaultSalt = big.NewInt(0)
)
// Set a global logger for task engine
func SetLogger(mylogger sdklogging.Logger) {
globalLogger = mylogger
}
// Set the global macro system. macros are static, immutable and available to all tasks at runtime
func SetMacroVars(v map[string]string) {
macroVars = v
}
func SetMacroSecrets(v map[string]string) {
macroSecrets = v
}
// GetMacroSecret returns a secret from macros.secrets loaded into the engine.
// Returns empty string if the key is not set or macros were not initialized.
func GetMacroSecret(key string) string {
if macroSecrets == nil || key == "" {
return ""
}
return macroSecrets[key]
}
func SetCache(c *bigcache.BigCache) {
cache = c
}
// SetTokenEnrichmentService sets the global token enrichment service used as
// the engine's default (single-chain mode, or the gateway's primary chain).
// Also adds the service to the chain-keyed registry so chain-aware callers
// can resolve it via GetTokenEnrichmentServiceForChain — this keeps the
// single-chain path covered without forcing every caller to register
// explicitly.
func SetTokenEnrichmentService(service *TokenEnrichmentService) {
globalTokenService = service
RegisterTokenEnrichmentService(service)
}
// GetTokenEnrichmentService returns the global token enrichment service
func GetTokenEnrichmentService() *TokenEnrichmentService {
return globalTokenService
}
// Initialize a shared rpc client instance
func SetRpc(rpcURL string) {
// Skip RPC initialization for test URLs to avoid external dependencies in CI
if strings.Contains(rpcURL, "localhost") || strings.Contains(rpcURL, "127.0.0.1") || strings.Contains(rpcURL, "mock") {
// For test environments, set rpcConn to nil and continue
// This allows tests to run without external RPC dependencies
rpcConn = nil
return
}
// Enhanced error handling with circuit breaker pattern
if err := rpcCallWithCircuitBreaker(func() error {
conn, err := ethclient.Dial(rpcURL)
if err != nil {
return err
}
rpcConn = conn
return nil
}, "HTTP_RPC"); err != nil {
// In CI environment, if RPC connection fails, set to nil instead of panicking
// This allows tests to run without external dependencies
if os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" {
fmt.Printf("CI environment detected: Setting rpcConn to nil due to RPC connection failure: %v", err)
rpcConn = nil
return
}
// Log error and report to Sentry
fmt.Printf("Failed to initialize HTTP RPC connection: %v", err)
enhancedPanicRecovery("rpc_connection", "SetRpc", map[string]interface{}{
"connection_type": "HTTP",
"url": rpcURL,
})
panic(fmt.Errorf("HTTP RPC connection failed: %w", err))
}
}
// Initialize a shared websocket rpc client instance
func SetWsRpc(rpcURL string) {
wsRpcURL = rpcURL
// Enhanced error handling with circuit breaker pattern
if err := rpcCallWithCircuitBreaker(retryWsRpc, "WS_RPC"); err != nil {
// Log error instead of panic for better resilience
fmt.Printf("Failed to initialize WebSocket RPC connection: %v", err)
// Report to Sentry
enhancedPanicRecovery("ws_rpc_connection", "SetWsRpc", map[string]interface{}{
"connection_type": "WebSocket",
"url": rpcURL,
})
panic(fmt.Errorf("WebSocket RPC connection failed: %w", err))
}
}
func retryWsRpc() error {
for {
conn, err := ethclient.Dial(wsRpcURL)
if err == nil {
wsEthClient = conn
return nil
}
globalLogger.Errorf("cannot establish websocket client for RPC, retry in 15 seconds", "err", err)
time.Sleep(15 * time.Second)
}
}
// Minimum interval between successive connections from the same operator.
// Connections arriving faster than this are rejected with ResourceExhausted.
const operatorReconnectCooldown = 5 * time.Second
type operatorState struct {
// list of task id that we had synced to this operator
TaskID map[string]bool
MonotonicClock int64
// Operator capabilities
Capabilities *avsproto.SyncMessagesReq_Capabilities
// Chains this operator advertises it can monitor.
//
// Empty SupportedChainIDs has TWO different meanings depending on
// SupportedChainsExplicit:
// - SupportedChainsExplicit=false → legacy pre-multi-chain
// operator that never advertised a list. Treated as "covers
// everything" for back-compat.
// - SupportedChainsExplicit=true → the operator HAS advertised
// chains before (either at SyncMessages connect or via a prior
// Ping) and is now reporting that none of its subscriptions
// are live. Treated as "covers nothing" — DO NOT widen routing
// to this operator. The orphan-scan loop will surface the
// resulting tasks-with-no-coverage on the next tick.
//
// Once SupportedChainsExplicit is set, it stays set until the
// operator reconnects (StreamCheckToOperator rebuilds the state
// from scratch on a fresh stream).
SupportedChainIDs []int64
SupportedChainsExplicit bool
// Context cancellation for managing ticker lifecycle
TickerCancel context.CancelFunc
TickerCtx context.Context
// Rate limiting: track when the operator last connected
LastConnectTime time.Time
}
type PendingNotification struct {
TaskID string
Operation avsproto.MessageOp
Timestamp time.Time
}
// The core datastructure of the task engine
type Engine struct {
db storage.Storage
config *config.Config
queue *apqueue.Queue
// maintain a list of active job that we have to synced to operators
// only task triggers are sent to operator
//
// Lock ordering (acquire outer before inner to avoid deadlock):
// assignmentMutex (outer) → streamsMutex (middle) → lock (inner)
// Never acquire an outer lock while holding an inner lock.
// lock is a leaf lock: critical sections must only do map reads/writes,
// never call functions that acquire streamsMutex or assignmentMutex.
tasks map[string]*model.Workflow
lock *sync.Mutex
trackSyncedTasks map[string]*operatorState
// orphanScanCtx / orphanScanCancel govern the periodic orphan-task
// scan started in MustStart (gateway mode only). Cancelling lets
// Stop() exit the loop immediately instead of waiting up to 5
// minutes for the next tick.
orphanScanCtx context.Context
orphanScanCancel context.CancelFunc
// operator stream management for real-time notifications
operatorStreams map[string]avsproto.Node_SyncMessagesServer
streamsMutex *sync.RWMutex
// lifecycle tracking for active operator streams
streamsWG sync.WaitGroup
// Round-robin task assignment
taskAssignments map[string]string // taskID -> operatorAddress mapping
assignmentRoundRobin int // index for round-robin assignment
assignmentMutex *sync.RWMutex // protects task assignments
smartWalletConfig *config.SmartWalletConfig
// chainConfigs maps chain_id to ChainConfig in gateway mode.
// nil in single-chain mode.
chainConfigs map[int64]*config.ChainConfig
// when shutdown is true, our engine will perform the shutdown
// pending execution will be pushed out before the shutdown completely
// to force shutdown, one can type ctrl+c twice
shutdown bool
// seq is a monotonic number to keep track our task id
seq storage.Sequence
logger sdklogging.Logger
// Token enrichment service for ERC20 transfers
tokenEnrichmentService *TokenEnrichmentService
// Shared clients
tenderlyClient *TenderlyClient
priceService PriceService
// Debouncing for operator approval logging
lastApprovalLogTime map[string]time.Time
approvalLogMutex *sync.RWMutex
// Deduplication for operator trigger notifications (prevents double-firing)
// Maps trigger_request_id -> time when it was processed (for TTL cleanup)
processedTriggerIDs map[string]time.Time
triggerDedupLock *sync.Mutex
// Batched operator notifications
pendingNotifications map[string][]PendingNotification // operatorAddr -> list of notifications
notificationMutex *sync.RWMutex
notificationTicker *time.Ticker
// Per-execution serialization for durable resume (exactly-once). Sharded so a
// fixed set of mutexes bounds memory — the same executionID always maps to the
// same shard, so concurrent resumes of one execution serialize. See executionMutex.
executionMutexes [executionLockShards]sync.Mutex
// Collapses concurrent nodes:run requests that carry the same Idempotency-Key
// so a retried/double-clicked Confirm can't broadcast a second UserOp. Works
// with a persistent TTL cache (see RunNodeImmediatelyRPCIdempotent) that also
// dedupes sequential retries after the first request has completed.
idempotencyGroup singleflight.Group
}
// executionLockShards bounds the per-execution resume locks to a fixed set of mutexes.
const executionLockShards = 256
// executionMutex returns the lock guarding resume of executionID. Holding it across
// the load-status → run → write-terminal sequence makes durable resume exactly-once:
// a second concurrent signal (two approvers, a duplicate operator notify) blocks, then
// finds the execution already terminal and no-ops — so an on-chain ContractWrite /
// ETHTransfer in the resumed leg can never run twice.
func (n *Engine) executionMutex(executionID string) *sync.Mutex {
// FNV-1a — allocation-free, deterministic (same id → same shard).
var h uint32 = 2166136261
for i := 0; i < len(executionID); i++ {
h ^= uint32(executionID[i])
h *= 16777619
}
return &n.executionMutexes[h%executionLockShards]
}
// create a new task engine using given storage, config and queue
func New(db storage.Storage, config *config.Config, queue *apqueue.Queue, logger sdklogging.Logger) *Engine {
e := Engine{
db: db,
config: config,
queue: queue,
lock: &sync.Mutex{},
tasks: make(map[string]*model.Workflow),
trackSyncedTasks: make(map[string]*operatorState),
operatorStreams: make(map[string]avsproto.Node_SyncMessagesServer),
streamsMutex: &sync.RWMutex{},
taskAssignments: make(map[string]string),
assignmentMutex: &sync.RWMutex{},
lastApprovalLogTime: make(map[string]time.Time),
approvalLogMutex: &sync.RWMutex{},
processedTriggerIDs: make(map[string]time.Time),
triggerDedupLock: &sync.Mutex{},
smartWalletConfig: config.SmartWallet,
chainConfigs: buildChainConfigMap(config),
shutdown: false,
// Initialize batched notifications
pendingNotifications: make(map[string][]PendingNotification),
notificationMutex: &sync.RWMutex{},
notificationTicker: time.NewTicker(3 * time.Second), // Send batched notifications every 3 seconds
logger: logger,
}
// Initialize AI summarizer (global) from aggregator config
// Only context-memory API is supported - all email content generation is delegated to context-memory
// The aggregator acts as a pass-through for the context-memory response to SendGrid
contextMemorySummarizer, err := NewContextMemorySummarizerFromAggregatorConfig(config)
if err != nil {
// notifications.summary is enabled but misconfigured — refuse to boot rather than
// silently fall back, so a broken summarizer config surfaces loudly at startup.
logger.Fatal("Invalid notifications.summary configuration — refusing to start", "error", err)
// Defense-in-depth: Logger.Fatal is interface-defined and not guaranteed to exit on
// every implementation (e.g. NoOpLogger.Fatal is a no-op). Force termination so a
// misconfiguration can never fall through to the deterministic-fallback path below.
os.Exit(1)
}
if contextMemorySummarizer != nil {
SetSummarizer(contextMemorySummarizer)
logger.Info("AI summarizer initialized", "provider", "context-memory", "base_url", config.NotificationsSummary.APIEndpoint)
} else {
// Summarization not enabled — the deterministic summarizer is used.
logger.Debug("notifications.summary.enabled is false; using deterministic summarizer")
}
// Initialize global macro variables and secrets from config
// This ensures all nodes (BalanceNode, etc.) can access secrets without manual setup
SetMacroVars(config.MacroVars)
SetMacroSecrets(config.MacroSecrets)
SetRpc(config.SmartWallet.EthRpcUrl)
aa.SetFactoryAddress(config.SmartWallet.FactoryAddress)
//SetWsRpc(config.SmartWallet.EthWsUrl)
// Use global TokenEnrichmentService or initialize if not set
if globalTokenService == nil {
logger.Debug("initializing global TokenEnrichmentService", "has_rpc", rpcConn != nil)
tokenService, err := NewTokenEnrichmentService(rpcConn, logger)
if err != nil {
logger.Warn("Failed to initialize TokenEnrichmentService", "error", err)
// Don't fail engine initialization, continue without token enrichment
} else {
globalTokenService = tokenService
// Load token whitelist data into cache
if err := tokenService.LoadWhitelist(); err != nil {
logger.Warn("Failed to load token whitelist", "error", err)
// Don't fail engine initialization, continue with RPC-only token enrichment
}
// Single consolidated log message
if rpcConn != nil {
logger.Info("Global TokenEnrichmentService initialized",
"chainID", tokenService.GetChainID(),
"whitelistTokens", tokenService.GetCacheSize(),
"rpcSupport", true)
} else {
logger.Info("Global TokenEnrichmentService initialized",
"chainID", tokenService.GetChainID(),
"whitelistTokens", tokenService.GetCacheSize(),
"rpcSupport", false)
}
}
} else {
logger.Debug("Using existing global TokenEnrichmentService")
}
e.tokenEnrichmentService = globalTokenService
// Initialize shared Tenderly client from config
e.tenderlyClient = NewTenderlyClient(config, logger)
logger.Info("TenderlyClient initialized", "ready", e.tenderlyClient != nil)
return &e
}
// buildChainConfigMap creates a map of chain_id -> ChainConfig from the gateway config.
// Returns nil if not in gateway mode.
func buildChainConfigMap(cfg *config.Config) map[int64]*config.ChainConfig {
if !cfg.IsGateway || len(cfg.Chains) == 0 {
return nil
}
m := make(map[int64]*config.ChainConfig, len(cfg.Chains))
for _, chain := range cfg.Chains {
m[chain.ChainID] = chain
}
return m
}
// ResolveSmartWalletConfig returns the SmartWalletConfig for a given chain_id.
// In gateway mode, it looks up the per-chain config. In single-chain mode,
// it always returns the default config.
func (n *Engine) ResolveSmartWalletConfig(chainID int64) *config.SmartWalletConfig {
if n.chainConfigs != nil && chainID > 0 {
if chainCfg, ok := n.chainConfigs[chainID]; ok {
return chainCfg.SmartWallet
}
}
return n.smartWalletConfig
}
// defaultChainID returns the aggregator's primary chain. Used as a fallback
// when constructing chain-scoped storage keys for entities that do not yet
// carry an explicit chain_id (back-compat for code paths that pre-date
// per-task chain ids). Returns 0 when no chain is configured — callers
// must accept 0 as a valid (placeholder) chain in that case.
func (n *Engine) defaultChainID() int64 {
if n.smartWalletConfig != nil {
return n.smartWalletConfig.ChainID
}
return 0
}
// userOwnsWalletOnAnyChain reports whether the user owns the given smart
// wallet on at least one of the chains this gateway hosts. Used by RPCs
// that query data across chains (ListWorkflowsByUser, the workflow-count
// path) — a user with tasks on multiple chains might own a wallet on only
// one of them, and we want the validation check to succeed for any.
//
// Falls through to the default-wallet equality first (the default wallet
// has the same derived address across chains when factories are aligned).
// On the first non-nil DB error, returns (false, err) — callers can
// distinguish "wallet not owned anywhere" from "DB problem."
func (n *Engine) userOwnsWalletOnAnyChain(user *model.User, walletAddr common.Address) (bool, error) {
if user.SmartAccountAddress != nil && user.SmartAccountAddress.Hex() == walletAddr.Hex() {
return true, nil
}
for _, chainID := range n.knownChainIDs() {
ok, err := ValidWalletOwner(n.db, chainID, user, walletAddr)
if err != nil {
return false, err
}
if ok {
return true, nil
}
}
return false, nil
}
// knownChainIDs returns every chain the aggregator hosts tasks for. In
// single-chain mode that is the SmartWallet chain; in gateway mode it is
// every chain registered in chainConfigs plus the SmartWallet default.
// Used by read paths that iterate per-status prefixes — they must scan
// each chain's chain-scoped bucket separately because chain-scoped keys
// share no common prefix below "t:".
//
// When neither the SmartWallet config nor chainConfigs provides a positive
// chain_id (e.g., unit tests with a placeholder config), returns a single
// [0] entry so writes and reads use the same prefix bucket.
func (n *Engine) knownChainIDs() []int64 {
seen := make(map[int64]struct{}, 1+len(n.chainConfigs))
out := make([]int64, 0, 1+len(n.chainConfigs))
if id := n.defaultChainID(); id >= 0 {
seen[id] = struct{}{}
out = append(out, id)
}
for id := range n.chainConfigs {
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
if len(out) == 0 {
out = append(out, 0)
}
return out
}
// chainScopedTaskKey constructs the chain-scoped storage key for a task
// whose chain_id might be 0 (legacy proto / unspecified). Falls back to
// the aggregator default chain so writers never produce zero-prefixed
// (and therefore never-readable) keys.
func (n *Engine) chainScopedTaskKey(task *model.Workflow) []byte {
return WorkflowStorageKey(task.Id, task.Status)
}
// isChainConfigured reports whether the aggregator serves chainID, using the
// canonical set knownChainIDs() (default chain + chainConfigs). ResolveSmart-
// WalletConfig is NOT a substitute — it falls back to the default config for
// an unknown chain, so it can't tell "configured" from "unconfigured".
func (n *Engine) isChainConfigured(chainID int64) bool {
for _, id := range n.knownChainIDs() {
if id == chainID {
return true
}
}
return false
}
// validateExplicitPartChains rejects, at create time, a task whose chain-aware
// trigger or nodes either omit chain_id (<= 0) or name a chain the aggregator
// isn't configured for. Post-G5 a task carries no chain, so every chain-aware part must name an
// explicit, configured chain; chain_id 0 or an unconfigured chain is rejected. Only enforced in gateway
// mode, where chainConfigs enumerates the served chains; single-chain mode has
// one chain and nothing to validate against.
func (n *Engine) validateExplicitPartChains(task *model.Workflow) error {
if n.config == nil || !n.config.IsGateway || task == nil {
return nil
}
check := func(kind string, chainID int64) error {
// A task carries no chain, so every chain-aware part must name an
// explicit, configured chain — chain_id 0 is rejected at create.
if chainID <= 0 {
return status.Errorf(codes.InvalidArgument,
"%s requires an explicit chain_id (a task no longer provides a default chain)", kind)
}
if n.isChainConfigured(chainID) {
return nil
}
return status.Errorf(codes.InvalidArgument,
"%s targets chain_id=%d, which is not configured on this aggregator", kind, chainID)
}
if t := task.Trigger; t != nil {
if et := t.GetEvent(); et != nil && et.Config != nil {
if err := check("event trigger", et.Config.GetChainId()); err != nil {
return err
}
}
if bt := t.GetBlock(); bt != nil && bt.Config != nil {
if err := check("block trigger", bt.Config.GetChainId()); err != nil {
return err
}
}
}
for _, node := range task.Nodes {
if err := checkNodeChain(node, check); err != nil {
return err
}
}
return nil
}
// checkNodeChain runs check against a node's chain-aware config, looking inside
// a Loop runner too (it wraps one chain-aware node inline).
func checkNodeChain(node *avsproto.TaskNode, check func(string, int64) error) error {
if node == nil {
return nil
}
if cw := node.GetContractWrite(); cw != nil && cw.Config != nil {
return check("contract write node", cw.Config.GetChainId())
}
if cr := node.GetContractRead(); cr != nil && cr.Config != nil {
return check("contract read node", cr.Config.GetChainId())
}
if et := node.GetEthTransfer(); et != nil && et.Config != nil {
return check("eth transfer node", et.Config.GetChainId())
}
if await := node.GetAwait(); await != nil && await.Config != nil {
// Only the chain-event flavor is chain-aware; external-signal Awaits have no chain.
if ce := await.Config.GetChainEvent(); ce != nil {
return check("await node chain event", ce.GetChainId())
}
}
if loop := node.GetLoop(); loop != nil {
if cw := loop.GetContractWrite(); cw != nil && cw.Config != nil {
return check("loop contract write runner", cw.Config.GetChainId())
}
if cr := loop.GetContractRead(); cr != nil && cr.Config != nil {
return check("loop contract read runner", cr.Config.GetChainId())
}
if et := loop.GetEthTransfer(); et != nil && et.Config != nil {
return check("loop eth transfer runner", et.Config.GetChainId())
}
}
// NOTE: chain-aware nodes nested inside a Branch node's conditional paths are
// not validated here — they fall through to the strict resolveSmartWalletForNode
// check at execution time instead of being rejected at create time. If Branch
// gains nested chain-aware runners, add a case here (mirroring Loop above).
return nil
}
// findTaskKey returns the chain-agnostic storage key for a task by id at the
// given status (G5: storage is no longer chain-bucketed, so the key is direct).
func (n *Engine) findTaskKey(taskID string, status avsproto.TaskStatus) []byte {
return WorkflowStorageKey(taskID, status)
}
// taskExecutionPrefixesBytes returns the chain-agnostic execution-history
// prefix for taskID (G5: one bucket, so a single prefix).
func (n *Engine) taskExecutionPrefixesBytes(taskID string) [][]byte {
return [][]byte{TaskExecutionPrefix(taskID)}
}
// taskExecutionPrefixes is the string form for ListKeysMulti.
func (n *Engine) taskExecutionPrefixes(taskID string) []string {
return []string{string(TaskExecutionPrefix(taskID))}
}
// chainUserPrefixesBytes returns the chain-agnostic "u:{owner}" prefix
// (matches every task this owner has).
func (n *Engine) chainUserPrefixesBytes(owner common.Address) [][]byte {
return [][]byte{[]byte(fmt.Sprintf("u:%s", strings.ToLower(owner.Hex())))}
}
// chainSmartWalletPrefixesBytes returns the chain-agnostic
// "u:{owner}:{wallet}" prefix (matches every task this owner has on the given
// smart wallet).
func (n *Engine) chainSmartWalletPrefixesBytes(owner common.Address, smartWallet common.Address) [][]byte {
return [][]byte{[]byte(fmt.Sprintf("u:%s:%s",
strings.ToLower(owner.Hex()), strings.ToLower(smartWallet.Hex())))}
}
// GetTenderlyClient returns the shared Tenderly client for fee estimation and simulation
func (n *Engine) GetTenderlyClient() *TenderlyClient {
return n.tenderlyClient
}
func (n *Engine) SetPriceService(priceService PriceService) {
n.priceService = priceService
}
func (n *Engine) Stop() {
if n.seq != nil {
if err := n.seq.Release(); err != nil {
n.logger.Error("failed to release sequence", "error", err)
}
}
// mark shutdown and collect cancels without holding locks during cancel
var cancels []context.CancelFunc
n.lock.Lock()
n.shutdown = true
for _, state := range n.trackSyncedTasks {
if state != nil && state.TickerCancel != nil {
cancels = append(cancels, state.TickerCancel)
}
}
n.lock.Unlock()
// cancel all operator tickers to stop their stream loops
for _, cancel := range cancels {
cancel()
}
// Stop the orphan-scan loop promptly (gateway mode only — nil
// cancel is a no-op).
if n.orphanScanCancel != nil {
n.orphanScanCancel()
}
// wait for all StreamCheckToOperator goroutines to exit
n.streamsWG.Wait()
// Send any remaining notifications before shutting down
if n.notificationTicker != nil {
n.sendBatchedNotifications()
n.notificationTicker.Stop()
}
}
// chainNeedsOperatorMonitoring reports whether a trigger type needs an
// operator with a live subscription on the task's chain. Block/Event
// triggers require chain-specific operator coverage; Manual/Cron/FixedTime
// don't depend on any particular chain (cron + fixed-time still run on
// the operator's chain-agnostic TimeTrigger; manual fires entirely on
// the gateway). The question this function answers is specifically
// "does this trigger need a per-chain RPC subscription?" — not "where
// does the trigger execute?"
func chainNeedsOperatorMonitoring(tt avsproto.TriggerType) bool {
switch tt {
case avsproto.TriggerType_TRIGGER_TYPE_BLOCK,
avsproto.TriggerType_TRIGGER_TYPE_EVENT:
return true
default:
return false
}
}
// triggerMonitoringChainID returns the chain an operator must subscribe to in
// order to watch this task's trigger fire (G2). For chain-watching triggers
// (event/block) it is the trigger's OWN configured chain — so a workflow can
// watch chain X while its nodes act on chain Y. Post-G5 there is no task-level
// chain: every caller passes fallbackChainID=0, and strict create-time validation
// already rejects a chain-watching trigger with chain_id<=0, so a 0 trigger chain
// here only occurs for non-chain triggers (cron/fixedtime/manual), which carry the
// fallback through unchanged — the operator's TimeTrigger is chain-agnostic and
// ignores it. Proto getters are nil-safe, so the GetEvent()/GetBlock() chain reads
// are safe for any trigger type.
func triggerMonitoringChainID(trigger *avsproto.TaskTrigger, fallbackChainID int64) int64 {
if cid := trigger.GetEvent().GetConfig().GetChainId(); cid != 0 {
return cid
}
if cid := trigger.GetBlock().GetConfig().GetChainId(); cid != 0 {
return cid
}
return fallbackChainID
}
// operatorsCoveringChain returns the addresses of currently-connected
// operators that advertise the given chain_id. Empty
// SupportedChainIDs is "covers everything" ONLY when
// SupportedChainsExplicit is false — see operatorState.SupportedChainIDs
// for the explicit-empty-vs-legacy-empty distinction. Caller MUST hold
// n.lock — this reads trackSyncedTasks without locking internally.
func (n *Engine) operatorsCoveringChain(chainID int64) []string {
if chainID == 0 {
// Chain-agnostic tasks are covered by any connected operator;
// returning the full set is correct.
out := make([]string, 0, len(n.trackSyncedTasks))
for addr := range n.trackSyncedTasks {
out = append(out, addr)
}
return out
}
out := make([]string, 0, len(n.trackSyncedTasks))
for addr, state := range n.trackSyncedTasks {
if state == nil {
continue
}
if len(state.SupportedChainIDs) == 0 {
// Empty list: only treat as "covers everything" for
// legacy operators that never explicitly advertised a
// chain set. Explicit-empty (operator told us "I have
// no live chains") covers nothing.
if !state.SupportedChainsExplicit {
out = append(out, addr)
}
continue
}
for _, id := range state.SupportedChainIDs {
if id == chainID {
out = append(out, addr)
break
}
}
}
return out
}
// orphanedTaskInfo is the read-only snapshot scanOrphanedTasks
// produces while holding n.lock; logging happens after the lock is
// released so a long task list doesn't block CreateTask / Pings / the
// stream loop.
type orphanedTaskInfo struct {
taskID string
chainID int64
triggerType string
}
// scanOrphanedTasks logs each currently-stored task whose chain is no
// longer covered by any connected operator. Runs on a slow interval
// from the engine startup loop; the alert path is the log line, not
// an automatic remediation — operators may reconnect and the orphan
// resolves itself. Logging is deliberately verbose (one line per
// orphaned task) so on-call has the task IDs at hand.
//
// The scan collects orphan info under n.lock and releases the lock
// before logging — n.logger.Warn can take non-trivial time (Sentry
// breadcrumb, sink I/O) and we don't want that blocking concurrent
// Pings / CreateTasks / stream ops.
func (n *Engine) scanOrphanedTasks() {
orphans := n.collectOrphans()
for _, o := range orphans {
n.logger.Warn("🚨 Orphaned task: no connected operator advertises this chain — trigger will not fire until coverage returns",
"task_id", o.taskID, "chain_id", o.chainID, "trigger_type", o.triggerType)
}
}
// collectOrphans walks the task + operator state under n.lock and
// returns the list of orphaned tasks. Pure read-only — no logging or
// I/O while the lock is held.
func (n *Engine) collectOrphans() []orphanedTaskInfo {
n.lock.Lock()
defer n.lock.Unlock()
// Build chain→hasOperator map up front; tasks-per-chain is much
// larger than operators-per-chain, so this avoids re-walking the
// operator set per task.
chainHasOperator := make(map[int64]bool, 8)
for _, state := range n.trackSyncedTasks {
if state == nil {
continue
}
if len(state.SupportedChainIDs) == 0 {
// Empty list: legacy operators (never explicitly
// advertised) still cover everything for back-compat.
// Explicit-empty (operator told us it has no live
// chains) covers nothing — see SupportedChainsExplicit.
if !state.SupportedChainsExplicit {
chainHasOperator[0] = true
}
continue
}
for _, id := range state.SupportedChainIDs {
chainHasOperator[id] = true
}
}
hasLegacyOperator := chainHasOperator[0]
var out []orphanedTaskInfo
for taskID, task := range n.tasks {
if task == nil {
continue
}
if task.Trigger == nil || !chainNeedsOperatorMonitoring(task.Trigger.Type) {
continue
}
if hasLegacyOperator {
continue
}
// Coverage is judged on the trigger's monitoring chain (G2), which is
// where the operator actually subscribes — not the task chain.
monitorChainID := triggerMonitoringChainID(task.Trigger, 0)
if !chainHasOperator[monitorChainID] {
out = append(out, orphanedTaskInfo{
taskID: taskID,
chainID: monitorChainID,
triggerType: task.Trigger.Type.String(),
})
}
}
return out
}
// chainIDSlicesEqual compares two chain-ID slices element-wise. Order
// matters here because chainOrder is deterministic on the operator
// side — we want a reorder (which would never happen in practice) to
// still be logged as a change if it did.
func chainIDSlicesEqual(a, b []int64) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// UpdateOperatorSupportedChains updates a connected operator's
// advertised chain capability. Called from the Ping RPC handler so a
// stalled subscription (chain configured but no recent heads — see
// operator's chainCapabilityStaleThreshold) can be dropped from
// per-chain task routing within one Ping interval, without forcing a
// SyncMessages reconnect.
//
// The operator MUST be currently connected (entry exists in
// trackSyncedTasks) for this to take effect. A Ping for an operator
// that never opened a stream is a no-op — the SyncMessages connect
// path is the authoritative one for first-time capability registration.
//
// Empty chainIDs means "operator has zero live chains right now". This
// sets SupportedChainsExplicit=true so operatorsCoveringChain treats
// the empty list as "covers nothing" rather than falling back to the
// legacy "covers everything" semantics — see operatorState
// .SupportedChainIDs.
func (n *Engine) UpdateOperatorSupportedChains(address string, chainIDs []int64) {
n.lock.Lock()
defer n.lock.Unlock()
state, ok := n.trackSyncedTasks[address]
if !ok || state == nil {
return
}
// Only emit when the set actually changes — Ping runs every 5s
// and the chain set is steady-state for the vast majority of
// pings, so a no-op comparison shouldn't allocate.
if !chainIDSlicesEqual(state.SupportedChainIDs, chainIDs) {
n.logger.Info("🔁 Operator advertised chain set changed via Ping",
"operator", address, "prev", state.SupportedChainIDs, "next", chainIDs)
}
state.SupportedChainIDs = chainIDs
// Any Ping carrying the field flips the explicit flag, including
// one with an empty list — the operator has actively told us its
// chain capability, so we should never fall back to the legacy
// empty-means-everything semantics for it again.
state.SupportedChainsExplicit = true
}
// AddTaskForTesting adds a task directly to the engine's task map for testing purposes
// This bypasses database storage and validation - only use in tests
func (n *Engine) AddWorkflowForTesting(task *model.Workflow) {
n.lock.Lock()
defer n.lock.Unlock()
n.tasks[task.Id] = task
}
func (n *Engine) MustStart() error {
var err error
n.seq, err = n.db.GetSequence([]byte("t:seq"), 1000)
if err != nil {
panic(err)
}
// Upon booting we load all enabled tasks. Storage is chain-agnostic (G5),
// so a single "t:a:" prefix scan covers every enabled task.
loadedCount := 0
{
kvs, e := n.db.GetByPrefix(WorkflowByStatusStoragePrefix(avsproto.TaskStatus_Enabled))
if e != nil {
panic(e)
}
for _, item := range kvs {
task := &model.Workflow{
Task: &avsproto.Task{},
}
// DiscardUnknown: schema evolution over time has renamed/
// removed proto fields (e.g. `expression`, `epochs`,
// `totalExecution`, `interval`, `input`). Strict decode
// would silently skip any task whose body retains those
// old fields (the `if err == nil` branch below swallows
// errors). Tolerate unknowns at load — re-marshal on the
// next write drops them permanently.
err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(item.Value, task)
if err == nil {
if initErr := task.EnsureInitialized(); initErr != nil {
n.logger.Warn("Task failed initialization after loading from storage",
"storage_key", string(item.Key),
"task_id", task.Id,
"error", initErr)
continue
}
n.tasks[task.Id] = task
loadedCount++
} else {
n.logger.Warn("Failed to unmarshal task during startup", "storage_key", string(item.Key), "error", err)
}
}
}
n.logger.Info("🚀 Engine started successfully", "active_tasks_loaded", loadedCount)
// Detect and handle any invalid tasks that may have been created before validation was fixed
if err := n.DetectAndHandleInvalidTasks(); err != nil {
n.logger.Error("Failed to handle invalid tasks during startup", "error", err)
// Don't fail startup, but log the error
}
// Start the batch notification processor
go n.processBatchedNotifications()
// Periodic orphan scan: degradation (operator drops chain mid-run)
// is the common failure mode, so create-time validation isn't
// sufficient on its own. Gateway mode only — single-chain
// deployments would log noise during operator-cold-start.
//
// orphanScanCtx is derived from a fresh background context (we
// don't have a parent ctx in MustStart) and cancelled from
// Stop() so the loop exits within milliseconds of shutdown
// instead of waiting up to 5 minutes for the next tick.
if n.config != nil && n.config.IsGateway {