Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions chain_capabilities/evm/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ go 1.26.2
require (
github.com/ethereum/go-ethereum v1.17.0
github.com/google/go-cmp v0.7.0
github.com/jonboulle/clockwork v0.5.0
github.com/smartcontractkit/capabilities/chain_capabilities/common v0.0.0-20260615195421-fb87220e503f
github.com/smartcontractkit/capabilities/libs v0.0.0-20260604174211-7f26071a47e0
github.com/smartcontractkit/capabilities/libs v0.0.0-20260609124022-2749e4a32bfb
github.com/smartcontractkit/chain-selectors v1.0.106
github.com/smartcontractkit/chainlink-common v0.11.2-0.20260714130758-475cb096a3e1
github.com/smartcontractkit/chainlink-common v0.11.2-0.20260730010536-738aeedcaf64
github.com/smartcontractkit/chainlink-evm v0.3.4-0.20260410162948-2dca02f24e98
github.com/smartcontractkit/chainlink-evm/gethwrappers v0.0.0-20251022073203-7d8ae8cf67c1
github.com/smartcontractkit/chainlink-framework/multinode v0.0.0-20260410144512-ca02ad6ed16a
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260707195416-ca350beacd4b
github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-20260729184203-90b4cdd48536
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel v1.43.0
go.uber.org/zap v1.27.1
Expand Down Expand Up @@ -69,7 +71,6 @@ require (
github.com/jackc/pgx/v5 v5.9.2 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jackpal/go-nat-pmp v1.0.2 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/pretty v0.3.1 // indirect
Expand All @@ -90,7 +91,7 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/smartcontractkit/chainlink-common/keystore v1.1.1-0.20260529092756-a94bc8ce96d6 // indirect
github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260626151909-052e55e62e62 // indirect
github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.11-0.20260724142814-45996a1bcb72 // indirect
github.com/smartcontractkit/chainlink-framework/chains v0.0.0-20260326122810-b657beadfb57 // indirect
github.com/smartcontractkit/chainlink-framework/metrics v0.0.0-20260401162955-be2bc6b5264b // indirect
github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect
Expand Down
14 changes: 8 additions & 6 deletions chain_capabilities/evm/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

30 changes: 26 additions & 4 deletions chain_capabilities/evm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
evmcapserver "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm/server"
"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/loop"
"github.com/smartcontractkit/chainlink-common/pkg/resourcemanager"
"github.com/smartcontractkit/chainlink-common/pkg/settings/limits"
"github.com/smartcontractkit/chainlink-common/pkg/types"
"github.com/smartcontractkit/chainlink-common/pkg/types/core"
Expand All @@ -46,6 +47,10 @@ type capabilityGRPCService struct {
capability
lggr logger.Logger
limitsFactory limits.Factory
// metering is the resolved metering Config (ResourceManagerConfig +
// DeploymentIdentity) produced by loop.Server.MeteringConfig at startup.
// The zero value is valid and leaves those dimensions empty/disabled.
metering resourcemanager.Config
}

type capability struct {
Expand All @@ -62,7 +67,15 @@ var _ evmcapserver.ClientCapability = &capabilityGRPCService{}

func main() {
loopserver.ServeNew(CapabilityName, func(s *loop.Server) loop.StandardCapabilities {
return evmcapserver.NewClientServer(&capabilityGRPCService{lggr: s.Logger, limitsFactory: s.LimitsFactory})
// Server.MeteringConfig is the single, canonical loop-env -> metering
// mapping (enable flags, snapshot interval, deployment identity); no
// per-main copy of that mapping, and no reaching for a process-global
// emitter (it injects the server's own durable emitter).
return evmcapserver.NewClientServer(&capabilityGRPCService{
lggr: s.Logger,
limitsFactory: s.LimitsFactory,
metering: s.MeteringConfig(),
})
}, loop.WithOtelViews(append(consMetrics.MetricViews(), monitoring.MetricViews()...)))
}

Expand Down Expand Up @@ -158,11 +171,20 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor
return fmt.Errorf("failed to init evm relayer for chainID %d from relayer: %w", cfg.ChainID, err)
}

// TODO: add org resolver
capabilityID := fmt.Sprintf("%s (%d)", c.id, cfg.ChainID)
c.triggerService, err = trigger.NewLogTriggerService(evmRelayer, trigger.NewLogTriggerStore(), c.lggr, capabilityID, capabilityDonID, processor, messageBuilder,
// The ResourceManager owns the snapshot tick; the LogTriggerService starts it
// as a sub-service and registers itself, so it must be configured with a
// snapshot interval here. Identity/snapshots are gated by the same metering
// env flag as MeterRecords.
resourceManager := resourcemanager.NewResourceManager(c.lggr, c.metering.ResourceManagerConfig)
baseIdentity := resourcemanager.NewBaseIdentity(c.metering.DeploymentIdentity, trigger.MeteringService, trigger.MeteringResource)
if dependencies.CapabilityDonID != 0 {
baseIdentity = baseIdentity.WithDonID(strconv.FormatUint(uint64(dependencies.CapabilityDonID), 10))
}
orgResolver := dependencies.OrgResolver
c.triggerService, err = trigger.NewLogTriggerService(evmRelayer, trigger.NewLogTriggerStore(), c.lggr, capabilityID, processor, messageBuilder,
cfg.LogTriggerPollInterval, cfg.LogTriggerSendChannelBufferSize, cfg.LogTriggerLimitQueryLogSize, c.limitsFactory,
dependencies.OrgResolver, dependencies.TriggerEventStore)
orgResolver, dependencies.TriggerEventStore, resourceManager, baseIdentity, c.chainSelector)
if err != nil {
return fmt.Errorf("error when creating trigger: %w", err)
}
Expand Down
63 changes: 63 additions & 0 deletions chain_capabilities/evm/trigger/physical_filter_id.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package trigger

import (
"crypto/sha256"
"encoding/hex"
"sort"
"strings"

evmtypes "github.com/smartcontractkit/chainlink-common/pkg/types/chains/evm"
)

// physicalFilterID returns the workflow-independent content identity of an EVM
// log filter: the lowercase hex SHA-256 over a canonical encoding of the
// filter's physical matching criteria (chain selector, addresses, event
// signatures, and positional topic slots). Two filters that match exactly the
// same on-chain logs hash to the same ID regardless of which workflow or
// trigger registered them, or of the order their addresses/sigs/topics were
// supplied. It is used as ResourceIdentity.ResourceID and as the
// RESERVE/RELEASE event identity so identical filters share one billable
// physical resource (R4).
//
// Canonicalization rules (each rule defeats a source of non-determinism):
// - addresses and event sigs are lowercased 0x-prefixed hex and sorted
// ascending: the matching set is order-independent;
// - topic2/topic3/topic4 are POSITIONAL — a value in topic2 is a different
// filter than the same value in topic3 — so each slot is encoded under its
// own positional tag, and within a slot the values are sorted ascending;
// - the chain selector scopes the hash so identical filters on different
// chains stay distinct.
//
// The preimage uses "|" as a top-level separator and "," within a set; the
// per-element hex encodings are fixed-width and contain neither, so the
// encoding is unambiguous.
func physicalFilterID(chainSelector string, addresses []evmtypes.Address, eventSigs, topic2, topic3, topic4 []evmtypes.Hash) string {
sortedAddrs := make([]string, len(addresses))
for i, a := range addresses {
sortedAddrs[i] = "0x" + hex.EncodeToString(a[:])
}
sort.Strings(sortedAddrs)

canonHashes := func(hs []evmtypes.Hash) string {
out := make([]string, len(hs))
for i, h := range hs {
out[i] = "0x" + hex.EncodeToString(h[:])
}
sort.Strings(out)
return strings.Join(out, ",")
}

// Topic slots are encoded positionally so the same value in different slots
// produces a different identity.
preimage := strings.Join([]string{
"cs=" + chainSelector,
"addrs=" + strings.Join(sortedAddrs, ","),
"sigs=" + canonHashes(eventSigs),
"t2=" + canonHashes(topic2),
"t3=" + canonHashes(topic3),
"t4=" + canonHashes(topic4),
}, "|")

sum := sha256.Sum256([]byte(preimage))
return hex.EncodeToString(sum[:])
}
77 changes: 74 additions & 3 deletions chain_capabilities/evm/trigger/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,34 @@
)

type filter struct {
filterID string
expressions []query.Expression
confidence primitives.ConfidenceLevel
filterID string
// physicalFilterID is the workflow-independent content hash of the filter's
// physical matching criteria (chain selector + canonicalized addresses,
// event sigs, and positional topics). It is the metering ResourceID and the
// shared-resource refcount key, so the unregister, cleanup, and snapshot
// paths all reuse it from here without the request input. Identical filters
// registered by different triggers share one physicalFilterID and are billed
// once (a +delta on the 0->1 activation, a -delta on the 1->0 release).
physicalFilterID string
// reservedAddressCount is the number of filter addresses this filter bills:
// the +delta emitted on the physical filter's 0->1 activation, and the
// -delta on its 1->0 release, both carry this value. UnregisterLogTrigger
// ignores its request input, so the count is stashed here at registration.
reservedAddressCount int64
// donID is stashed from the registration RequestMetadata so the
// unregister/cleanup/snapshot paths reproduce the same identity as the
// activation delta without the original request. It is the resolved metering
// DON ID string (capability DON, or the consumer WorkflowDonID fallback when
// the host did not inject a capability DON); empty when neither is known.
donID string
// workflowOwner is stored for attribution.
workflowOwner string
// orgID is the organization ID resolved from workflowOwner at registration
// time and stored alongside so that emit and snapshot paths can use it
// without a network call.
orgID string

Check failure on line 40 in chain_capabilities/evm/trigger/store.go

View workflow job for this annotation

GitHub Actions / lint (evm)

File is not properly formatted (goimports)
expressions []query.Expression
confidence primitives.ConfidenceLevel
}

type logTriggerState struct {
Expand All @@ -38,6 +63,8 @@
Read(triggerID string) (value logTriggerState, ok bool)
ReadAll() (values map[string]logTriggerState)
Write(triggerID string, value logTriggerState)
WriteAndIsFirstForPhysical(triggerID string, value logTriggerState) (firstForPhysical bool)
DeleteAndIsLastForPhysical(triggerID, physicalFilterID string) (found, lastForPhysical bool)
Update(triggerID string, lastBlock *big.Int, unfinalizedSentEventIDs map[string]*big.Int) error
Delete(triggerID string)
}
Expand Down Expand Up @@ -71,6 +98,50 @@
cs.triggers[triggerID] = value
}

