Skip to content
Merged
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
16 changes: 8 additions & 8 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,14 @@ import (
// It mirrors the structure produced by our existing config.tmpl files and remains
// compatible with server-side gomplate rendering used during e2e runs.
type Config struct {
Network string `yaml:"network" json:"network"`
ChainID string `yaml:"chainID" json:"chainID"`
CourierURL string `yaml:"courierURL" json:"courierURL"`
Service string `yaml:"service" json:"service"`
ApiKeySecret string `yaml:"apiKeySecret,omitempty" json:"apiKeySecret,omitempty"`
ChainSelector string `yaml:"chainSelector" json:"chainSelector"`
WatcherID string `yaml:"watcherID" json:"watcherID"`
WorkflowName string `yaml:"workflowName" json:"workflowName"`
Network string `yaml:"network" json:"network"`
ChainID string `yaml:"chainID" json:"chainID"`
CourierURL string `yaml:"courierURL" json:"courierURL"`
Service *string `yaml:"service,omitempty" json:"service,omitempty"`
ApiKeySecret string `yaml:"apiKeySecret,omitempty" json:"apiKeySecret,omitempty"`
ChainSelector string `yaml:"chainSelector" json:"chainSelector"`
WatcherID string `yaml:"watcherID" json:"watcherID"`
WorkflowName string `yaml:"workflowName" json:"workflowName"`

DetectEventTriggerConfig DetectEventTriggerConfig `yaml:"detectEventTriggerConfig" json:"detectEventTriggerConfig"`
}
Expand Down
107 changes: 63 additions & 44 deletions event_processing.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,11 @@ import (
"encoding/json"
"fmt"
"log/slog"
"math"
"math/big"
"reflect"
"strconv"
"strings"
"time"

"github.com/google/uuid"
gethAbi "github.com/ethereum/go-ethereum/accounts/abi"
gethCommon "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
Expand Down Expand Up @@ -196,7 +193,7 @@ func MustEvent(abiJSON, eventName string) gethAbi.Event {
// BuildAndHashEventEnvelope builds the base64 verifiable-event JSON and computes keccak256(type+"."+b64).
// Note: parameters and metadata are treated as read-only; they are not mutated.
func BuildAndHashEventEnvelope(
service string,
service *string,
eventName string,
contractAddress string,
eventABIJSON string,
Expand All @@ -218,14 +215,16 @@ func BuildAndHashEventEnvelope(
}

event := map[string]any{
"service": service,
"name": eventName,
"address": contractAddress,
"topic_hash": eventABI.ID.Hex(),
"log_index": logIndex,
"block_number": blockNumber,
"parameters": parameters,
}
if service != nil {
event["service"] = *service
}

transaction := map[string]any{
"timestamp": timestamp,
Expand All @@ -245,7 +244,11 @@ func BuildAndHashEventEnvelope(

marshalledVerifiableEvent, _ := json.Marshal(verifiableEventBody)
base64VerifiableEvent := base64.StdEncoding.EncodeToString(marshalledVerifiableEvent)
typeName := service + "." + eventName
// Build typeName: if service is nil, use just eventName, otherwise use service.eventName
typeName := eventName
if service != nil {
typeName = *service + "." + eventName
}
payloadToSign := typeName + "." + base64VerifiableEvent
eventHash := crypto.Keccak256Hash([]byte(payloadToSign))

Expand All @@ -260,12 +263,12 @@ func BuildAndHashEventEnvelope(

// ResolveAPIKey returns the API key to use for Courier requests.
// Only the secret-based approach is supported:
// - cfg.ApiKeySecret MUST be set to the secret ID.
// - apiKeySecret MUST be set to the secret ID.
// - The secret MUST resolve via rt.GetSecret.
//
// If resolution fails, an empty string is returned and callers should error.
func ResolveAPIKey(rt cre.Runtime, cfg *Config) string {
secretID := strings.TrimSpace(cfg.ApiKeySecret)
func ResolveAPIKey(rt cre.Runtime, apiKeySecret string) string {
secretID := strings.TrimSpace(apiKeySecret)
if secretID == "" {
return ""
}
Expand All @@ -274,7 +277,7 @@ func ResolveAPIKey(rt cre.Runtime, cfg *Config) string {
s, err := rt.GetSecret(&cre.SecretRequest{Id: secretID}).Await()

if err != nil {
slog.Warn("failed to resolve API key secret", "error", err)
rt.Logger().Warn("ResolveAPIKey failed to get secret", "error", err)
return ""
}

Expand All @@ -296,20 +299,12 @@ func PostSignedEvent(cfg *Config, rt cre.Runtime, eventName, address string, pre
}
rpb := report.X_GeneratedCodeOnly_Unwrap()

// Convert ChainSelector to uint64
chainSelector, err := strconv.ParseUint(cfg.ChainSelector, 10, 64)
if err != nil {
return "", fmt.Errorf("invalid chain selector: %w", err)
}

// Compose HTTP body
bodyMap := map[string]any{
"event_id": uuid.New().String(),
"created_at": int64(pre.BlockTimestamp) * 1000, // Convert seconds to milliseconds for server
"watcher_id": cfg.WatcherID,
"domain": cfg.Service,
"name": eventName,
"chain_selector": chainSelector,
"chain_selector": cfg.ChainSelector,
"address": address,
"ocr_report": "0x" + hex.EncodeToString(rpb.RawReport),
"ocr_context": "0x" + hex.EncodeToString(rpb.ReportContext),
Expand All @@ -323,42 +318,66 @@ func PostSignedEvent(cfg *Config, rt cre.Runtime, eventName, address string, pre
return out
}(),
}
if cfg.Service != nil {
bodyMap["domain"] = *cfg.Service
}
body, _ := json.Marshal(bodyMap)

// HTTP POST with identical consensus
// We aggregate only the integer StatusCode to ensure compatibility with Identical consensus.
client := &httpcap.Client{}
key := ResolveAPIKey(rt, cfg)

_, err = httpcap.SendRequest(cfg, rt, client, func(_ *Config, _ *slog.Logger, sr *httpcap.SendRequester) (*httpcap.Response, error) {
if key == "" {
return nil, fmt.Errorf("courier API key is required but not configured")
}
headers := map[string]string{
"Content-Type": "application/json",
"Api-Key": key,
}
req := &httpcap.Request{
Url: strings.TrimRight(cfg.CourierURL, "/") + "/system/onchain-watcher-events",
Method: "POST",
Headers: headers,
Body: body,
}
return sr.SendRequest(req).Await()
}, cre.ConsensusIdenticalAggregation[*httpcap.Response]()).Await()
key := ResolveAPIKey(rt, cfg.ApiKeySecret)

_, err = httpcap.SendRequest(
cfg,
rt,
client,
func(_ *Config, _ *slog.Logger, sr *httpcap.SendRequester) (int, error) {
if key == "" {
return 0, fmt.Errorf("courier API key is required but not configured")
}
headers := map[string]string{
"Content-Type": "application/json",
"Api-Key": key,
}
req := &httpcap.Request{
Url: strings.TrimRight(cfg.CourierURL, "/") + "/system/onchain-watcher-events",
Method: "POST",
Headers: headers,
Body: body,
}
resp, err := sr.SendRequest(req).Await()
if err != nil {
return 0, err
}
if resp == nil {
return 0, fmt.Errorf("nil response")
}
// Treat any 4xx/5xx as an error (caller may retry).
if resp.StatusCode >= 400 {
return 0, fmt.Errorf("courier API responded with status %d", resp.StatusCode)
}
return int(resp.StatusCode), nil
},
cre.ConsensusIdenticalAggregation[int](),
).Await()
if err != nil {
return "", err
}

return pre.Base64Event, nil
}

// CheckResponse validates an HTTP response status code and returns it as int.
// Returns an error if the status code exceeds int32 bounds.
func CheckResponse(resp *httpcap.Response) (int, error) {
code := resp.StatusCode
if code > math.MaxInt32 {
return 0, fmt.Errorf("API responded with invalid status code %d", code)
// CheckResponse validates the httpcap response and returns it unchanged if acceptable.
func CheckResponse(resp *httpcap.Response) (*httpcap.Response, error) {
if resp == nil {
return nil, fmt.Errorf("nil response")
}
// Treat any 4xx/5xx as an error (caller may retry).
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("courier API responded with status %d", resp.StatusCode)
}
return int(code), nil
return resp, nil
}

// DecodeEventParams decodes an EVM log's topics/data into a named parameter map, using the provided ABI JSON and event-name.
Expand Down
118 changes: 110 additions & 8 deletions event_processing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ import (
"math/big"
"testing"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
httpcap "github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http"
httpmock "github.com/smartcontractkit/cre-sdk-go/capabilities/networking/http/mock"
"github.com/smartcontractkit/cre-sdk-go/cre/testutils"

"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"
)

Expand All @@ -39,8 +39,9 @@ func TestBuildAndSignEventEnvelope_IncludesParametersInEventAndTopLevel(t *testi
}
params := SanitiseJSON(raw).(map[string]any)

testService := "test_service"
res, err := BuildAndHashEventEnvelope(
"test_service",
&testService,
"Sender",
"0xContract",
testABIForCommon,
Expand Down Expand Up @@ -85,6 +86,108 @@ func TestBuildAndSignEventEnvelope_IncludesParametersInEventAndTopLevel(t *testi
require.Equal(t, "meta", meta["extra"])
}

func TestBuildAndHashEventEnvelope_WithNilService(t *testing.T) {
// prepare parameters
addr := common.HexToAddress("0x1234567890123456789012345678901234567890")
raw := map[string]any{
"Sender": addr.Bytes(),
}
params := SanitiseJSON(raw).(map[string]any)

// Build event envelope with nil service
res, err := BuildAndHashEventEnvelope(
nil,
"Sender",
"0xContract",
testABIForCommon,
"1",
100,
2,
"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1_700_000_000,
params,
map[string]any{"extra": "meta"},
)
require.NoError(t, err)
require.NotEmpty(t, res.Base64Event)
// When service is nil, typeName should be just the event name (no service prefix)
require.Equal(t, "Sender", res.Type)
require.NotEqual(t, common.Hash{}, res.EventHash)

decoded, err := base64.StdEncoding.DecodeString(res.Base64Event)
require.NoError(t, err)
var obj map[string]any
require.NoError(t, json.Unmarshal(decoded, &obj))

ev := obj["event"].(map[string]any)
// service field should not be present when service is nil
_, hasService := ev["service"]
require.False(t, hasService, "service field should not be present when service is nil")
require.Equal(t, "Sender", ev["name"])
require.Equal(t, "0xContract", ev["address"])
}

func TestBuildAndHashEventEnvelope_ServiceHashCompatibility(t *testing.T) {
addr := common.HexToAddress("0x1234567890123456789012345678901234567890")
raw := map[string]any{
"Sender": addr.Bytes(),
}
params := SanitiseJSON(raw).(map[string]any)
metadata := map[string]any{"extra": "meta"}

eventName := "Sender"

resNilService, err := BuildAndHashEventEnvelope(
nil,
eventName,
"0xContract",
testABIForCommon,
"1",
100,
2,
"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1_700_000_000,
params,
metadata,
)
require.NoError(t, err)

testService := "operations"
resWithService, err := BuildAndHashEventEnvelope(
&testService,
eventName,
"0xContract",
testABIForCommon,
"1",
100,
2,
"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
1_700_000_000,
params,
metadata,
)
require.NoError(t, err)

require.Equal(t, eventName, resNilService.Type, "nil service should produce typeName = eventName")
require.Equal(t, "operations."+eventName, resWithService.Type, "service should produce typeName = service.eventName")

// Note: base64 payloads differ because service is included in the event JSON when present
// This is expected behavior - the service field is part of the verifiable event structure
require.NotEqual(t, resNilService.Base64Event, resWithService.Base64Event,
"base64 verifiable event payloads differ because service is included in JSON when present")

require.NotEqual(t, resNilService.EventHash, resWithService.EventHash,
"event hashes must differ when service is nil vs present (compatibility boundary)")

expectedNilHash := common.BytesToHash(crypto.Keccak256([]byte(eventName + "." + resNilService.Base64Event)))
expectedServiceHash := common.BytesToHash(crypto.Keccak256([]byte("operations." + eventName + "." + resWithService.Base64Event)))

require.Equal(t, expectedNilHash, resNilService.EventHash,
"nil-service hash should match keccak256(eventName + \".\" + base64payload)")
require.Equal(t, expectedServiceHash, resWithService.EventHash,
"service-prefixed hash should match keccak256(service.eventName + \".\" + base64payload)")
}

func TestSanitiseJSON_Conversions(t *testing.T) {
// prepare a structure with varied types
b20 := make([]byte, 20) // 20 bytes -> address-like
Expand Down Expand Up @@ -164,11 +267,9 @@ func TestPostSignedEvent_HTTPPayloadStructure(t *testing.T) {
require.NoError(t, json.Unmarshal(req.Body, &body))

// required fields
require.NotEmpty(t, body["event_id"])
require.Equal(t, "test", body["domain"])
require.Equal(t, "Sender", body["name"])
// Courier protocol expects chain_selector as number
require.Equal(t, float64(11155111), body["chain_selector"], "chain_selector should be a number")
require.Equal(t, "11155111", body["chain_selector"], "chain_selector should be a string")
require.Equal(t, "0xABCDEF", body["address"])

// ocr report/context hex encoded
Expand Down Expand Up @@ -196,12 +297,13 @@ func TestPostSignedEvent_HTTPPayloadStructure(t *testing.T) {
}

// Workflow config for POST (use secret id, not inline key)
testService := "test"
cfg := &Config{
Network: "evm",
ChainID: "1",
ChainSelector: "11155111", // Provide explicit selector
CourierURL: "http://example.com",
Service: "test",
Service: &testService,
ApiKeySecret: "courier",
DetectEventTriggerConfig: DetectEventTriggerConfig{
ContractName: "TestConsumer",
Expand All @@ -213,7 +315,7 @@ func TestPostSignedEvent_HTTPPayloadStructure(t *testing.T) {
"Sender": common.HexToAddress("0x1111111111111111111111111111111111111111").Bytes(),
}).(map[string]any)
pre, err := BuildAndHashEventEnvelope(
"test",
&testService,
"Sender",
"0xABCDEF",
testABIForCommon,
Expand Down
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ go 1.25.3

require (
github.com/ethereum/go-ethereum v1.16.7
github.com/google/uuid v1.6.0
github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20251211142334-5c3421fe2c8d
github.com/smartcontractkit/cre-sdk-go v1.1.2
github.com/smartcontractkit/cre-sdk-go/capabilities/blockchain/evm v0.10.0
Expand Down
Loading
Loading