Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
6 changes: 3 additions & 3 deletions .github/workflows/run-test-on-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ on:
jobs:
check-changes:
name: Check Changed Files
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check.outputs.should_run }}
steps:
Expand Down Expand Up @@ -78,7 +78,7 @@ jobs:
name: Lint
needs: check-changes
if: needs.check-changes.outputs.should_run == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
Expand Down Expand Up @@ -122,7 +122,7 @@ jobs:
name: Unit Test
needs: check-changes
if: needs.check-changes.outputs.should_run == 'true'
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
Expand Down
14 changes: 8 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ Ava Protocol Automation AVS (Actively Validated Service) on EigenLayer - a task
### Building and Running

```bash
make build # Build main binary
make dev-build # Build development version
make dev-agg # Run aggregator locally
make dev-op # Run operator locally
make up # Start docker compose stack
make dev-live # Live reload during development
make build # Build main binary (-> out/ap)
make build-prod # Build production binary
make gateway # Run local-dev gateway (config/gateway.yaml); logs -> gateway.log
make dev-gateway # Same, logs -> logs/gateway.log
make dev-operator-sepolia # Run a Sepolia operator locally
make dev-stack # gateway + workers + operator together (logs/)
make up # Start docker compose stack
make dev-live # Live reload during development
```

### Testing
Expand Down
191 changes: 191 additions & 0 deletions PLAN_AGENT_ONDEMAND_ACTIONS.md

Large diffs are not rendered by default.

383 changes: 383 additions & 0 deletions PLAN_WORKFLOW_STATE_GUARDIAN_MONITORING.md

Large diffs are not rendered by default.

23 changes: 20 additions & 3 deletions aggregator/rest/handlers_nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ func (s *Server) RunNode(ctx echo.Context) error {
req.Erc20Overrides = openAPIERC20OverridesToProto(*body.Erc20Overrides)
}

