Skip to content

Commit 763a2bd

Browse files
committed
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.
1 parent 9e6d970 commit 763a2bd

11 files changed

Lines changed: 414 additions & 64 deletions

core/taskengine/engine.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2928,7 +2928,7 @@ func (n *Engine) SimulateTask(user *model.User, trigger *avsproto.TaskTrigger, n
29282928
Index: task.ExecutionCount, // Use current execution count for simulation (0-based)
29292929
ExecutionFee: buildExecutionFee(n.config.FeeRates),
29302930
Cogs: buildCOGSFromSteps(vm.ExecutionLogs),
2931-
ValueFee: buildValueFee(vm.ExecutionLogs, n.config.FeeRates),
2931+
ValueFee: buildValueFee(task.Nodes, n.config.FeeRates),
29322932
}
29332933

29342934
// Log execution status based on result type

core/taskengine/executor.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -629,7 +629,7 @@ func (x *TaskExecutor) RunTask(task *model.Task, queueData *QueueExecutionData)
629629
execution.Steps = vm.ExecutionLogs // Contains all steps including failed ones
630630
execution.ExecutionFee = buildExecutionFee(x.engine.config.FeeRates)
631631
execution.Cogs = buildCOGSFromSteps(vm.ExecutionLogs)
632-
execution.ValueFee = buildValueFee(vm.ExecutionLogs, x.engine.config.FeeRates)
632+
execution.ValueFee = buildValueFee(task.Nodes, x.engine.config.FeeRates)
633633

634634
// Value fee recording placeholder.
635635
// V1: value fees are not recorded because actual transaction value (the base for

core/taskengine/fee_estimator.go

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -562,22 +562,24 @@ func buildExecutionFee(feeRatesConfig *config.FeeRatesConfig) *avsproto.Fee {
562562
}
563563

