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
10 changes: 9 additions & 1 deletion core/taskengine/event_trigger_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,15 @@ type TransferEventResponse struct {
// Transfer-specific data
FromAddress string `json:"fromAddress"` // Sender address
ToAddress string `json:"toAddress"` // Recipient address
Value string `json:"value"` // Formatted token amount (decimals applied if requested)
// Value is the RAW uint256 transfer amount as a decimal string in base units
// (e.g. "1500000" for 1.5 USDC). This is the on-chain-native form and is what
// downstream BigInt math (split nodes, contract writes, etc.) should consume.
// Convention follows Moralis / Etherscan / The Graph — clients format if needed.
Value string `json:"value"`
// ValueFormatted is the human-readable decimal-adjusted amount as a string
// (e.g. "1.5" for 1.5 USDC). Populated whenever token decimals are known so
// UI/notification nodes can display it without re-computing. Never used for math.
ValueFormatted string `json:"valueFormatted"`
}

// GetSampleTransferAmount returns an appropriate sample transfer amount based on token decimals
Expand Down
23 changes: 23 additions & 0 deletions core/taskengine/loop_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,29 @@ import (
// These functions support template variable substitution, nested node creation, and
// input variable resolution for loop iterations in VM.executeLoopWithQueue.

// propagateLoopExecutionContext copies the ExecutionContext from the first iteration
// step that has one set onto the parent loop step. This is needed because
// CreateNodeExecutionStep can't infer the simulation mode for loops — the simulation
// flag lives on the inner runner (contractWrite, ethTransfer), not the loop itself.
// Without this, a loop wrapping Tenderly-simulated contract writes would mislabel
// itself as is_simulated=false, provider=chain_rpc.
//
// We pick the first non-nil iteration context (rather than e.g. validating all
// match) because all iterations of a loop run through the same runner and therefore
// share the same simulation mode by construction. If iterationSteps is empty or all
// entries lack ExecutionContext, the parent step is left untouched.
func propagateLoopExecutionContext(parent *avsproto.Execution_Step, iterationSteps []*avsproto.Execution_Step) {
if parent == nil {
return
}
for _, iterStep := range iterationSteps {
if iterStep != nil && iterStep.ExecutionContext != nil {
parent.ExecutionContext = iterStep.ExecutionContext
return
}
}
}

// substituteTemplateVariables replaces template variables like {{value}} and {{index}} with actual values.
// This is a pure function with no VM dependency.
//
Expand Down
93 changes: 93 additions & 0 deletions core/taskengine/loop_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package taskengine

import (
"testing"

avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf"
"google.golang.org/protobuf/types/known/structpb"
)

// TestPropagateLoopExecutionContext verifies that a loop step inherits the
// ExecutionContext from its first iteration step that has one. This is what
// makes a loop wrapping Tenderly-simulated contract writes correctly report
// `is_simulated: true, provider: tenderly` instead of the misleading
// `chain_rpc` default that CreateNodeExecutionStep would otherwise leave behind.
func TestPropagateLoopExecutionContext(t *testing.T) {
mkCtx := func(simulated bool, provider string) *structpb.Value {
v, err := structpb.NewValue(map[string]interface{}{
"is_simulated": simulated,
"provider": provider,
"chain_id": 11155111,
})
if err != nil {
t.Fatalf("structpb.NewValue: %v", err)
}
return v
}

t.Run("propagates from first iteration with context", func(t *testing.T) {
parent := &avsproto.Execution_Step{Id: "loop1"}
iter0 := &avsproto.Execution_Step{Id: "iter0", ExecutionContext: mkCtx(true, "tenderly")}
iter1 := &avsproto.Execution_Step{Id: "iter1", ExecutionContext: mkCtx(true, "tenderly")}

propagateLoopExecutionContext(parent, []*avsproto.Execution_Step{iter0, iter1})

if parent.ExecutionContext == nil {
t.Fatal("expected parent ExecutionContext to be set")
}
ctxMap := parent.ExecutionContext.GetStructValue().AsMap()
if got := ctxMap["is_simulated"]; got != true {
t.Errorf("is_simulated = %v, want true", got)
}
if got := ctxMap["provider"]; got != "tenderly" {
t.Errorf("provider = %v, want tenderly", got)
}
})

t.Run("skips nil iterations and picks first non-nil context", func(t *testing.T) {
parent := &avsproto.Execution_Step{Id: "loop1"}
iter0 := &avsproto.Execution_Step{Id: "iter0"} // no ExecutionContext
iter1 := (*avsproto.Execution_Step)(nil)
iter2 := &avsproto.Execution_Step{Id: "iter2", ExecutionContext: mkCtx(false, "bundler")}

propagateLoopExecutionContext(parent, []*avsproto.Execution_Step{iter0, iter1, iter2})

if parent.ExecutionContext == nil {
t.Fatal("expected parent ExecutionContext to be set from iter2")
}
ctxMap := parent.ExecutionContext.GetStructValue().AsMap()
if got := ctxMap["provider"]; got != "bundler" {
t.Errorf("provider = %v, want bundler", got)
}
if got := ctxMap["is_simulated"]; got != false {
t.Errorf("is_simulated = %v, want false", got)
}
})

t.Run("leaves parent untouched when no iteration has context", func(t *testing.T) {
baseline := mkCtx(false, "chain_rpc")
parent := &avsproto.Execution_Step{Id: "loop1", ExecutionContext: baseline}
iter0 := &avsproto.Execution_Step{Id: "iter0"}

propagateLoopExecutionContext(parent, []*avsproto.Execution_Step{iter0})

if parent.ExecutionContext != baseline {
t.Error("expected parent ExecutionContext to remain unchanged when no iteration provides one")
}
})

t.Run("handles empty iterations slice", func(t *testing.T) {
parent := &avsproto.Execution_Step{Id: "loop1"}
propagateLoopExecutionContext(parent, nil)
if parent.ExecutionContext != nil {
t.Error("expected parent ExecutionContext to remain nil for empty iterations")
}
})

t.Run("handles nil parent gracefully", func(t *testing.T) {
// Should not panic.
propagateLoopExecutionContext(nil, []*avsproto.Execution_Step{
{ExecutionContext: mkCtx(true, "tenderly")},
})
})
}
79 changes: 56 additions & 23 deletions core/taskengine/run_node_immediately.go
Original file line number Diff line number Diff line change
Expand Up @@ -1576,8 +1576,40 @@ func (n *Engine) runEventTriggerWithTenderlySimulation(ctx context.Context, quer
"methodCallsCount", methodCallsCount)
}

// Deprecation notice: applyDecimalsTo on Transfer.value is now a no-op.
// The enricher unconditionally emits both `value` (raw uint256 base units) and
// `valueFormatted` (decimal-adjusted) for every Transfer event, so the hint is
// redundant. Kept honored silently for non-Transfer fields where it's still
// meaningful (e.g. Uniswap Swap.amountIn). SDK clients should drop the hint
// from Transfer triggers.
if n.logger != nil && query != nil {
for _, mc := range query.GetMethodCalls() {
for _, field := range mc.GetApplyToFields() {
if strings.HasPrefix(strings.ToLower(field), "transfer.") {
n.logger.Warn("⚠️ DEPRECATED: applyDecimalsTo on Transfer fields is a no-op; the response always contains both 'value' (raw) and 'valueFormatted'. Drop this hint from Transfer triggers.",
"field", field,
"methodName", mc.GetMethodName())
}
}
}
}

// Pre-resolve token decimals so the synthetic Transfer amount represents ~1.5 tokens
// regardless of decimal precision (USDC has 6, most ERC20s have 18). Without this,
// the default synthetic value dwarfs realistic wallet balances and downstream
// Tenderly transfer simulations revert with "ERC20: transfer amount exceeds balance".
// Cached after first lookup, so subsequent simulations are free.
// nil = unknown (fall back to 18); a real 0 is preserved (legitimate ERC20 case).
var simulatedTokenDecimals *uint32
if n.tokenEnrichmentService != nil && len(query.GetAddresses()) > 0 {
if md, mdErr := n.tokenEnrichmentService.GetTokenMetadata(query.GetAddresses()[0]); mdErr == nil && md != nil {
d := md.Decimals
simulatedTokenDecimals = &d
}
}

