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: 8 additions & 1 deletion chain_capabilities/evm/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ require (
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-20260526195338-adcf8013a1b7
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260611123141-db97012a6c32
github.com/smartcontractkit/chainlink-protos/metering/go v0.0.0-00010101000000-000000000000
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 @@ -217,3 +218,9 @@ require (
)

replace github.com/fbsobreira/gotron-sdk => github.com/smartcontractkit/chainlink-tron/relayer/gotron-sdk v0.0.5-0.20250528121202-292529af39df

// Local replaces for the unpublished resourcemanager/metering stack; drop once
// chainlink-common and chainlink-protos/metering/go are tagged.
replace github.com/smartcontractkit/chainlink-common => ../../../chainlink-common

replace github.com/smartcontractkit/chainlink-protos/metering/go => ../../../chainlink-protos/metering/go
6 changes: 2 additions & 4 deletions chain_capabilities/evm/go.sum

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

71 changes: 70 additions & 1 deletion chain_capabilities/evm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"time"

Expand All @@ -28,11 +29,13 @@ import (
"github.com/smartcontractkit/capabilities/chain_capabilities/evm/trigger"
"github.com/smartcontractkit/capabilities/libs/loopserver"

"github.com/smartcontractkit/chainlink-common/pkg/beholder"
"github.com/smartcontractkit/chainlink-common/pkg/capabilities"
evmcappb "github.com/smartcontractkit/chainlink-common/pkg/capabilities/v2/chain-capabilities/evm"
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 Down Expand Up @@ -142,9 +145,19 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor

// TODO: add org resolver
capabilityID := fmt.Sprintf("%s (%d)", c.id, cfg.ChainID)
// 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, resourcemanager.ResourceManagerConfig{
Enabled: meterRecordsEnabled(c.lggr),
Emitter: beholder.GetEmitter(),
SnapshotInterval: resourcemanager.DefaultSnapshotInterval,
})
baseIdentity := newBaseMeteringIdentity(dependencies)
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)
dependencies.OrgResolver, dependencies.TriggerEventStore, resourceManager, baseIdentity, c.chainSelector)
if err != nil {
return fmt.Errorf("error when creating trigger: %w", err)
}
Expand Down Expand Up @@ -179,6 +192,62 @@ func (c *capabilityGRPCService) Initialise(ctx context.Context, dependencies cor
return nil
}

// defaultMeteringProduct is the fallback metering product dimension used when
// the host did not inject one via the standardized Initialise channel (a legacy
// node or a boot path not yet updated). The other deployment dimensions
// (environment, zone, node_id) have no meaningful constant and are left empty
// in that case, as documented on StandardCapabilitiesDependencies.
const defaultMeteringProduct = "cre"

// newBaseMeteringIdentity builds the EVM log trigger's base metering identity
// from the host-injected dependencies. It carries the six coarse dimensions
// plus the service-level resource/resource_type; the per-resource ResourceID is
// set per emit/snapshot. DONID is the capability DON when the host injected one
// (deps.CapabilityDonID); when 0, it is left empty here and resolved per emit
// from the consumer's WorkflowDonID (see LogTriggerService.resolveDONID). This
// reads deps.CapabilityDonID at the Initialise layer so the change is orthogonal
// to capabilities#619's NewLogTriggerService signature edit.
func newBaseMeteringIdentity(deps core.StandardCapabilitiesDependencies) resourcemanager.ResourceIdentity {
product := deps.Product
if product == "" {
product = defaultMeteringProduct
}
var donID string
if deps.CapabilityDonID != 0 {
donID = strconv.FormatUint(uint64(deps.CapabilityDonID), 10)
}
return resourcemanager.ResourceIdentity{
Product: product,
Environment: deps.Environment,
Zone: deps.Zone,
DONID: donID,
NodeID: deps.NodeID,
Service: trigger.MeteringService,
Resource: trigger.MeteringResource,
ResourceType: trigger.MeteringResourceType,
}
}

// meterRecordsEnabledEnvVar gates MeterRecord emission; the name is the
// cross-producer convention for the metering rollout (SHARED-2718).
const meterRecordsEnabledEnvVar = "CL_METER_RECORDS_ENABLED"

// meterRecordsEnabled reads the metering gate from the environment. Unset or
// unparseable values disable emission; metering config must never prevent the
// capability from starting.
func meterRecordsEnabled(lggr logger.Logger) bool {
v := os.Getenv(meterRecordsEnabledEnvVar)
if v == "" {
return false
}
enabled, err := strconv.ParseBool(v)
if err != nil {
lggr.Warnw("Invalid value for "+meterRecordsEnabledEnvVar+", meter record emission disabled", "value", v, "error", err)
return false
}
return enabled
}

func (c *capabilityGRPCService) unmarshalConfig(configStr string) (*config.Config, error) {
var cfg config.Config
if err := json.Unmarshal([]byte(configStr), &cfg); err != nil {
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[:])
}
20 changes: 19 additions & 1 deletion chain_capabilities/evm/trigger/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,25 @@ import (
)

type filter struct {
filterID string
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
// RESERVE/RELEASE event identity, so the unregister, cleanup, snapshot, and
// graceful-close paths all reuse it from here without the request input.
physicalFilterID string
// reservedAddressCount is the number of filter addresses metered in the
// RESERVE record when the filter was registered. The matching RELEASE
// must carry the same value, and 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/close paths can emit a metering record with
// the same identity as the RESERVE, 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
expressions []query.Expression
confidence primitives.ConfidenceLevel
}
Expand Down
Loading
Loading