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
2 changes: 1 addition & 1 deletion core/taskengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion core/taskengine/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 20 additions & 30 deletions core/taskengine/fee_estimator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 40 additions & 0 deletions core/taskengine/fee_estimator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
181 changes: 181 additions & 0 deletions core/taskengine/value_fee_simulation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading