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
29 changes: 28 additions & 1 deletion aggregator/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ func (r *RpcServer) GetKey(ctx context.Context, payload *avsproto.GetKeyReq) (*a
return nil, status.Errorf(codes.InvalidArgument, "Invalid wallet address format")
}
ownerAddress := common.HexToAddress(walletStr)
// Refuse to mint a token bound to the zero address. The two-step
// auth flow lets a caller request a JWT with sub = walletStr, so we
// must reject 0x0…0 here as well as in verifyAuth — defense-in-depth
// against the dummy-target-address bypass.
if ownerAddress == (common.Address{}) {
return nil, status.Errorf(codes.InvalidArgument, "Wallet address cannot be the zero address")
}

if strings.Contains(payload.Signature, ".") {
// API key directly
Expand Down Expand Up @@ -256,8 +263,21 @@ func (r *RpcServer) verifyAuth(ctx context.Context) (*model.User, error) {
return nil, fmt.Errorf("%s: subject must be a valid EOA address", auth.InvalidAuthenticationKey)
}

// Defense-in-depth: refuse the zero address. common.IsHexAddress
// returns true for "0x0000…0000", and a buggy SDK that minted a
// JWT with the zero-address subject (e.g. via the
// dummy-target-address bypass) would otherwise pass validation
// and silently fail every w:<owner>:<wallet> lookup downstream.
// The zero address is never a real EOA — reject it explicitly.
subjectAddr := common.HexToAddress(subject)
if subjectAddr == (common.Address{}) {
r.config.Logger.Error("API key has zero-address subject; refusing authentication",
"subject", subject)
return nil, fmt.Errorf("%s: subject cannot be the zero address", auth.InvalidAuthenticationKey)
}

user := model.User{
Address: common.HexToAddress(subject),
Address: subjectAddr,
}

// caching to reduce hitting eth rpc node
Expand Down Expand Up @@ -312,6 +332,13 @@ func (r *RpcServer) GetSignatureFormat(ctx context.Context, req *avsproto.GetSig
if !common.IsHexAddress(walletAddress) {
return nil, status.Errorf(codes.InvalidArgument, "Invalid Ethereum wallet address format")
}
// Refuse to issue a signature template for the zero address. This is
// the earliest point in the auth flow where we can stop a caller
// (typically a buggy SDK) from accidentally requesting a JWT bound
// to 0x0…0. See verifyAuth for the matching last-line defense.
if common.HexToAddress(walletAddress) == (common.Address{}) {
return nil, status.Errorf(codes.InvalidArgument, "Wallet address cannot be the zero address")
}

// Use smart wallet chain ID (r.chainID) instead of global EigenLayer chain ID
// This ensures authentication uses the correct chain for smart wallet operations
Expand Down
124 changes: 124 additions & 0 deletions aggregator/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
"github.com/golang-jwt/jwt/v5"
"google.golang.org/grpc/codes"
grpcmetadata "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)

Expand Down Expand Up @@ -308,3 +309,126 @@ func TestGetSignatureFormat(t *testing.T) {
t.Errorf("expected message to contain %s but got %s", expectedChainIDStr, message)
}
}

// Defense-in-depth tests: confirm the auth flow refuses the zero
// address at all three points where it could otherwise sneak in
// (GetSignatureFormat → GetKey → verifyAuth). Without these checks
// a buggy SDK can request a JWT bound to 0x0…0 and silently fail every
// w:<owner>:<wallet> ownership lookup downstream — see PR #520 fallout.

func TestGetSignatureFormat_RejectsZeroAddress(t *testing.T) {
logger, _ := sdklogging.NewZapLogger("development")

r := RpcServer{
config: &config.Config{
JwtSecret: []byte("test123"),
Logger: logger,
},
chainID: big.NewInt(11155111),
}

req := &avsproto.GetSignatureFormatReq{
Wallet: "0x0000000000000000000000000000000000000000",
}

_, err := r.GetSignatureFormat(context.Background(), req)
if err == nil {
t.Fatalf("expected GetSignatureFormat to reject zero address but it succeeded")
}
statusErr, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got: %v", err)
}
if statusErr.Code() != codes.InvalidArgument {
t.Errorf("expected InvalidArgument, got: %v", statusErr.Code())
}
if !strings.Contains(statusErr.Message(), "zero address") {
t.Errorf("expected error message to mention 'zero address', got: %q", statusErr.Message())
}
}