resp, err := s.engine.RunNodeImmediatelyRPCWithContext(ctx.Request().Context(), user, req)
// A caller-supplied Idempotency-Key (Stripe-style header) makes a retried or
// double-submitted execute safe: the same key returns the first result instead
// of broadcasting a second UserOp. Absent the header, behavior is unchanged.
idempotencyKey := ctx.Request().Header.Get("Idempotency-Key")
resp, err := s.engine.RunNodeImmediatelyRPCIdempotent(ctx.Request().Context(), user, req, idempotencyKey)
if err != nil {
return err
}
Expand Down Expand Up @@ -123,8 +127,21 @@ func runNodeRespToOpenAPI(in *avsproto.RunNodeWithInputsResp) generated.RunNodeR
out.ErrorCode = &code
}
if md := in.GetMetadata(); md != nil {
if v, ok := md.AsInterface().(map[string]interface{}); ok && v != nil {
out.Metadata = &v
switch v := md.AsInterface().(type) {
case map[string]interface{}:
if v != nil {
out.Metadata = &v
}
case []interface{}:
// Some node types (notably contractWrite) produce a per-method
// results array as metadata. RunNodeResponse.metadata is an object,
// so a bare array was previously dropped here — taking the per-method
// receipts (executionStatus, userOpHash, transactionHash) with it.
// Wrap it under "results" so those actually reach the client.
if len(v) > 0 {
wrapped := map[string]interface{}{"results": v}
out.Metadata = &wrapped
}
Comment on lines +135 to +144
}
}
if ec := in.GetExecutionContext(); ec != nil {
Expand Down
57 changes: 57 additions & 0 deletions aggregator/rest/handlers_nodes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package rest

import (
"testing"

avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/structpb"
)

// contractWrite surfaces its per-method results (including the receipt with
// executionStatus / userOpHash / transactionHash) as an ARRAY-typed metadata.
// runNodeRespToOpenAPI must wrap that array under "results" so the client can
// read it — a bare array used to be silently dropped, taking the receipts with it.
func TestRunNodeRespToOpenAPISurfacesContractWriteReceiptArray(t *testing.T) {
results := []interface{}{
map[string]interface{}{
"methodName": "exactInputSingle",
"success": true,
"receipt": map[string]interface{}{
"executionStatus": "confirmed",
"userOpHash": "0xuserop",
"transactionHash": "0xtx",
},
},
}
md, err := structpb.NewValue(results)
require.NoError(t, err)

out := runNodeRespToOpenAPI(&avsproto.RunNodeWithInputsResp{Success: true, Metadata: md})

require.NotNil(t, out.Metadata, "array metadata must be surfaced, not dropped")
m := *out.Metadata
arr, ok := m["results"].([]interface{})
require.True(t, ok, "array metadata must be wrapped under 'results'")
require.Len(t, arr, 1)
first := arr[0].(map[string]interface{})
receipt := first["receipt"].(map[string]interface{})
assert.Equal(t, "confirmed", receipt["executionStatus"])
assert.Equal(t, "0xuserop", receipt["userOpHash"])
assert.Equal(t, "0xtx", receipt["transactionHash"])
}

// Map-typed metadata (e.g. ethTransfer's gasUsed/transactionHash) must keep
// surfacing as-is, without the "results" wrapper.
func TestRunNodeRespToOpenAPIMapMetadataStillForwarded(t *testing.T) {
md, err := structpb.NewValue(map[string]interface{}{"transactionHash": "0xabc", "gasUsed": "21000"})
require.NoError(t, err)

out := runNodeRespToOpenAPI(&avsproto.RunNodeWithInputsResp{Success: true, Metadata: md})

require.NotNil(t, out.Metadata)
m := *out.Metadata
assert.Equal(t, "0xabc", m["transactionHash"])
assert.Nil(t, m["results"], "map metadata is forwarded as-is, not wrapped")
}
27 changes: 14 additions & 13 deletions aggregator/rest/partner.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,12 @@ const (
)

// partnerPrincipal is the result of a verified partner assertion: which
// partner vouched (PartnerID) and the end-user it vouched for (Subject,
// possibly empty or a non-address identifier).
// partner vouched (partnerID) and the end-user it vouched for (subject,
// possibly empty or a non-address identifier). Package-private — fields stay
// lowercase since it never leaves this package.
type partnerPrincipal struct {
PartnerID string
Subject string
partnerID string
subject string
}

// requireSimulateAuth authorizes a simulate-family request via EITHER an
Expand Down Expand Up @@ -91,12 +92,12 @@ func (s *Server) requireSimulateAuth(ctx echo.Context) (*model.User, error) {
// `sub` is attribution only. Use it as the acting address when it's
// a real EOA; otherwise leave the zero address — simulate runs
// against any address and never checks ownership.
if common.IsHexAddress(principal.Subject) {
user.Address = common.HexToAddress(principal.Subject)
if common.IsHexAddress(principal.subject) {
user.Address = common.HexToAddress(principal.subject)
}
s.logger.Info("partner-delegated simulate call",
"partner_id", principal.PartnerID,
"subject", principal.Subject,
"partner_id", principal.partnerID,
"subject", principal.subject,
)
return user, nil
}
Expand Down Expand Up @@ -149,9 +150,10 @@ func (s *Server) verifyPartnerAssertion(ctx echo.Context, requiredScope string)
}

// Read the issuer without verifying so we can select the partner's keys.
parser := jwt.NewParser(jwt.WithValidMethods([]string{"EdDSA"}))
// No alg enforcement here — ParseUnverified validates nothing; the EdDSA
// requirement is enforced on the real verify (jwt.Parse) below.
preview := jwt.MapClaims{}
if _, _, err := parser.ParseUnverified(raw, preview); err != nil {
if _, _, err := jwt.NewParser().ParseUnverified(raw, preview); err != nil {
return nil, partnerError(http.StatusUnauthorized, "PARTNER_ASSERTION_MALFORMED",
"Malformed partner assertion", err.Error())
}
Expand All @@ -176,8 +178,7 @@ func (s *Server) verifyPartnerAssertion(ctx echo.Context, requiredScope string)
// Verify the signature against each registered key (rotation support).
var verified *jwt.Token
for _, pk := range pubKeys {
key := pk
tok, verr := jwt.Parse(raw, func(*jwt.Token) (any, error) { return key, nil },
tok, verr := jwt.Parse(raw, func(*jwt.Token) (any, error) { return pk, nil },
jwt.WithValidMethods([]string{"EdDSA"}))
if verr == nil && tok.Valid {
verified = tok
Expand Down Expand Up @@ -227,7 +228,7 @@ func (s *Server) verifyPartnerAssertion(ctx echo.Context, requiredScope string)
}

subject, _ := claims["sub"].(string)
return &partnerPrincipal{PartnerID: issuer, Subject: strings.TrimSpace(subject)}, nil
return &partnerPrincipal{partnerID: issuer, subject: strings.TrimSpace(subject)}, nil
}

// lookupPartner returns the registered partner whose ID matches id, or nil.
Expand Down
3 changes: 2 additions & 1 deletion aggregator/rest/partner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func TestDecodeEd25519Keys_TolerantAndMultiEncoding(t *testing.T) {
std,
base64.RawStdEncoding.EncodeToString(pub),
base64.URLEncoding.EncodeToString(pub),
base64.RawURLEncoding.EncodeToString(pub),
"ed25519:" + std,
} {
if _, err := decodeEd25519Key(enc); err != nil {
Expand Down Expand Up @@ -270,7 +271,7 @@ func TestVerifyPartnerAssertion_Audience(t *testing.T) {
if err != nil {
t.Fatalf("expected success, got: %v", err)
}
if principal == nil || principal.PartnerID != "studio" {
if principal == nil || principal.partnerID != "studio" {
t.Fatalf("expected studio principal, got %+v", principal)
}
})
Expand Down
9 changes: 9 additions & 0 deletions config/gateway.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ macros:
sendgrid_key: ""
thegraph_api_key: ""
moralis_api_key: ""
# GoPlus signed-token auth for restApi nodes using `options.auth.provider: goplus`.
# Leave blank to fall back to keyless GoPlus (lower rate limits).
goplus_app_key: ""
goplus_app_secret: ""
# guardian_ruleset — the tunable verdict knobs the guardian customCode reads via
# {{apContext.configVars.guardian_ruleset}} (bare-injected as a JS object). MUST be
# a valid JSON object and MUST be set (an unresolved template in a customCode source
# hard-fails the node). Single-quote it so the inner double quotes survive YAML.
guardian_ruleset: '{"weak":["honeypot_related_address","blacklist_doubt"]}'

# Optional: post-execution AI summarization. Disabled by default in dev.
notifications:
Expand Down
7 changes: 7 additions & 0 deletions config/test.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ macros:
sendgrid_key: ""
thegraph_api_key: ""
moralis_api_key: ""
# GoPlus signed-token auth for restApi nodes using `options.auth.provider: goplus`.
# Leave blank to fall back to keyless GoPlus (lower rate limits).
goplus_app_key: ""
goplus_app_secret: ""
# guardian_ruleset — tunable verdict knobs read via {{apContext.configVars.guardian_ruleset}}
# (bare-injected as a JS object; must be valid JSON and must be set).
guardian_ruleset: '{"weak":["honeypot_related_address","blacklist_doubt"]}'

# Optional: post-execution AI summarization. Disabled by default in tests.
notifications:
Expand Down
20 changes: 20 additions & 0 deletions core/taskengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/oklog/ulid/v2"
"golang.org/x/sync/singleflight"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
Expand Down Expand Up @@ -305,6 +306,12 @@ type Engine struct {
// fixed set of mutexes bounds memory — the same executionID always maps to the
// same shard, so concurrent resumes of one execution serialize. See executionMutex.
executionMutexes [executionLockShards]sync.Mutex

// Collapses concurrent nodes:run requests that carry the same Idempotency-Key
// so a retried/double-clicked Confirm can't broadcast a second UserOp. Works
// with a persistent TTL cache (see RunNodeImmediatelyRPCIdempotent) that also
// dedupes sequential retries after the first request has completed.
idempotencyGroup singleflight.Group
}

// executionLockShards bounds the per-execution resume locks to a fixed set of mutexes.
Expand Down Expand Up @@ -4596,6 +4603,19 @@ func (n *Engine) DeleteWorkflowByUser(user *model.User, taskID string) (*avsprot
// checkpoint/wake — and any operator internal trigger — don't outlive the workflow.
n.gcDurableStateForTask(taskID)

// Cascade-delete this workflow's durable per-run state ({{state.*}} /
// wfstate:<taskId>) so it doesn't outlive the task (scoped by taskID, so this
// touches only this workflow's namespace).
if stateKeys, listErr := n.db.ListKeys(string(WorkflowStatePrefix(taskID))); listErr == nil {
for _, k := range stateKeys {
if delErr := n.db.Delete([]byte(k)); delErr != nil {
n.logger.Warn("failed to delete workflow state key", "key", k, "error", delErr)
}
}
} else {
n.logger.Warn("failed to list workflow state keys for cascade delete; state may be orphaned", "task_id", taskID, "error", listErr)
}

n.logger.Info("📢 Starting operator notifications", "task_id", taskID)
n.notifyOperatorsTaskOperation(taskID, avsproto.MessageOp_DeleteTask)
n.logger.Info("✅ Delete task operation completed", "task_id", taskID)
Expand Down
49 changes: 43 additions & 6 deletions core/taskengine/macros/exp.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math/big"
"regexp"
"strings"

"github.com/ethereum/go-ethereum/accounts/abi"
Expand Down Expand Up @@ -59,7 +60,7 @@ func readContractData(contractAddress string, data string, method string, contra
}

// QueryContract
//const taskCondition = `cmp(chainlinkPrice("0x694AA1769357215DE4FAC081bf1f309aDC325306"), parseUnit("262199799820", 8)) > 1`
//const taskCondition = `cmp(chainlinkPrice("0x694AA1769357215DE4FAC081bf1f309aDC325306"), parseUnit("2621.99", 8)) > 1`

func chainlinkLatestRoundData(tokenPair string) *big.Int {
output, err := QueryContract(
Expand Down Expand Up @@ -115,14 +116,50 @@ func BigLt(a *big.Int, b *big.Int) bool {
return a.Cmp(b) < 0
}

// parseUnitRe matches a non-negative decimal number with an optional fractional
// part (e.g. "2621", "2621.99", "2621."). Validating the shape up front rejects
// signs, a leading "+" in the fraction, hex, and other malformed input that
// big.Int.SetString would otherwise silently accept. A bare ".5" (empty whole
// part) is intentionally rejected; task thresholds are written with a leading 0.
var parseUnitRe = regexp.MustCompile(`^[0-9]+(\.[0-9]*)?$`)

// parseUnitMaxDecimals caps 10^decimals so a user-supplied `decimal` can't force
// the aggregator/operator to materialize a huge integer. 10^78 ≈ 2^259, beyond
// anything on-chain needs (uint256 tops out near 10^77).
const parseUnitMaxDecimals = 78

// ParseUnit converts a non-negative decimal string into a fixed-point integer
// scaled by 10^decimals, matching the ethers.js parseUnits(value, decimals)
// convention. It accepts a fractional component (e.g. ParseUnit("2621.99", 8) ==
// 262199000000), which is what task-condition expressions need to build a price
// threshold to compare against chainlinkPrice(). Input that is not a non-negative
// decimal, a fraction with more digits than `decimals` (over-precision), or
// `decimals` beyond the on-chain range is rejected.
func ParseUnit(val string, decimal uint) *big.Int {
b, ok := ethmath.ParseBig256(val)
if !ok {
panic(fmt.Errorf("Parse error: %s", val))
if !parseUnitRe.MatchString(val) {
panic(fmt.Errorf("Parse error: %q is not a non-negative decimal number", val))
}
if decimal > parseUnitMaxDecimals {
panic(fmt.Errorf("Parse error: decimals %d out of range (max %d)", decimal, parseUnitMaxDecimals))
}

parts := strings.SplitN(val, ".", 2)
whole, _ := new(big.Int).SetString(parts[0], 10) // regex guarantees a valid non-negative integer

scale := new(big.Int).Exp(big.NewInt(10), new(big.Int).SetUint64(uint64(decimal)), nil)
result := new(big.Int).Mul(whole, scale)

// Add the fractional component, right-padded to `decimals` digits.
if len(parts) == 2 && parts[1] != "" {
frac := parts[1]
if uint(len(frac)) > decimal {
panic(fmt.Errorf("Parse error: fractional part %q exceeds %d decimals in %q", frac, decimal, val))
}
fracVal, _ := new(big.Int).SetString(frac+strings.Repeat("0", int(decimal)-len(frac)), 10)
result.Add(result, fracVal)
}

r := big.NewInt(0)
return r.Div(b, big.NewInt(int64(decimal)))
return result
}

func ToBigInt(val string) *big.Int {
Expand Down
Loading
Loading