diff --git a/core/taskengine/engine.go b/core/taskengine/engine.go index fa3a789e..402796db 100644 --- a/core/taskengine/engine.go +++ b/core/taskengine/engine.go @@ -2928,7 +2928,7 @@ func (n *Engine) SimulateTask(user *model.User, trigger *avsproto.TaskTrigger, n Index: task.ExecutionCount, // Use current execution count for simulation (0-based) ExecutionFee: buildExecutionFee(n.config.FeeRates), Cogs: buildCOGSFromSteps(vm.ExecutionLogs), - ValueFee: buildValueFee(vm.ExecutionLogs, n.config.FeeRates), + ValueFee: buildValueFee(task.Nodes, n.config.FeeRates), } // Log execution status based on result type diff --git a/core/taskengine/executor.go b/core/taskengine/executor.go index a897868c..0e5edc1b 100644 --- a/core/taskengine/executor.go +++ b/core/taskengine/executor.go @@ -629,7 +629,7 @@ func (x *TaskExecutor) RunTask(task *model.Task, queueData *QueueExecutionData) execution.Steps = vm.ExecutionLogs // Contains all steps including failed ones execution.ExecutionFee = buildExecutionFee(x.engine.config.FeeRates) execution.Cogs = buildCOGSFromSteps(vm.ExecutionLogs) - execution.ValueFee = buildValueFee(vm.ExecutionLogs, x.engine.config.FeeRates) + execution.ValueFee = buildValueFee(task.Nodes, x.engine.config.FeeRates) // Value fee recording placeholder. // V1: value fees are not recorded because actual transaction value (the base for diff --git a/core/taskengine/fee_estimator.go b/core/taskengine/fee_estimator.go index 9f967a90..b55722d2 100644 --- a/core/taskengine/fee_estimator.go +++ b/core/taskengine/fee_estimator.go @@ -455,26 +455,30 @@ func (fe *FeeEstimator) estimateLoopGas(ctx context.Context, node *avsproto.Task // - NO but improves outcome → Tier 2 // - Simple execution → Tier 1 // -// V1: rule-based, defaults to Tier 1 if workflow has on-chain nodes, UNSPECIFIED otherwise. -// V2: LLM-based classification analyzing workflow purpose and node composition. -func (fe *FeeEstimator) classifyWorkflowValue(req *avsproto.EstimateFeesReq) *avsproto.ValueFee { - hasOnChainNodes := false - for _, node := range req.Nodes { +// hasOnChainNodes reports whether the workflow definition contains any node +// that performs on-chain execution. A Loop counts when its inner runner is +// itself an on-chain node (ETHTransfer or ContractWrite). Used by both +// classifyWorkflowValue (pre-flight EstimateFees) and buildValueFee +// (post-execution simulate/run) so the two paths cannot drift. +func hasOnChainNodes(nodes []*avsproto.TaskNode) bool { + for _, node := range nodes { switch { case node.GetEthTransfer() != nil, node.GetContractWrite() != nil: - hasOnChainNodes = true + return true case node.GetLoop() != nil: loop := node.GetLoop() if loop.GetEthTransfer() != nil || loop.GetContractWrite() != nil { - hasOnChainNodes = true + return true } } - if hasOnChainNodes { - break - } } + return false +} - if !hasOnChainNodes { +// V1: rule-based, defaults to Tier 1 if workflow has on-chain nodes, UNSPECIFIED otherwise. +// V2: LLM-based classification analyzing workflow purpose and node composition. +func (fe *FeeEstimator) classifyWorkflowValue(req *avsproto.EstimateFeesReq) *avsproto.ValueFee { + if !hasOnChainNodes(req.Nodes) { return &avsproto.ValueFee{ Fee: &avsproto.Fee{Amount: "0", Unit: "PERCENTAGE"}, Tier: avsproto.ExecutionTier_EXECUTION_TIER_UNSPECIFIED, @@ -562,27 +566,13 @@ func buildExecutionFee(feeRatesConfig *config.FeeRatesConfig) *avsproto.Fee { } // buildValueFee classifies the workflow and returns the value fee. -// Uses the task's nodes to determine if on-chain execution is present. -func buildValueFee(steps []*avsproto.Execution_Step, feeRatesConfig *config.FeeRatesConfig) *avsproto.ValueFee { +// Classification is based on the workflow definition (nodes), not on runtime +// gas presence — otherwise simulation runs (Tenderly, no real gas) would be +// classified differently from production runs of the same workflow. +func buildValueFee(nodes []*avsproto.TaskNode, feeRatesConfig *config.FeeRatesConfig) *avsproto.ValueFee { rates := convertFeeRatesConfig(feeRatesConfig) - hasOnChainSteps := false - for _, step := range steps { - stepType := step.Type - if stepType == avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String() || - stepType == avsproto.NodeType_NODE_TYPE_ETH_TRANSFER.String() { - hasOnChainSteps = true - break - } - // A loop step that recorded gas costs ran on-chain work via its inner runner - if stepType == avsproto.NodeType_NODE_TYPE_LOOP.String() && - step.TotalGasCost != "" && step.TotalGasCost != "0" { - hasOnChainSteps = true - break - } - } - - if !hasOnChainSteps { + if !hasOnChainNodes(nodes) { return &avsproto.ValueFee{ Fee: &avsproto.Fee{Amount: "0", Unit: "PERCENTAGE"}, Tier: avsproto.ExecutionTier_EXECUTION_TIER_UNSPECIFIED, diff --git a/core/taskengine/fee_estimator_test.go b/core/taskengine/fee_estimator_test.go index 47dd2690..ed9556f8 100644 --- a/core/taskengine/fee_estimator_test.go +++ b/core/taskengine/fee_estimator_test.go @@ -110,6 +110,46 @@ func TestClassifyWorkflowValue_OnChainDetection(t *testing.T) { assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_UNSPECIFIED, resp.Tier, "Empty node should be UNSPECIFIED") } +// TestBuildValueFee_ClassifiesByDefinitionNotGas asserts that the post-execution +// value-fee classifier looks at the workflow definition (task.Nodes), not at +// runtime gas presence in the execution logs. Without this, simulation runs +// (Tenderly, no real gas) get classified as "no on-chain execution nodes" while +// production runs of the same workflow get Tier 1, causing fee divergence. +func TestBuildValueFee_ClassifiesByDefinitionNotGas(t *testing.T) { + // Revenue-splitter shape: customCode (split) -> loop(contractWrite). + // In simulation the loop step has no TotalGasCost, but it must still be + // classified as on-chain because the workflow definition says so. + splitterNodes := []*avsproto.TaskNode{ + {TaskType: &avsproto.TaskNode_CustomCode{CustomCode: &avsproto.CustomCodeNode{}}}, + {TaskType: &avsproto.TaskNode_Loop{Loop: &avsproto.LoopNode{ + Runner: &avsproto.LoopNode_ContractWrite{ContractWrite: &avsproto.ContractWriteNode{}}, + }}}, + } + + vf := buildValueFee(splitterNodes, nil) + require.NotNil(t, vf) + assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_1, vf.Tier, + "loop(contractWrite) workflow must be Tier 1 even when simulation reports zero gas") + assert.Equal(t, "0.03", vf.Fee.Amount) + assert.Equal(t, "PERCENTAGE", vf.Fee.Unit) + + // Pure off-chain workflow stays UNSPECIFIED / 0%. + offChainNodes := []*avsproto.TaskNode{ + {TaskType: &avsproto.TaskNode_CustomCode{CustomCode: &avsproto.CustomCodeNode{}}}, + {TaskType: &avsproto.TaskNode_RestApi{RestApi: &avsproto.RestAPINode{}}}, + } + vfOff := buildValueFee(offChainNodes, nil) + assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_UNSPECIFIED, vfOff.Tier) + assert.Equal(t, "0", vfOff.Fee.Amount) + + // Direct contractWrite (no loop) classifies as on-chain. + directNodes := []*avsproto.TaskNode{ + {TaskType: &avsproto.TaskNode_ContractWrite{ContractWrite: &avsproto.ContractWriteNode{}}}, + } + vfDirect := buildValueFee(directNodes, nil) + assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_1, vfDirect.Tier) +} + func TestDefaultFeeRates(t *testing.T) { rates := getDefaultFeeRates() diff --git a/core/taskengine/value_fee_simulation_test.go b/core/taskengine/value_fee_simulation_test.go new file mode 100644 index 00000000..ced079f4 --- /dev/null +++ b/core/taskengine/value_fee_simulation_test.go @@ -0,0 +1,181 @@ +package taskengine + +import ( + "testing" + + "github.com/AvaProtocol/EigenLayer-AVS/core/testutil" + avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" + "github.com/AvaProtocol/EigenLayer-AVS/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +// TestSimulateTask_ValueFeeMatchesWorkflowDefinition is a regression test for +// the simulation/production fee divergence: a workflow whose loop runs a +// contractWrite was being classified as "no on-chain execution nodes" during +// SimulateTask (because Tenderly-backed loop steps record no real gas), while +// the same workflow in production reported Tier 1 / 0.03%. The classifier now +// reads from the workflow definition (task.Nodes), so simulation and production +// must agree. +// +// This test asserts the engine-level path: SimulateTask -> buildValueFee. +func TestSimulateTask_ValueFeeMatchesWorkflowDefinition(t *testing.T) { + trigger := &avsproto.TaskTrigger{ + Id: "trigger1", + Name: "manualTrigger", + Type: avsproto.TriggerType_TRIGGER_TYPE_MANUAL, + TriggerType: &avsproto.TaskTrigger_Manual{ + Manual: &avsproto.ManualTrigger{ + Config: &avsproto.ManualTrigger_Config{ + Lang: avsproto.Lang_LANG_JSON, + Data: func() *structpb.Value { + data, _ := structpb.NewValue(map[string]interface{}{"value": "1500000"}) + return data + }(), + }, + }, + }, + } + + // Revenue-splitter shape: customCode produces an array of recipients, + // loop iterates and contractWrite-transfers to each. + splitNode := &avsproto.TaskNode{ + Id: "split1", + Name: "split1", + Type: avsproto.NodeType_NODE_TYPE_CUSTOM_CODE, + TaskType: &avsproto.TaskNode_CustomCode{ + CustomCode: &avsproto.CustomCodeNode{ + Config: &avsproto.CustomCodeNode_Config{ + Source: `return [ + { recipient: "0x804e49e8C4eDb560AE7c48B554f6d2e27Bb81557", amount: "200000" }, + { recipient: "0xfE66125343Aabda4A330DA667431eC1Acb7BbDA9", amount: "450000" } + ];`, + }, + }, + }, + } + + loopNode := &avsproto.TaskNode{ + Id: "loop1", + Name: "loop1", + Type: avsproto.NodeType_NODE_TYPE_LOOP, + TaskType: &avsproto.TaskNode_Loop{ + Loop: &avsproto.LoopNode{ + Config: &avsproto.LoopNode_Config{ + InputVariable: "{{split1.data}}", + IterVal: "value", + IterKey: "index", + }, + Runner: &avsproto.LoopNode_ContractWrite{ + ContractWrite: &avsproto.ContractWriteNode{ + Config: &avsproto.ContractWriteNode_Config{ + ContractAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + ContractAbi: func() []*structpb.Value { + v, _ := structpb.NewValue(map[string]interface{}{ + "name": "transfer", "type": "function", "stateMutability": "nonpayable", + "inputs": []interface{}{ + map[string]interface{}{"name": "to", "type": "address"}, + map[string]interface{}{"name": "amount", "type": "uint256"}, + }, + "outputs": []interface{}{map[string]interface{}{"name": "", "type": "bool"}}, + }) + return []*structpb.Value{v} + }(), + MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ + { + MethodName: "transfer", + MethodParams: []string{"{{value.recipient}}", "{{value.amount}}"}, + }, + }, + }, + }, + }, + }, + }, + } + + nodes := []*avsproto.TaskNode{splitNode, loopNode} + edges := []*avsproto.TaskEdge{ + {Id: "e1", Source: "trigger1", Target: "split1"}, + {Id: "e2", Source: "split1", Target: "loop1"}, + } + + db := testutil.TestMustDB() + defer storage.Destroy(db.(*storage.BadgerStorage)) + + cfg := testutil.GetAggregatorConfig() + engine := New(db, cfg, nil, testutil.GetLogger()) + user := testutil.TestUser1() + + execution, err := engine.SimulateTask(user, trigger, nodes, edges, map[string]interface{}{ + "settings": map[string]interface{}{ + "name": "Revenue Splitter Fee Test", + "runner": "0x6cF121b8783Ae78A30A46DD4Ae1609E436422C26", + }, + }) + require.NoError(t, err) + require.NotNil(t, execution) + require.NotNil(t, execution.ValueFee, "ValueFee must be populated by SimulateTask") + + // The classifier must look at the workflow definition, not at runtime gas. + // Even though the loop runs under simulation (no real gas metered), the + // presence of a contractWrite runner means this workflow IS on-chain in + // production and must be Tier 1 / 0.03%. + assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_1, execution.ValueFee.Tier, + "loop(contractWrite) workflow must be Tier 1 in simulation, matching production") + assert.Equal(t, "0.03", execution.ValueFee.Fee.Amount) + assert.Equal(t, "PERCENTAGE", execution.ValueFee.Fee.Unit) + assert.NotEqual(t, "Workflow has no on-chain execution nodes", execution.ValueFee.Reason, + "must not report off-chain reason for a workflow whose loop runs contractWrite") +} + +// TestSimulateTask_ValueFeeOffChainOnly is the negative case: a workflow with +// no on-chain nodes must classify as UNSPECIFIED / 0% in simulation. +func TestSimulateTask_ValueFeeOffChainOnly(t *testing.T) { + trigger := &avsproto.TaskTrigger{ + Id: "trigger1", + Name: "manualTrigger", + Type: avsproto.TriggerType_TRIGGER_TYPE_MANUAL, + TriggerType: &avsproto.TaskTrigger_Manual{ + Manual: &avsproto.ManualTrigger{ + Config: &avsproto.ManualTrigger_Config{ + Lang: avsproto.Lang_LANG_JSON, + Data: func() *structpb.Value { + data, _ := structpb.NewValue(map[string]interface{}{"x": 1}) + return data + }(), + }, + }, + }, + } + + nodes := []*avsproto.TaskNode{ + { + Id: "code1", + Name: "code1", + Type: avsproto.NodeType_NODE_TYPE_CUSTOM_CODE, + TaskType: &avsproto.TaskNode_CustomCode{ + CustomCode: &avsproto.CustomCodeNode{ + Config: &avsproto.CustomCodeNode_Config{Source: "return { ok: true };"}, + }, + }, + }, + } + edges := []*avsproto.TaskEdge{{Id: "e1", Source: "trigger1", Target: "code1"}} + + db := testutil.TestMustDB() + defer storage.Destroy(db.(*storage.BadgerStorage)) + + cfg := testutil.GetAggregatorConfig() + engine := New(db, cfg, nil, testutil.GetLogger()) + user := testutil.TestUser1() + + execution, err := engine.SimulateTask(user, trigger, nodes, edges, map[string]interface{}{ + "settings": map[string]interface{}{"name": "Off-chain only", "runner": "0x6cF121b8783Ae78A30A46DD4Ae1609E436422C26"}, + }) + require.NoError(t, err) + require.NotNil(t, execution.ValueFee) + assert.Equal(t, avsproto.ExecutionTier_EXECUTION_TIER_UNSPECIFIED, execution.ValueFee.Tier) + assert.Equal(t, "0", execution.ValueFee.Fee.Amount) +}