func TestGetKey_RejectsZeroAddressWallet(t *testing.T) {
logger, _ := sdklogging.NewZapLogger("development")

r := RpcServer{
config: &config.Config{
JwtSecret: []byte("test123"),
Logger: logger,
},
chainID: big.NewInt(11155111),
}

chainID := int64(11155111)
issuedTs, _ := time.Parse(time.RFC3339, "2025-01-01T00:00:00Z")
expiredTs, _ := time.Parse(time.RFC3339, "2030-01-01T00:00:00Z")

// Build a valid-looking message but with the zero address as the
// wallet. We don't even need to sign it correctly — the zero-address
// check fires before the signature verification.
message := fmt.Sprintf(authTemplate,
chainID,
"1",
issuedTs.UTC().Format("2006-01-02T15:04:05.000Z"),
expiredTs.UTC().Format("2006-01-02T15:04:05.000Z"),
"0x0000000000000000000000000000000000000000")

payload := &avsproto.GetKeyReq{
Message: message,
Signature: "0xdeadbeef", // doesn't matter, never reached
}

_, err := r.GetKey(context.Background(), payload)
if err == nil {
t.Fatalf("expected GetKey to reject zero address wallet but it succeeded")
}
statusErr, ok := status.FromError(err)
if !ok {
t.Fatalf("expected gRPC status error, got: %v", err)
}
if statusErr.Code() != codes.InvalidArgument {
t.Errorf("expected InvalidArgument, got: %v", statusErr.Code())
}
if !strings.Contains(statusErr.Message(), "zero address") {
t.Errorf("expected error message to mention 'zero address', got: %q", statusErr.Message())
}
}

func TestVerifyAuth_RejectsZeroAddressSubject(t *testing.T) {
logger, _ := sdklogging.NewZapLogger("development")

r := RpcServer{
config: &config.Config{
JwtSecret: []byte("test123"),
Logger: logger,
},
chainID: big.NewInt(11155111),
}

// Manually mint a JWT with sub = 0x0…0 and the correct audience.
// This simulates the bypass we observed: the SDK had requested a
// re-minted token via the dummy-zero-address path, and an older
// build of the server happily accepted it.
claims := &jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
Issuer: auth.Issuer,
Subject: "0x0000000000000000000000000000000000000000",
Audience: jwt.ClaimStrings{"11155111"},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signedToken, signErr := token.SignedString(r.config.JwtSecret)
if signErr != nil {
t.Fatalf("failed to sign test token: %v", signErr)
}

// Build a metadata context the way grpc would for an inbound request.
md := grpcmetadata.New(map[string]string{"authkey": signedToken})
ctx := grpcmetadata.NewIncomingContext(context.Background(), md)

_, err := r.verifyAuth(ctx)
if err == nil {
t.Fatalf("expected verifyAuth to reject zero-address subject but it succeeded")
}
if !strings.Contains(err.Error(), "zero address") {
t.Errorf("expected error to mention 'zero address', got: %q", err.Error())
}
}
23 changes: 5 additions & 18 deletions core/taskengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -3051,7 +3051,7 @@ func (n *Engine) SimulateTask(user *model.User, trigger *avsproto.TaskTrigger, n
}

// Step 10: Analyze execution results from all steps
_, executionError, failedStepCount, resultStatus := vm.AnalyzeExecutionResult()
executionError, failedStepCount, resultStatus := vm.AnalyzeExecutionResult()

// Step 11: Calculate total gas cost for the workflow