// WriteAndIsFirstForPhysical writes value under triggerID and reports whether,
// at the instant of the write, no OTHER trigger already held
// value.physicalFilterID. A true result is the 0->1 activation of that shared
// physical filter — the only transition that bills a +delta. Deriving the
// transition from owned state at operation time keeps the emitter stateless
// (no ledger). The scan and write are atomic under the store lock so two
// concurrent registrations of the same physical filter can never both observe
// zero and double-bill.
func (cs *logTriggerStore) WriteAndIsFirstForPhysical(triggerID string, value logTriggerState) (firstForPhysical bool) {
cs.mu.Lock()
defer cs.mu.Unlock()
firstForPhysical = true
for id, existing := range cs.triggers {
if id == triggerID {
continue
}
if existing.physicalFilterID == value.physicalFilterID {
firstForPhysical = false
break
}
}
cs.triggers[triggerID] = value
return firstForPhysical
}

// DeleteAndIsLastForPhysical deletes triggerID and reports whether any remaining
// trigger still holds physicalFilterID. lastForPhysical is true when none
// remain — the 1->0 deactivation that bills a -delta. found reports whether the
// trigger existed. Scan and delete are atomic under the store lock.
func (cs *logTriggerStore) DeleteAndIsLastForPhysical(triggerID, physicalFilterID string) (found, lastForPhysical bool) {
cs.mu.Lock()
defer cs.mu.Unlock()
_, found = cs.triggers[triggerID]
delete(cs.triggers, triggerID)
lastForPhysical = true
for _, existing := range cs.triggers {
if existing.physicalFilterID == physicalFilterID {
lastForPhysical = false
break
}
}
return found, lastForPhysical
}

func (cs *logTriggerStore) Update(triggerID string, lastBlock *big.Int, unfinalizedSentEventIDs map[string]*big.Int) error {
cs.mu.Lock()
defer cs.mu.Unlock()
Expand Down
Loading
Loading