564564
// buildValueFee classifies the workflow and returns the value fee.
565-
// Uses the task's nodes to determine if on-chain execution is present.
566-
func buildValueFee(steps []*avsproto.Execution_Step, feeRatesConfig *config.FeeRatesConfig) *avsproto.ValueFee {
565+
// Classification is based on the workflow definition (nodes), not on runtime
566+
// gas presence — otherwise simulation runs (Tenderly, no real gas) would be
567+
// classified differently from production runs of the same workflow.
568+
func buildValueFee(nodes []*avsproto.TaskNode, feeRatesConfig *config.FeeRatesConfig) *avsproto.ValueFee {
567569
rates := convertFeeRatesConfig(feeRatesConfig)
568570

569571
hasOnChainSteps := false
570-
for _, step := range steps {
571-
stepType := step.Type
572-
if stepType == avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String() ||
573-
stepType == avsproto.NodeType_NODE_TYPE_ETH_TRANSFER.String() {
572+
for _, node := range nodes {
573+
switch {
574+
case node.GetEthTransfer() != nil, node.GetContractWrite() != nil:
574575
hasOnChainSteps = true
575-
break
576+
case node.GetLoop() != nil:
577+
loop := node.GetLoop()
578+
if loop.GetEthTransfer() != nil || loop.GetContractWrite() != nil {
579+
hasOnChainSteps = true
580+
}
576581
}
577-
// A loop step that recorded gas costs ran on-chain work via its inner runner
578-
if stepType == avsproto.NodeType_NODE_TYPE_LOOP.String() &&
579-
step.TotalGasCost != "" && step.TotalGasCost != "0" {
580-
hasOnChainSteps = true
582+
if hasOnChainSteps {
581583
break
582584
}
583585
}

core/taskengine/fee_estimator_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,46 @@ func TestClassifyWorkflowValue_OnChainDetection(t *testing.T) {
110110
assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_UNSPECIFIED, resp.Tier, "Empty node should be UNSPECIFIED")
111111
}
112112

113+
// TestBuildValueFee_ClassifiesByDefinitionNotGas asserts that the post-execution
114+
// value-fee classifier looks at the workflow definition (task.Nodes), not at
115+
// runtime gas presence in the execution logs. Without this, simulation runs
116+
// (Tenderly, no real gas) get classified as "no on-chain execution nodes" while
117+
// production runs of the same workflow get Tier 1, causing fee divergence.
118+
func TestBuildValueFee_ClassifiesByDefinitionNotGas(t *testing.T) {
119+
// Revenue-splitter shape: customCode (split) -> loop(contractWrite).
120+
// In simulation the loop step has no TotalGasCost, but it must still be
121+
// classified as on-chain because the workflow definition says so.
122+
splitterNodes := []*avsproto.TaskNode{
123+
{TaskType: &avsproto.TaskNode_CustomCode{CustomCode: &avsproto.CustomCodeNode{}}},
124+
{TaskType: &avsproto.TaskNode_Loop{Loop: &avsproto.LoopNode{
125+
Runner: &avsproto.LoopNode_ContractWrite{ContractWrite: &avsproto.ContractWriteNode{}},
126+
}}},
127+
}
128+
129+
vf := buildValueFee(splitterNodes, nil)
130+
require.NotNil(t, vf)
131+
assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_1, vf.Tier,
132+
"loop(contractWrite) workflow must be Tier 1 even when simulation reports zero gas")
133+
assert.Equal(t, "0.03", vf.Fee.Amount)
134+
assert.Equal(t, "PERCENTAGE", vf.Fee.Unit)
135+
136+
// Pure off-chain workflow stays UNSPECIFIED / 0%.
137+
offChainNodes := []*avsproto.TaskNode{
138+
{TaskType: &avsproto.TaskNode_CustomCode{CustomCode: &avsproto.CustomCodeNode{}}},
139+
{TaskType: &avsproto.TaskNode_RestApi{RestApi: &avsproto.RestAPINode{}}},
140+
}
141+
vfOff := buildValueFee(offChainNodes, nil)
142+
assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_UNSPECIFIED, vfOff.Tier)
143+
assert.Equal(t, "0", vfOff.Fee.Amount)
144+
145+
// Direct contractWrite (no loop) classifies as on-chain.
146+
directNodes := []*avsproto.TaskNode{
147+
{TaskType: &avsproto.TaskNode_ContractWrite{ContractWrite: &avsproto.ContractWriteNode{}}},
148+
}
149+
vfDirect := buildValueFee(directNodes, nil)
150+
assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_1, vfDirect.Tier)
151+
}
152+
113153
func TestDefaultFeeRates(t *testing.T) {
114154
rates := getDefaultFeeRates()
115155

core/taskengine/loop_helpers.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,29 @@ import (
1414
// These functions support template variable substitution, nested node creation, and
1515
// input variable resolution for loop iterations in VM.executeLoopWithQueue.
1616

17+
// propagateLoopExecutionContext copies the ExecutionContext from the first iteration
18+
// step that has one set onto the parent loop step. This is needed because
19+
// CreateNodeExecutionStep can't infer the simulation mode for loops — the simulation
20+
// flag lives on the inner runner (contractWrite, ethTransfer), not the loop itself.
21+
// Without this, a loop wrapping Tenderly-simulated contract writes would mislabel
22+
// itself as is_simulated=false, provider=chain_rpc.
23+
//
24+
// We pick the first non-nil iteration context (rather than e.g. validating all
25+
// match) because all iterations of a loop run through the same runner and therefore
26+
// share the same simulation mode by construction. If iterationSteps is empty or all
27+
// entries lack ExecutionContext, the parent step is left untouched.
28+
func propagateLoopExecutionContext(parent *avsproto.Execution_Step, iterationSteps []*avsproto.Execution_Step) {
29+
if parent == nil {
30+
return
31+
}
32+
for _, iterStep := range iterationSteps {
33+
if iterStep != nil && iterStep.ExecutionContext != nil {
34+
parent.ExecutionContext = iterStep.ExecutionContext
35+
return
36+
}
37+
}
38+
}
39+
1740
// substituteTemplateVariables replaces template variables like {{value}} and {{index}} with actual values.
1841
// This is a pure function with no VM dependency.
1942
//
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package taskengine
2+
3+
import (
4+
"testing"
5+
6+
avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf"
7+
"google.golang.org/protobuf/types/known/structpb"
8+
)
9+
10+
// TestPropagateLoopExecutionContext verifies that a loop step inherits the
11+
// ExecutionContext from its first iteration step that has one. This is what
12+
// makes a loop wrapping Tenderly-simulated contract writes correctly report
13+
// `is_simulated: true, provider: tenderly` instead of the misleading
14+
// `chain_rpc` default that CreateNodeExecutionStep would otherwise leave behind.
15+
func TestPropagateLoopExecutionContext(t *testing.T) {
16+
mkCtx := func(simulated bool, provider string) *structpb.Value {
17+
v, err := structpb.NewValue(map[string]interface{}{
18+
"is_simulated": simulated,
19+
"provider": provider,
20+
"chain_id": 11155111,
21+
})
22+
if err != nil {
23+
t.Fatalf("structpb.NewValue: %v", err)
24+
}
25+
return v
26+
}
27+
28+
t.Run("propagates from first iteration with context", func(t *testing.T) {
29+
parent := &avsproto.Execution_Step{Id: "loop1"}
30+
iter0 := &avsproto.Execution_Step{Id: "iter0", ExecutionContext: mkCtx(true, "tenderly")}
31+
iter1 := &avsproto.Execution_Step{Id: "iter1", ExecutionContext: mkCtx(true, "tenderly")}
32+
33+
propagateLoopExecutionContext(parent, []*avsproto.Execution_Step{iter0, iter1})
34+
35+
if parent.ExecutionContext == nil {
36+
t.Fatal("expected parent ExecutionContext to be set")
37+
}
38+
ctxMap := parent.ExecutionContext.GetStructValue().AsMap()
39+
if got := ctxMap["is_simulated"]; got != true {
40+
t.Errorf("is_simulated = %v, want true", got)
41+
}
42+
if got := ctxMap["provider"]; got != "tenderly" {
43+
t.Errorf("provider = %v, want tenderly", got)
44+
}
45+
})
46+
47+
t.Run("skips nil iterations and picks first non-nil context", func(t *testing.T) {
48+
parent := &avsproto.Execution_Step{Id: "loop1"}
49+
iter0 := &avsproto.Execution_Step{Id: "iter0"} // no ExecutionContext
50+
iter1 := (*avsproto.Execution_Step)(nil)
51+
iter2 := &avsproto.Execution_Step{Id: "iter2", ExecutionContext: mkCtx(false, "bundler")}
52+
53+
propagateLoopExecutionContext(parent, []*avsproto.Execution_Step{iter0, iter1, iter2})
54+
55+
if parent.ExecutionContext == nil {
56+
t.Fatal("expected parent ExecutionContext to be set from iter2")
57+
}
58+
ctxMap := parent.ExecutionContext.GetStructValue().AsMap()
59+
if got := ctxMap["provider"]; got != "bundler" {
60+
t.Errorf("provider = %v, want bundler", got)
61+
}
62+
if got := ctxMap["is_simulated"]; got != false {
63+
t.Errorf("is_simulated = %v, want false", got)
64+
}
65+
})
66+
67+
t.Run("leaves parent untouched when no iteration has context", func(t *testing.T) {
68+
baseline := mkCtx(false, "chain_rpc")
69+
parent := &avsproto.Execution_Step{Id: "loop1", ExecutionContext: baseline}
70+
iter0 := &avsproto.Execution_Step{Id: "iter0"}
71+
72+
propagateLoopExecutionContext(parent, []*avsproto.Execution_Step{iter0})
73+
74+
if parent.ExecutionContext != baseline {
75+
t.Error("expected parent ExecutionContext to remain unchanged when no iteration provides one")
76+
}
77+
})
78+
79+
t.Run("handles empty iterations slice", func(t *testing.T) {
80+
parent := &avsproto.Execution_Step{Id: "loop1"}
81+
propagateLoopExecutionContext(parent, nil)
82+
if parent.ExecutionContext != nil {
83+
t.Error("expected parent ExecutionContext to remain nil for empty iterations")
84+
}
85+
})
86+
87+
t.Run("handles nil parent gracefully", func(t *testing.T) {
88+
// Should not panic.
89+
propagateLoopExecutionContext(nil, []*avsproto.Execution_Step{
90+
{ExecutionContext: mkCtx(true, "tenderly")},
91+
})
92+
})
93+
}

core/taskengine/run_node_immediately.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,10 +1599,12 @@ func (n *Engine) runEventTriggerWithTenderlySimulation(ctx context.Context, quer
15991599
// the default synthetic value dwarfs realistic wallet balances and downstream
16001600
// Tenderly transfer simulations revert with "ERC20: transfer amount exceeds balance".
16011601
// Cached after first lookup, so subsequent simulations are free.
1602-
var simulatedTokenDecimals uint32
1602+
// nil = unknown (fall back to 18); a real 0 is preserved (legitimate ERC20 case).
1603+
var simulatedTokenDecimals *uint32
16031604
if n.tokenEnrichmentService != nil && len(query.GetAddresses()) > 0 {
16041605
if md, mdErr := n.tokenEnrichmentService.GetTokenMetadata(query.GetAddresses()[0]); mdErr == nil && md != nil {
1605-
simulatedTokenDecimals = md.Decimals
1606+
d := md.Decimals
1607+
simulatedTokenDecimals = &d
16061608
}
16071609
}
16081610

core/taskengine/shared_event_enrichment.go

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -264,34 +264,36 @@ func enrichTransferEventShared(eventLog *types.Log, parsedData map[string]interf
264264
}
265265
}
266266

267-
// Populate token metadata if available
267+
// Always populate the raw value from the parsed event, regardless of whether
268+
// token metadata is available. The new schema contract is that `value` is the
269+
// raw uint256 base-units string — clients depend on this for BigInt math even
270+
// when metadata lookup fails (token not in whitelist, RPC error, etc.).
271+
// `valueFormatted` is only computed when we know the decimals.
272+
var rawValueStr string
273+
if transferEventData != nil {
274+
if rawValue, ok := transferEventData["value"]; ok {
275+
switch v := rawValue.(type) {
276+
case *big.Int:
277+
rawValueStr = v.String()
278+
case string:
279+
rawValueStr = v
280+
default:
281+
rawValueStr = fmt.Sprintf("%v", v)
282+
}
283+
}
284+
}
285+
if rawValueStr != "" {
286+
transferResponse.Value = rawValueStr
287+
}
288+
289+
// Populate token metadata and the formatted value if available
268290
if tokenMetadata != nil {
269291
transferResponse.TokenName = tokenMetadata.Name
270292
transferResponse.TokenSymbol = tokenMetadata.Symbol
271293
transferResponse.TokenDecimals = tokenMetadata.Decimals
272294

273-
// Populate both raw and formatted value:
274-
// - value → raw uint256 base-units string (for on-chain math, e.g. BigInt())
275-
// - valueFormatted → human-readable decimal-adjusted string (for display)
276-
// This matches Moralis / Etherscan / subgraph conventions and avoids the
277-
// foot-gun where downstream BigInt() consumers receive "1.5" and crash.
278-
if transferEventData != nil {
279-
if rawValue, ok := transferEventData["value"]; ok {
280-
var valueStr string
281-
282-
// Handle different value types (big.Int, string, etc.)
283-
switch v := rawValue.(type) {
284-
case *big.Int:
285-
valueStr = v.String()
286-
case string:
287-
valueStr = v
288-
default:
289-
valueStr = fmt.Sprintf("%v", v)
290-
}
291-
292-
transferResponse.Value = valueStr
293-
transferResponse.ValueFormatted = tokenService.FormatTokenValue(valueStr, tokenMetadata.Decimals)
294-
}
295+
if rawValueStr != "" {
296+
transferResponse.ValueFormatted = tokenService.FormatTokenValue(rawValueStr, tokenMetadata.Decimals)
295297
}
296298

297299
if logger != nil {

core/taskengine/tenderly_client.go

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,16 @@ func NewTenderlyClient(cfg *config.Config, logger sdklogging.Logger) *TenderlyCl
9494
// that know the token's actual decimals should use SimulateEventTriggerWithDecimals
9595
// so the synthetic value scales correctly (e.g., USDC with 6 decimals).
9696
func (tc *TenderlyClient) SimulateEventTrigger(ctx context.Context, query *avsproto.EventTrigger_Query, chainID int64) (*types.Log, error) {
97-
return tc.SimulateEventTriggerWithDecimals(ctx, query, chainID, 0)
97+
return tc.SimulateEventTriggerWithDecimals(ctx, query, chainID, nil)
9898
}
9999

100100
// SimulateEventTriggerWithDecimals is like SimulateEventTrigger but lets callers
101101
// pass the token's decimals so simulated Transfer amounts represent ~1.5 tokens
102-
// regardless of the token's decimal precision. Pass 0 to use the 18-decimal default.
103-
func (tc *TenderlyClient) SimulateEventTriggerWithDecimals(ctx context.Context, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals uint32) (*types.Log, error) {
102+
// regardless of the token's decimal precision. Pass nil if the token's decimals
103+
// are unknown — the simulator will fall back to an 18-decimal assumption. Note
104+
// that 0 is a legitimate decimals value (some ERC20s have it), so a pointer is
105+
// used to disambiguate "unknown" from "zero".
106+
func (tc *TenderlyClient) SimulateEventTriggerWithDecimals(ctx context.Context, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals *uint32) (*types.Log, error) {
104107
if err := ctx.Err(); err != nil {
105108
return nil, fmt.Errorf("context error before simulation: %w", err)
106109
}
@@ -286,9 +289,10 @@ func (tc *TenderlyClient) createMockAnswerUpdatedLog(contractAddress string, pri
286289
}
287290

288291
// simulateTransferEvent simulates an actual ERC20 transfer transaction using Tenderly simulation API.
289-
// tokenDecimals scales the synthetic default amount (1.5 tokens) to the token's decimal precision;
290-
// pass 0 to default to 18 decimals.
291-
func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAddress string, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals uint32) (*types.Log, error) {
292+
// tokenDecimals scales the synthetic default amount (1.5 tokens) to the token's decimal precision.
293+
// Pass nil for unknown decimals — the simulator falls back to 18. Pointer is used so that 0 (a
294+
// legitimate ERC20 decimals value) is not conflated with "unknown".
295+
func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAddress string, query *avsproto.EventTrigger_Query, chainID int64, tokenDecimals *uint32) (*types.Log, error) {
292296
tc.logger.Info("🔮 Simulating ERC20 Transfer transaction via Tenderly API",
293297
"contract", contractAddress,
294298
"chain_id", chainID)
@@ -334,19 +338,31 @@ func (tc *TenderlyClient) simulateTransferEvent(ctx context.Context, contractAdd
334338
toAddress = common.HexToAddress("0x0000000000000000000000000000000000000002")
335339
}
336340

337-
// Default synthetic amount: 1.5 tokens, scaled to the token's actual decimals.
341+
// Default synthetic amount: ~1.5 tokens, scaled to the token's actual decimals.
338342
// Without scaling, an 18-decimal default (1.5e18) becomes 1.5 trillion units for
339343
// a 6-decimal token like USDC, which then dwarfs any realistic wallet balance and
340344
// causes downstream Tenderly transfer simulations to revert with "exceeds balance".
341-
decimalsForScale := tokenDecimals
342-
if decimalsForScale == 0 {
343-
decimalsForScale = 18
344-
}
345-
// transferAmount = 15 * 10^(decimalsForScale-1) → represents 1.5 tokens
346-
transferAmount := new(big.Int).Mul(
347-
big.NewInt(15),
348-
new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimalsForScale)-1), nil),
349-
)
345+
//
346+
// Decimal handling:
347+
// - tokenDecimals == nil → unknown, fall back to 18-decimal assumption
348+
// - tokenDecimals == 0 → real 0-decimal token (e.g. some governance tokens);
349+
// fractional amounts aren't representable, use 1 unit
350+
// - tokenDecimals > 0 → represent 1.5 tokens as 15 * 10^(decimals-1)
351+
var transferAmount *big.Int
352+
switch {
353+
case tokenDecimals == nil:
354+
transferAmount = new(big.Int).Mul(
355+
big.NewInt(15),
356+
new(big.Int).Exp(big.NewInt(10), big.NewInt(17), nil), // 1.5 * 10^18
357+
)
358+
case *tokenDecimals == 0:
359+
transferAmount = big.NewInt(1)
360+
default:
361+
transferAmount = new(big.Int).Mul(
362+
big.NewInt(15),
363+
new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(*tokenDecimals)-1), nil),
364+
)
365+
}
350366

351367
for _, condition := range query.GetConditions() {
352368
if condition.GetFieldName() == "value" || condition.GetFieldName() == "amount" {

0 commit comments

Comments
 (0)