Expand All @@ -3073,20 +3073,7 @@ func (n *Engine) SimulateTask(user *model.User, trigger *avsproto.TaskTrigger, n
switch resultStatus {
case ExecutionSuccess:
n.logger.Info("workflow simulation completed successfully", "task_id", task.Id, "simulation_id", simulationID, "steps", len(execution.Steps))
case ExecutionPartialSuccess:
// Clean up error message to avoid stack traces in logs
cleanErrorMsg := executionError
stackTraceRegex := regexp.MustCompile(`(?m)^\s*at .*$`)
cleanErrorMsg = stackTraceRegex.ReplaceAllString(cleanErrorMsg, "")
cleanErrorMsg = strings.TrimSpace(cleanErrorMsg)

n.logger.Warn("workflow simulation completed with partial success",
"error", cleanErrorMsg,
"task_id", task.Id,
"simulation_id", simulationID,
"failed_steps", failedStepCount,
"total_steps", len(vm.ExecutionLogs))
case ExecutionFailure:
case ExecutionFailed:
// Clean up error message to avoid stack traces in logs
cleanErrorMsg := executionError
stackTraceRegex := regexp.MustCompile(`(?m)^\s*at .*$`)
Expand All @@ -3103,13 +3090,13 @@ func (n *Engine) SimulateTask(user *model.User, trigger *avsproto.TaskTrigger, n

// Handle VM-level errors if they occurred
if runErr != nil {
// This should not happen if AnalyzeExecutionResult is working correctly,
// but handle it as a fallback for VM-level errors
n.logger.Error("workflow simulation had VM-level error", "vm_error", runErr, "task_id", task.Id, "simulation_id", simulationID)
if execution.Error == "" {
execution.Error = fmt.Sprintf("VM execution error: %s", runErr.Error())
execution.Status = avsproto.ExecutionStatus_EXECUTION_STATUS_FAILED
} else {
execution.Error = fmt.Sprintf("VM execution error: %s (step analysis: %s)", runErr.Error(), execution.Error)
}
Comment on lines 3092 to 3098
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The system-level runErr path should set the execution status to EXECUTION_STATUS_ERROR even if execution.Error is already populated by AnalyzeExecutionResult. With the current guard (only setting ERROR when execution.Error == ""), VM-level failures can be misreported as FAILED (e.g., "no execution steps found"), which conflicts with the new SUCCESS/FAILED/ERROR semantics.

Copilot uses AI. Check for mistakes.
execution.Status = avsproto.ExecutionStatus_EXECUTION_STATUS_ERROR
}

return execution, nil
Expand Down
23 changes: 7 additions & 16 deletions core/taskengine/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,7 +618,7 @@ func (x *TaskExecutor) RunTask(task *model.Task, queueData *QueueExecutionData)
}

// Analyze execution results from all steps (including failed ones)
_, executionError, failedStepCount, resultStatus := vm.AnalyzeExecutionResult()
executionError, failedStepCount, resultStatus := vm.AnalyzeExecutionResult()

// Calculate total gas cost for the workflow

Expand Down Expand Up @@ -652,14 +652,7 @@ func (x *TaskExecutor) RunTask(task *model.Task, queueData *QueueExecutionData)
switch resultStatus {
case ExecutionSuccess:
x.logger.Info("task execution completed successfully", "task_id", task.Id, "execution_id", queueData.ExecutionID, "total_steps", len(vm.ExecutionLogs))
case ExecutionPartialSuccess:
x.logger.Warn("task execution completed with partial success",
"error", executionError,
"task_id", task.Id,
"execution_id", queueData.ExecutionID,
"failed_steps", failedStepCount,
"total_steps", len(vm.ExecutionLogs))
case ExecutionFailure:
case ExecutionFailed:
x.logger.Error("task execution completed with failures",
"error", executionError,
"task_id", task.Id,
Expand All @@ -669,13 +662,13 @@ func (x *TaskExecutor) RunTask(task *model.Task, queueData *QueueExecutionData)
}

if runTaskErr != nil {
// This should not happen if AnalyzeExecutionResult is working correctly,
// but handle it as a fallback for VM-level errors
x.logger.Error("task execution had VM-level error", "vm_error", runTaskErr, "task_id", task.Id, "execution_id", queueData.ExecutionID)
if execution.Error == "" {
execution.Error = fmt.Sprintf("VM execution error: %s", runTaskErr.Error())
execution.Status = avsproto.ExecutionStatus_EXECUTION_STATUS_FAILED
} else {
execution.Error = fmt.Sprintf("VM execution error: %s (step analysis: %s)", runTaskErr.Error(), execution.Error)
}
execution.Status = avsproto.ExecutionStatus_EXECUTION_STATUS_ERROR
}

// batch update storage for task + execution log
Expand Down Expand Up @@ -732,10 +725,8 @@ func (x *TaskExecutor) RunTask(task *model.Task, queueData *QueueExecutionData)
switch resultStatus {
case ExecutionSuccess:
x.logger.Info("successfully executing task", "task_id", task.Id, "triggermark", queueData)
case ExecutionPartialSuccess:
x.logger.Info("task execution completed with partial success", "task_id", task.Id, "failed_steps", failedStepCount, "triggermark", queueData)
default: // ExecutionFailure or other
x.logger.Warn("task execution completed with step failures", "task_id", task.Id, "failed_steps", failedStepCount)
case ExecutionFailed:
x.logger.Warn("task execution completed with step failures", "task_id", task.Id, "failed_steps", failedStepCount, "triggermark", queueData)
}

return execution, nil
Expand Down
14 changes: 7 additions & 7 deletions core/taskengine/executor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package taskengine
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -225,14 +224,15 @@ func TestExecutorRunTaskWithBranchSilentFailureBehavior(t *testing.T) {
t.Errorf("Expected no error with silent failure behavior, but got: %v", err)
}

// Branch workflows with skipped nodes should report PARTIAL_SUCCESS
if execution.Status != avsproto.ExecutionStatus_EXECUTION_STATUS_PARTIAL_SUCCESS {
t.Errorf("Expected partial success status (branch path with skipped nodes), but got: %v with error: %s", execution.Status, execution.Error)
// Branch workflows with skipped nodes are SUCCESS — the workflow executed its
// chosen path correctly; skipping nodes due to branching is expected behavior.
if execution.Status != avsproto.ExecutionStatus_EXECUTION_STATUS_SUCCESS {
t.Errorf("Expected success status (branch path with skipped nodes is normal), but got: %v with error: %s", execution.Status, execution.Error)
}

// Should have a partial execution message explaining the branching
if execution.Error == "" || !strings.Contains(execution.Error, "Partial execution") {
t.Errorf("Expected partial execution message, but got: %s", execution.Error)
// No error when all executed steps succeeded
if execution.Error != "" {
t.Errorf("Expected empty error for successful branch execution, but got: %s", execution.Error)
}

// Find the branch step regardless of ordering
Expand Down
Loading
Loading