// Simulate the event using Tenderly (gets real current data)
simulatedLog, err := tenderlyClient.SimulateEventTrigger(ctx, query, chainID)
simulatedLog, err := tenderlyClient.SimulateEventTriggerWithDecimals(ctx, query, chainID, simulatedTokenDecimals)
if err != nil {
n.logger.Warn("Tenderly simulation failed", "error", err)
return nil, fmt.Errorf("tenderly event simulation failed: %w", err)
Expand Down Expand Up @@ -2129,47 +2161,47 @@ func (n *Engine) parseEventWithParsedABI(eventLog *types.Log, contractABI *abi.A
transferResponse.ToAddress = toAddr
}

// Resolve raw value once. Prefer "valueRaw" (ABI parser stores the unmodified
// uint256 there); fall back to "value" if the ABI parser hasn't formatted it.
// transferResponse.Value MUST always be the raw base-units string so downstream
// BigInt() math works. valueFormatted gets the human-readable string when we
// know the decimals.
var rawValueStr string
if rv, ok := eventFields["valueRaw"].(string); ok && rv != "" {
rawValueStr = rv
} else if v, ok := eventFields["value"].(string); ok {
rawValueStr = v
}
if rawValueStr != "" {
transferResponse.Value = rawValueStr
}

// Populate token metadata if available
if tokenMetadata != nil {
transferResponse.TokenName = tokenMetadata.Name
transferResponse.TokenSymbol = tokenMetadata.Symbol
transferResponse.TokenDecimals = tokenMetadata.Decimals

// Use the formatted value from ABI parsing if available, otherwise format using token metadata
if formattedValue, hasFormatted := eventFields["value"]; hasFormatted {
if valueStr, ok := formattedValue.(string); ok {
transferResponse.Value = valueStr
}
} else if rawValue, ok := eventFields["valueRaw"].(string); ok {
// Fallback: format using token metadata if ABI didn't format it
formattedValue := n.tokenEnrichmentService.FormatTokenValue(rawValue, tokenMetadata.Decimals)
transferResponse.Value = formattedValue
if rawValueStr != "" && n.tokenEnrichmentService != nil {
transferResponse.ValueFormatted = n.tokenEnrichmentService.FormatTokenValue(rawValueStr, tokenMetadata.Decimals)
}

if n.logger != nil {
n.logger.Info("✅ Transfer event enrichment completed",
"tokenSymbol", tokenMetadata.Symbol,
"tokenName", tokenMetadata.Name,
"decimals", tokenMetadata.Decimals,
"value", transferResponse.Value)
"value", transferResponse.Value,
"valueFormatted", transferResponse.ValueFormatted)
}
} else {
// Even without token metadata, try to format using decimals from method call
if decimalsValue != nil && len(fieldsToFormat) > 0 {
decimalsUint32 := uint32(decimalsValue.Uint64())
transferResponse.TokenDecimals = decimalsUint32

// Use the formatted value from ABI parsing if available, otherwise format using method call decimals
if formattedValue, hasFormatted := eventFields["value"]; hasFormatted {
if valueStr, ok := formattedValue.(string); ok {
transferResponse.Value = valueStr
}
} else if rawValue, ok := eventFields["valueRaw"].(string); ok {
// Fallback: format using method call decimals if ABI didn't format it
if n.tokenEnrichmentService != nil {
formattedValue := n.tokenEnrichmentService.FormatTokenValue(rawValue, decimalsUint32)
transferResponse.Value = formattedValue
}
if rawValueStr != "" && n.tokenEnrichmentService != nil {
transferResponse.ValueFormatted = n.tokenEnrichmentService.FormatTokenValue(rawValueStr, decimalsUint32)
}
}

Expand Down Expand Up @@ -2207,7 +2239,8 @@ func (n *Engine) parseEventWithParsedABI(eventLog *types.Log, contractABI *abi.A
"blockTimestamp": transferResponse.BlockTimestamp,
"fromAddress": transferResponse.FromAddress,
"toAddress": transferResponse.ToAddress,
"value": transferResponse.Value,
"value": transferResponse.Value, // Raw uint256 base units (for math)
"valueFormatted": transferResponse.ValueFormatted, // Decimal-adjusted (for display)
}

// Return structured format for Transfer events too
Expand Down
50 changes: 29 additions & 21 deletions core/taskengine/shared_event_enrichment.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,38 +264,45 @@ func enrichTransferEventShared(eventLog *types.Log, parsedData map[string]interf
}
}

// Populate token metadata if available
// Always populate the raw value from the parsed event, regardless of whether
// token metadata is available. The new schema contract is that `value` is the
// raw uint256 base-units string — clients depend on this for BigInt math even
// when metadata lookup fails (token not in whitelist, RPC error, etc.).
// `valueFormatted` is only computed when we know the decimals.
var rawValueStr string
if transferEventData != nil {
if rawValue, ok := transferEventData["value"]; ok {
switch v := rawValue.(type) {
case *big.Int:
rawValueStr = v.String()
case string:
rawValueStr = v
default:
rawValueStr = fmt.Sprintf("%v", v)
}
}
}
if rawValueStr != "" {
transferResponse.Value = rawValueStr
}

// Populate token metadata and the formatted value if available
if tokenMetadata != nil {
transferResponse.TokenName = tokenMetadata.Name
transferResponse.TokenSymbol = tokenMetadata.Symbol
transferResponse.TokenDecimals = tokenMetadata.Decimals

// Format the value using token metadata
if transferEventData != nil {
if rawValue, ok := transferEventData["value"]; ok {
var valueStr string

// Handle different value types (big.Int, string, etc.)
switch v := rawValue.(type) {
case *big.Int:
valueStr = v.String()
case string:
valueStr = v
default:
valueStr = fmt.Sprintf("%v", v)
}

formattedValue := tokenService.FormatTokenValue(valueStr, tokenMetadata.Decimals)
transferResponse.Value = formattedValue
}
if rawValueStr != "" {
transferResponse.ValueFormatted = tokenService.FormatTokenValue(rawValueStr, tokenMetadata.Decimals)
}

if logger != nil {
logger.Info("✅ SharedEventEnrichment: Transfer event enrichment completed",
"tokenSymbol", tokenMetadata.Symbol,
"tokenName", tokenMetadata.Name,
"decimals", tokenMetadata.Decimals,
"value", transferResponse.Value)
"value", transferResponse.Value,
"valueFormatted", transferResponse.ValueFormatted)
}
}

Expand Down Expand Up @@ -327,7 +334,8 @@ func enrichTransferEventShared(eventLog *types.Log, parsedData map[string]interf
"blockTimestamp": transferResponse.BlockTimestamp,
"fromAddress": transferResponse.FromAddress,
"toAddress": transferResponse.ToAddress,
"value": transferResponse.Value,
"value": transferResponse.Value, // Raw uint256 base units (for math)
"valueFormatted": transferResponse.ValueFormatted, // Decimal-adjusted (for display)
"eventName": "Transfer",
"direction": direction, // New field indicating "sent" or "received"
"walletAddress": userWalletAddress, // New field with detected user wallet
Expand Down
Loading
Loading