From 9e6d970d851d0a0eba572c389f6b6144087fd7ec Mon Sep 17 00:00:00 2001 From: Will Zimmerman Date: Tue, 7 Apr 2026 22:52:59 -0700 Subject: [PATCH 1/2] fix: improve event trigger simulation accuracy and response schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scale synthetic Transfer amount by token decimals so it represents ~1.5 tokens regardless of precision (was hardcoded 1.5e18, which became 1.5T units for 6-decimal tokens like USDC and caused downstream Tenderly transfer simulations to revert with "exceeds balance"). - Emit both `value` (raw uint256 base units) and `valueFormatted` (decimal-adjusted) on Transfer event responses, matching the Moralis/Etherscan/subgraph convention. Downstream BigInt math now consumes `value` directly without crashing on formatted floats. - Deprecate `applyDecimalsTo` for Transfer fields — the enricher unconditionally produces both formats, so the hint is redundant. Logged as a warning; still honored for non-Transfer fields. - Propagate ExecutionContext from inner loop iteration steps up to the parent loop step so loops wrapping Tenderly-simulated writes correctly report `is_simulated: true, provider: tenderly` instead of the misleading `chain_rpc` default. --- core/taskengine/event_trigger_constants.go | 10 ++- core/taskengine/run_node_immediately.go | 77 +++++++++++++++------- core/taskengine/shared_event_enrichment.go | 16 +++-- core/taskengine/tenderly_client.go | 35 ++++++++-- core/taskengine/vm.go | 12 ++++ 5 files changed, 115 insertions(+), 35 deletions(-) diff --git a/core/taskengine/event_trigger_constants.go b/core/taskengine/event_trigger_constants.go index d4002d3d..2c6b8dbb 100644 --- a/core/taskengine/event_trigger_constants.go +++ b/core/taskengine/event_trigger_constants.go @@ -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 diff --git a/core/taskengine/run_node_immediately.go b/core/taskengine/run_node_immediately.go index f8bb8059..0e007294 100644 --- a/core/taskengine/run_node_immediately.go +++ b/core/taskengine/run_node_immediately.go @@ -1576,8 +1576,38 @@ 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. + var simulatedTokenDecimals uint32 + if n.tokenEnrichmentService != nil && len(query.GetAddresses()) > 0 { + if md, mdErr := n.tokenEnrichmentService.GetTokenMetadata(query.GetAddresses()[0]); mdErr == nil && md != nil { + simulatedTokenDecimals = md.Decimals + } + } + // 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) @@ -2129,21 +2159,29 @@ 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 { @@ -2151,7 +2189,8 @@ func (n *Engine) parseEventWithParsedABI(eventLog *types.Log, contractABI *abi.A "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 @@ -2159,17 +2198,8 @@ func (n *Engine) parseEventWithParsedABI(eventLog *types.Log, contractABI *abi.A 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) } } @@ -2207,7 +2237,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 diff --git a/core/taskengine/shared_event_enrichment.go b/core/taskengine/shared_event_enrichment.go index 1b08cf31..29a14d4c 100644 --- a/core/taskengine/shared_event_enrichment.go +++ b/core/taskengine/shared_event_enrichment.go @@ -270,7 +270,11 @@ func enrichTransferEventShared(eventLog *types.Log, parsedData map[string]interf transferResponse.TokenSymbol = tokenMetadata.Symbol transferResponse.TokenDecimals = tokenMetadata.Decimals - // Format the value using token metadata + // Populate both raw and formatted value: + // - value → raw uint256 base-units string (for on-chain math, e.g. BigInt()) + // - valueFormatted → human-readable decimal-adjusted string (for display) + // This matches Moralis / Etherscan / subgraph conventions and avoids the + // foot-gun where downstream BigInt() consumers receive "1.5" and crash. if transferEventData != nil { if rawValue, ok := transferEventData["value"]; ok { var valueStr string @@ -285,8 +289,8 @@ func enrichTransferEventShared(eventLog *types.Log, parsedData map[string]interf valueStr = fmt.Sprintf("%v", v) } - formattedValue := tokenService.FormatTokenValue(valueStr, tokenMetadata.Decimals) - transferResponse.Value = formattedValue + transferResponse.Value = valueStr + transferResponse.ValueFormatted = tokenService.FormatTokenValue(valueStr, tokenMetadata.Decimals) } } @@ -295,7 +299,8 @@ func enrichTransferEventShared(eventLog *types.Log, parsedData map[string]interf "tokenSymbol", tokenMetadata.Symbol, "tokenName", tokenMetadata.Name, "decimals", tokenMetadata.Decimals, - "value", transferResponse.Value) + "value", transferResponse.Value, + "valueFormatted", transferResponse.ValueFormatted) } } @@ -327,7 +332,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 diff --git a/core/taskengine/tenderly_client.go b/core/taskengine/tenderly_client.go index 736fad76..9a274ac1 100644 --- a/core/taskengine/tenderly_client.go +++ b/core/taskengine/tenderly_client.go @@ -89,8 +89,18 @@ func NewTenderlyClient(cfg *config.Config, logger sdklogging.Logger) *TenderlyCl return tc } -// SimulateEventTrigger simulates transactions to generate realistic event data +// SimulateEventTrigger simulates transactions to generate realistic event data. +// For Transfer events, the default synthetic amount assumes 18 decimals; callers +// that know the token's actual decimals should use SimulateEventTriggerWithDecimals +// so the synthetic value scales correctly (e.g., USDC with 6 decimals). func (tc *TenderlyClient) SimulateEventTrigger(ctx context.Context, query *avsproto.EventTrigger_Query, chainID int64) (*types.Log, error) { + return tc.SimulateEventTriggerWithDecimals(ctx, query, chainID, 0) +} + +// SimulateEventTriggerWithDecimals is like SimulateEventTrigger but lets callers +// pass the token's decimals so simulated Transfer amounts represent ~1.5 tokens +// regardless of the token's decimal precision. Pass 0 to use the 18-decimal default. +func (tc *TenderlyClient) SimulateEventTriggerWithDecimals(ctx context.Context, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals uint32) (*types.Log, error) { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("context error before simulation: %w", err) } @@ -112,7 +122,7 @@ func (tc *TenderlyClient) SimulateEventTrigger(ctx context.Context, query *avspr } if isTransferEvent { - return tc.simulateTransferEvent(ctx, contractAddress, query, chainID) + return tc.simulateTransferEvent(ctx, contractAddress, query, chainID, tokenDecimals) } // Generic simulation for other event types: build a plausible log from input @@ -275,8 +285,10 @@ func (tc *TenderlyClient) createMockAnswerUpdatedLog(contractAddress string, pri } } -// simulateTransferEvent simulates an actual ERC20 transfer transaction using Tenderly simulation API -func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAddress string, query *avsproto.EventTrigger_Query, chainID int64) (*types.Log, error) { +// simulateTransferEvent simulates an actual ERC20 transfer transaction using Tenderly simulation API. +// tokenDecimals scales the synthetic default amount (1.5 tokens) to the token's decimal precision; +// pass 0 to default to 18 decimals. +func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAddress string, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals uint32) (*types.Log, error) { tc.logger.Info("🔮 Simulating ERC20 Transfer transaction via Tenderly API", "contract", contractAddress, "chain_id", chainID) @@ -322,8 +334,19 @@ func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAdd toAddress = common.HexToAddress("0x0000000000000000000000000000000000000002") } - // Extract transfer amount from query conditions (optional) - transferAmount := big.NewInt(1500000000000000000) // Default 1.5 tokens for simulation + // Default synthetic amount: 1.5 tokens, scaled to the token's actual decimals. + // Without scaling, an 18-decimal default (1.5e18) becomes 1.5 trillion units for + // a 6-decimal token like USDC, which then dwarfs any realistic wallet balance and + // causes downstream Tenderly transfer simulations to revert with "exceeds balance". + decimalsForScale := tokenDecimals + if decimalsForScale == 0 { + decimalsForScale = 18 + } + // transferAmount = 15 * 10^(decimalsForScale-1) → represents 1.5 tokens + transferAmount := new(big.Int).Mul( + big.NewInt(15), + new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimalsForScale)-1), nil), + ) for _, condition := range query.GetConditions() { if condition.GetFieldName() == "value" || condition.GetFieldName() == "amount" { diff --git a/core/taskengine/vm.go b/core/taskengine/vm.go index 17672312..87594623 100644 --- a/core/taskengine/vm.go +++ b/core/taskengine/vm.go @@ -4800,6 +4800,18 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no // Aggregate gas costs from iteration steps into the parent loop step aggregateIterationGasCosts(s, iterationSteps, v) + // Propagate ExecutionContext from inner iteration steps up to the parent loop step. + // CreateNodeExecutionStep can't infer this for loops because 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. + for _, iterStep := range iterationSteps { + if iterStep != nil && iterStep.ExecutionContext != nil { + s.ExecutionContext = iterStep.ExecutionContext + break + } + } + // Set output variable for this step processor := &CommonProcessor{vm: v} setNodeOutputData(processor, stepID, results) From 3ac89b3830da4683c62b96fef625ac7c0bca51b0 Mon Sep 17 00:00:00 2001 From: Will Zimmerman Date: Tue, 7 Apr 2026 23:11:24 -0700 Subject: [PATCH 2/2] fix: address Copilot review feedback on event trigger simulation - Populate raw value unconditionally from parsed event data, even when token metadata lookup fails. The new schema contract is that value is always the raw uint256 base-units string; only valueFormatted depends on knowing decimals. Fixes a regression where tokens not in the whitelist would silently keep the default value of "0". - Disambiguate "unknown decimals" from "0 decimals" in the synthetic Transfer amount path by switching the API to *uint32. Some ERC20 tokens legitimately have 0 decimals; treating 0 as a sentinel for "unknown, assume 18" would scale them incorrectly. nil now means unknown (fall back to 18), 0 is honored as a real value (use 1 unit since fractional amounts are not representable). - Extract loop ExecutionContext propagation into a testable helper and add unit tests covering propagation from first iteration with context, skipping nil/empty iterations, leaving the parent untouched when no iteration provides context, and graceful handling of nil parent / empty iteration slice. --- core/taskengine/loop_helpers.go | 23 ++++++ core/taskengine/loop_helpers_test.go | 93 ++++++++++++++++++++++ core/taskengine/run_node_immediately.go | 6 +- core/taskengine/shared_event_enrichment.go | 48 +++++------ core/taskengine/tenderly_client.go | 48 +++++++---- core/taskengine/vm.go | 11 +-- 6 files changed, 178 insertions(+), 51 deletions(-) create mode 100644 core/taskengine/loop_helpers_test.go diff --git a/core/taskengine/loop_helpers.go b/core/taskengine/loop_helpers.go index d44626a2..a37afd86 100644 --- a/core/taskengine/loop_helpers.go +++ b/core/taskengine/loop_helpers.go @@ -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. // diff --git a/core/taskengine/loop_helpers_test.go b/core/taskengine/loop_helpers_test.go new file mode 100644 index 00000000..11687d61 --- /dev/null +++ b/core/taskengine/loop_helpers_test.go @@ -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")}, + }) + }) +} diff --git a/core/taskengine/run_node_immediately.go b/core/taskengine/run_node_immediately.go index 0e007294..6994d305 100644 --- a/core/taskengine/run_node_immediately.go +++ b/core/taskengine/run_node_immediately.go @@ -1599,10 +1599,12 @@ func (n *Engine) runEventTriggerWithTenderlySimulation(ctx context.Context, quer // 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. - var simulatedTokenDecimals uint32 + // 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 { - simulatedTokenDecimals = md.Decimals + d := md.Decimals + simulatedTokenDecimals = &d } } diff --git a/core/taskengine/shared_event_enrichment.go b/core/taskengine/shared_event_enrichment.go index 29a14d4c..2ae9db9c 100644 --- a/core/taskengine/shared_event_enrichment.go +++ b/core/taskengine/shared_event_enrichment.go @@ -264,34 +264,36 @@ 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 - // Populate both raw and formatted value: - // - value → raw uint256 base-units string (for on-chain math, e.g. BigInt()) - // - valueFormatted → human-readable decimal-adjusted string (for display) - // This matches Moralis / Etherscan / subgraph conventions and avoids the - // foot-gun where downstream BigInt() consumers receive "1.5" and crash. - 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) - } - - transferResponse.Value = valueStr - transferResponse.ValueFormatted = tokenService.FormatTokenValue(valueStr, tokenMetadata.Decimals) - } + if rawValueStr != "" { + transferResponse.ValueFormatted = tokenService.FormatTokenValue(rawValueStr, tokenMetadata.Decimals) } if logger != nil { diff --git a/core/taskengine/tenderly_client.go b/core/taskengine/tenderly_client.go index 9a274ac1..4e0f0b49 100644 --- a/core/taskengine/tenderly_client.go +++ b/core/taskengine/tenderly_client.go @@ -94,13 +94,16 @@ func NewTenderlyClient(cfg *config.Config, logger sdklogging.Logger) *TenderlyCl // that know the token's actual decimals should use SimulateEventTriggerWithDecimals // so the synthetic value scales correctly (e.g., USDC with 6 decimals). func (tc *TenderlyClient) SimulateEventTrigger(ctx context.Context, query *avsproto.EventTrigger_Query, chainID int64) (*types.Log, error) { - return tc.SimulateEventTriggerWithDecimals(ctx, query, chainID, 0) + return tc.SimulateEventTriggerWithDecimals(ctx, query, chainID, nil) } // SimulateEventTriggerWithDecimals is like SimulateEventTrigger but lets callers // pass the token's decimals so simulated Transfer amounts represent ~1.5 tokens -// regardless of the token's decimal precision. Pass 0 to use the 18-decimal default. -func (tc *TenderlyClient) SimulateEventTriggerWithDecimals(ctx context.Context, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals uint32) (*types.Log, error) { +// regardless of the token's decimal precision. Pass nil if the token's decimals +// are unknown — the simulator will fall back to an 18-decimal assumption. Note +// that 0 is a legitimate decimals value (some ERC20s have it), so a pointer is +// used to disambiguate "unknown" from "zero". +func (tc *TenderlyClient) SimulateEventTriggerWithDecimals(ctx context.Context, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals *uint32) (*types.Log, error) { if err := ctx.Err(); err != nil { return nil, fmt.Errorf("context error before simulation: %w", err) } @@ -286,9 +289,10 @@ func (tc *TenderlyClient) createMockAnswerUpdatedLog(contractAddress string, pri } // simulateTransferEvent simulates an actual ERC20 transfer transaction using Tenderly simulation API. -// tokenDecimals scales the synthetic default amount (1.5 tokens) to the token's decimal precision; -// pass 0 to default to 18 decimals. -func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAddress string, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals uint32) (*types.Log, error) { +// tokenDecimals scales the synthetic default amount (1.5 tokens) to the token's decimal precision. +// Pass nil for unknown decimals — the simulator falls back to 18. Pointer is used so that 0 (a +// legitimate ERC20 decimals value) is not conflated with "unknown". +func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAddress string, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals *uint32) (*types.Log, error) { tc.logger.Info("🔮 Simulating ERC20 Transfer transaction via Tenderly API", "contract", contractAddress, "chain_id", chainID) @@ -334,19 +338,31 @@ func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAdd toAddress = common.HexToAddress("0x0000000000000000000000000000000000000002") } - // Default synthetic amount: 1.5 tokens, scaled to the token's actual decimals. + // Default synthetic amount: ~1.5 tokens, scaled to the token's actual decimals. // Without scaling, an 18-decimal default (1.5e18) becomes 1.5 trillion units for // a 6-decimal token like USDC, which then dwarfs any realistic wallet balance and // causes downstream Tenderly transfer simulations to revert with "exceeds balance". - decimalsForScale := tokenDecimals - if decimalsForScale == 0 { - decimalsForScale = 18 - } - // transferAmount = 15 * 10^(decimalsForScale-1) → represents 1.5 tokens - transferAmount := new(big.Int).Mul( - big.NewInt(15), - new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimalsForScale)-1), nil), - ) + // + // Decimal handling: + // - tokenDecimals == nil → unknown, fall back to 18-decimal assumption + // - tokenDecimals == 0 → real 0-decimal token (e.g. some governance tokens); + // fractional amounts aren't representable, use 1 unit + // - tokenDecimals > 0 → represent 1.5 tokens as 15 * 10^(decimals-1) + var transferAmount *big.Int + switch { + case tokenDecimals == nil: + transferAmount = new(big.Int).Mul( + big.NewInt(15), + new(big.Int).Exp(big.NewInt(10), big.NewInt(17), nil), // 1.5 * 10^18 + ) + case *tokenDecimals == 0: + transferAmount = big.NewInt(1) + default: + transferAmount = new(big.Int).Mul( + big.NewInt(15), + new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(*tokenDecimals)-1), nil), + ) + } for _, condition := range query.GetConditions() { if condition.GetFieldName() == "value" || condition.GetFieldName() == "amount" { diff --git a/core/taskengine/vm.go b/core/taskengine/vm.go index 87594623..8c9d04c4 100644 --- a/core/taskengine/vm.go +++ b/core/taskengine/vm.go @@ -4801,16 +4801,7 @@ func (v *VM) executeLoopWithQueue(stepID string, taskNode *avsproto.TaskNode, no aggregateIterationGasCosts(s, iterationSteps, v) // Propagate ExecutionContext from inner iteration steps up to the parent loop step. - // CreateNodeExecutionStep can't infer this for loops because 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. - for _, iterStep := range iterationSteps { - if iterStep != nil && iterStep.ExecutionContext != nil { - s.ExecutionContext = iterStep.ExecutionContext - break - } - } + propagateLoopExecutionContext(s, iterationSteps) // Set output variable for this step processor := &CommonProcessor{vm: v}