diff --git a/PLAN_CHAIN_DECOUPLING.md b/PLAN_CHAIN_DECOUPLING.md new file mode 100644 index 00000000..b5d138ab --- /dev/null +++ b/PLAN_CHAIN_DECOUPLING.md @@ -0,0 +1,498 @@ +# PLAN: Decouple chainId from the workflow — per-trigger / per-node chains + +**Owner:** AVS backend (this repo leads). **Status:** backend implemented (strict enforcement in review). +**Date:** 2026-06-26. + +Today a workflow (AVS task) is bound to **one** `chainId`, fixed at creation. The goal is to make +chainId a property of the **parts that need it** — an event trigger watching a contract on chain X, a +contract-write/transfer/read acting on chain Y — so that: + +- a workflow is no longer a single-chain object, and +- a workflow can eventually **bridge across chains mid-run** (act on B after bridging from A). + +We lead this **bottom-up from the AVS backend**. The studio client +(the `studio` repo's `PLAN_WALLET_CHAIN_DECOUPLING.md`, Phase 3/4) and the v4 SDK +(`ava-sdk-js`) follow the model defined here — they must not ship a per-node chain the backend would +flatten. + +--- + +## ✅ Completion checklist (status) + +Backend gaps (this repo): + +- [x] **G1** — EventTrigger `chainId` mapped REST↔proto — **merged #630** +- [x] **G2** — operator routes/coverage by the trigger's own chain (`triggerMonitoringChainID`) — **merged #630** +- [x] **G3** — `ExtractNodeConfiguration` carries `chainId`; `CreateNodeFromType` reads it back — **merged #630** +- [x] **G4** — explicit unresolvable/unconfigured chain rejected at run + create — **merged #630** +- [x] **G5b** — `Task.chain_id` / `CreateTaskReq.chain_id` removed from proto; ~60 readers rerouted — **merged #631** +- [x] **G5c** — task storage chain-agnostic (`t::`); per-chain read iteration collapsed — **merged #631** +- [x] **G5d** — `wipe-chain-bucketed-task-keys` boot migration — **merged #631** +- [x] **G5e** — proto/OpenAPI "0 = inherit" comments dropped; loop-runner chain wiring fixed — **merged #631** +- [x] **Strict reject-0** — `chain_id <= 0` invalid in all modes (resolver + create validator); no default fallback — **merged #632** +- [x] **Create-path verification** — `TestNodeRoundTrip_PerNodeChainId` proves the persisted create path decodes per-node `chainId` — **merged #633** +- [x] **OpenAPI renovation** — removed `Workflow.chainId` / `CreateWorkflowRequest.chainId` + the `ChainIdQuery` param on list/count/executions; per-part config `chainId` now `required` — **merged #634** + +**Backend chain decoupling is complete on `staging`.** Wallet/exec defaults (decided): wallet-ownership +checks across all configured chains (`userOwnsWalletOnAnyChain`); VM-default config = aggregator default; +salt is chain-invariant. ✅ done. + +Client (separate repos — backend spec on `staging` now unblocks them): + +- [ ] **`ava-sdk-js`** — **handed off to the SDK project.** Regen types (`openapi-download`+`types-gen` off the + renovated staging spec), make builder `chainId` required, drop list/count/executions `chainId` params, fix the + stale chain-resolution doc, flip the `PER_NODE_CHAIN_READY` tests. Contract: `## Client contract` below. +- [ ] **studio** — Phase 3: per-part chains in the canvas; drop workflow-level `chainId`; chain-agnostic workflow list + +Release / ops (this repo): + +- [ ] **staging → main** promotion: confirm the `wipe-chain-bucketed-task-keys` migration runs at boot; + `make storage-check` will flag the breaking proto + key-template change (expected — the migration is the handler). + Coordinate with the SDK/studio client update so chain-less calls aren't in flight when strict prod goes live. + +Future (not started): + +- [ ] **Phase 4** — true cross-chain *sequencing* (bridge A→B then act on B): wait-for-finality primitive + + re-entrant executor. Independent multi-chain (watch X / act Y) already works; this is the bridge case only. + +--- + +## TL;DR — per-node chains already work; the task-level chain is being removed + +The proto, the REST mapping for nodes, the v4 SDK, and the **deployed-execution** path already support +per-node chains. The studio plan's premise that *"triggers/nodes carry no chainId of their own"* and that +Phase 3 is *"blocked on an AVS model change"* is **out of date**. Two tiers of work remain: +**(1) narrow plumbing** (G1–G4) to make per-node/trigger chains flow on every path; and **(2) the decided +structural change** (G5) — make `chain_id` **required** on every chain-aware part and **remove the +task-level chain entirely** (no `Task.chain_id`, chain-agnostic task storage). G5 is a real refactor +(~30 readers + storage-key change + wipe migration), shipped after the plumbing. + +### What already works (verified) + +| Layer | Status | Evidence | +|---|---|---| +| Proto: `Task.chain_id` (task-level default) | ✅ | `protobuf/avs.proto:817`, `CreateTaskReq.chain_id` `:858` | +| Proto: per-trigger/per-node `chain_id`, `0 = inherit task` | ✅ | EventTrigger `:222`, BlockTrigger `:152`, ContractWrite `:410`, ContractRead `:455`, ETHTransfer `:384` | +| REST → proto: per-**node** chainId carried (protojson round-trip) | ✅ | `aggregator/rest/mapping/node.go:203` (`jsonRetargetProto` → `protojson.Unmarshal`); proto json name `chainId` maps onto `Config.ChainId` automatically | +| REST → proto: **BlockTrigger** config chainId | ✅ | `aggregator/rest/mapping/trigger.go:119-120` | +| Deployed execution honors per-node chainId | ✅ | `core/taskengine/vm.go:1475,1518,1743` read `node.Config.GetChainId()` → `resolveSmartWalletForNode` (`vm.go:347-361`) | +| Multi-chain RPC/smart-wallet pool in the engine | ✅ | `Engine.chainConfigs` + `ResolveSmartWalletConfig(chainID)` `core/taskengine/engine.go:426-433`; `chainConfigResolver` wired to VMs at simulation/executor/run-node sites | +| Inherit logic: node-chain → task-chain → default | ✅ | `resolveSmartWalletForNode` `vm.go:347-361`, tested `vm_resolve_smart_wallet_test.go` (regression for Sentry EIGENLAYER-AVS-1N/1M) | +| Gateway mode requires explicit task `chain_id` | ✅ | `engine.go:1637-1660` | +| v4 SDK builders + types expose per-node/per-trigger `chainId` | ✅ | `ava-sdk-js` `builders/nodes.ts`, `builders/triggers.ts`; `packages/types/src/openapi.gen.ts` (`*NodeConfig.chainId`, `*TriggerConfig.chainId`) | +| OpenAPI spec documents per-node/trigger chainId + inherit | ✅ | `api/openapi.yaml:402,492,592,618,634` | + +### The actual gaps (Phases 1–2 are plumbing; G5/Phase 3 is the decided model change) + +| # | Gap | Location | Effect today | +|---|---|---|---| +| **G1** | ~~EventTrigger.Config.chainId dropped at REST→proto.~~ **FIXED** — `openAPIEventToProto`/`protoEventToOpenAPI` now map `chainId` both directions (mirrors BlockTrigger). Test: `trigger_test.go` event case. | `aggregator/rest/mapping/trigger.go` | Event-trigger chain now reaches the proto. | +| **G2** | ~~Operator monitors triggers by task chain, not the trigger's own chain.~~ **FIXED** — the aggregator now dispatches `TaskMetadata.ChainId` as the trigger's monitoring chain (`triggerMonitoringChainID`, event/block config chain → task-chain fallback), and computes operator coverage + orphan-scan from it; the operator routes on that value. Test: `chain_per_part_test.go` (`TestTriggerMonitoringChainID`). | `core/taskengine/engine.go`, `operator/worker_loop.go` | An event trigger on chain X attached to a task whose default chain is Y is now watched on X. | +| **G3** | ~~`ExtractNodeConfiguration` omits `chainId`.~~ **FIXED** — emits `chainId` for contract/transfer nodes; `CreateNodeFromType` reads it back onto the proto; `requireChainIDFromConfig` accepts the camelCase key. Test: `chain_per_part_test.go`. | `core/taskengine/vm.go`, `run_node_immediately.go` | Simulate/preview/`runNodeImmediately`/loop-nested now honor the per-node chain. | +| **G4** | ~~Unknown explicit chain silently fell back / wasn't validated at create.~~ **FIXED** — `resolveSmartWalletForNode` errors on an explicit unresolvable chain (`vm.go:347`), and `validateExplicitPartChains` rejects an unconfigured explicit part chain at create (gateway mode). Tests: `vm_resolve_smart_wallet_test.go`, `chain_per_part_test.go`. `0`-inherit still allowed until G5. | `core/taskengine/engine.go`, `vm.go:347` | Explicit wrong-chain execution closed at both create and run. | +| **G5** | ✅ **IMPLEMENTED** (branch `feat/per-part-chain-phase3-g5`): `Task.chain_id`/`CreateTaskReq.chain_id` removed from proto (reserved 16/10); task storage chain-agnostic (`t::`, read-iteration collapsed); `resolveSmartWalletForNode` requires an explicit configured chain in **all modes** (chain_id<=0 is a hard error); the create-time validator also rejects chain_id<=0 on chain-aware parts; wipe-chain-bucketed-task-keys boot migration; "0 = inherit" dropped from proto/OpenAPI. Wallet-ownership/fee/exec-default resolve to the aggregator default chain; dispatch/coverage key on the trigger chain. — **Make `chain_id` required per chain-aware part AND remove the task-level chain entirely** (the decided model). Per the resolution rule, `chain_id` becomes REQUIRED (`>0`, configured) on event/block triggers and contract/transfer nodes, all modes; `0` rejected. `Task.chain_id` / `CreateTaskReq.chain_id` are **removed** and task **storage becomes chain-agnostic** (chain drops out of `t:`/`u:`/execution keys — Decision 1). Requires: drop "0 = inherit" from proto + OpenAPI; remove audit-class-**A** fallback sites; fix the Loop runner; update ~30 `task.ChainId` readers; **wipe-all migration** (storage schema changed; legacy parts unfixable). | proto `avs.proto` (remove fields 16/10 + the 151/222/383/410/455 comments), `api/openapi.yaml`, `storage/schema/workflow.go` + `core/taskengine/schema.go` (key builders), `vm.go:347`/`:1583`, `loop_helpers.go:205`, `executor.go`, `engine.go` (~30 readers), `ChainRegistry.*`, `aggregator/key.go`, `migrations/` | Today chain is baked into the task (field + storage key) and a `0`-chain part silently runs on it. After G5, chain lives **only** on the parts — explicit-or-error — and a task belongs to no chain at all; per-chain views derive from the parts. Only the (B) chain-agnostic operator-routing sentinel for cron/manual survives. | + +G1–G4 turn the existing infrastructure into working per-node/per-trigger multi-chain execution. **G5 is +the model change** the team decided on (2026-06-26): make `chain_id` required on chain-aware parts and +delete inheritance. No backfill — a one-shot delete migration clears pre-existing `0`-chain tasks at boot +before the strict code runs; owners re-create them with explicit chains. + +--- + +## Backend answers to studio's Phase-3 gate questions + +(Responses to `studio/PLAN_WALLET_CHAIN_DECOUPLING.md` → "Open questions to confirm with the AVS backend +before studio Phase 3". All verified in code.) + +1. **Auth granularity — one authKey authorizes the whole task; single-sign, no per-chain user signature.** + On-chain execution is signed by the **backend controller key** (`pkg/erc4337/preset/builder.go:1064`, + `smartWalletConfig.ControllerPrivateKey`), resolved per chain — **not** by the user. The user authKey/JWT + (`aud` = chain) is checked **only at the create-API boundary** (`aggregator/rest/middleware/jwt.go`); + the executor never re-validates it. The authKey's chain is just which gateway endpoint the create + request used — it does **not** have to match the task's per-node chains. So one create-time authKey + authorizes a task whose nodes/triggers span several chains. Per-step user auth is only the bridge case + (Phase 4). **Studio's read confirmed.** +2. **Create-path acceptance — explicit-per-part required; the task-level chain field is removed.** + Per-node/trigger chain flows through the protojson round-trip already. Under the decided model (G5), + the create path **requires** every chain-aware trigger/node to carry an explicit `chain_id` in the + configured set, and **rejects** `chain_id == 0` or an unconfigured chain with `InvalidArgument`. There + is **no workflow-level chain at all** — `Task.chain_id` / `CreateTaskReq.chain_id` are deleted and task + storage is chain-agnostic (Decision 1). So: studio sends an explicit chain on every event/block trigger + and contract/transfer node, and **stops sending / reading any workflow-level `chainId`**. A request that + omits a chain-aware part's chain is rejected, not flattened. +3. **Runner provisioning/funding — automatic deploy, per-chain paymaster, no pre-check.** The wallet + deploys counterfactually on first UserOp (initCode injected when code is empty, + `pkg/erc4337/preset/builder.go:1331`). Paymaster + bundler are the node-chain's via + `ResolveSmartWalletConfig(nodeChainID)`. One CREATE2 address is the runner on all 4 core chains + (Decision 2). The backend does **not** pre-check balance; it surfaces `AA21` (prefund) at execution. + **Studio must surface per-node-chain readiness** ("needs gas / paymaster on chain Y"); the backend + won't block create. +4. **Cleared chain set — the 4 core chains, enforced.** The usable set = the aggregator's configured + `chainConfigs` (bundler + paymaster + controller present). Per `docs/Contract.md` that's **Ethereum, + Base, Sepolia, Base Sepolia**. The per-node chain selector **must** restrict to those four — once G5 + lands, a node naming any other chain is rejected at create. Soneium/Minato out of scope. +5. **Landing order — align studio with G5, not just G1/G3.** Backend G1 (event-trigger mapping) + G3 + (extract config) make per-part chains flow; G5 makes them required and removes task-level inheritance. + Studio should send **explicit per-part chains from day one** (works after G1+G3) and **never** depend + on a workflow-level `chainId` cascading — that contract is exactly what G5 enforces. Net: studio + Phase 3 ships after backend G1+G3; the data model it adopts must match G5 (per-part required, no + workflow-level inheritance). + +--- + +## Design model (the contract every layer follows) + +### chainId is a property of *conditions/actions*, not of the task + +A task has **exactly one trigger** (`Task.trigger` is a single field, not `repeated`) and N nodes. +chainId is intrinsic to the parts that touch a chain, and irrelevant to the rest: + +| Part | What it does | chainId relevant? | +|---|---|---| +| Manual trigger | fired by API call | no | +| Cron / FixedTime trigger | watches wall-clock time | no | +| **Block trigger** | watches block height on a chain | **yes** | +| **Event trigger** | watches logs on a chain | **yes** | +| **ContractWrite / ContractRead / ETHTransfer node** | acts on a chain | **yes** | +| CustomCode / REST / GraphQL / Branch / Filter / Loop node | pure compute / off-chain | no | + +So a workflow's multi-chain behavior has **two independent axes**, and the operator only owns the first: + +1. **The trigger** decides which chain (or none) to *watch* for the firing condition. The operator + monitors exactly this. Because there is one trigger per task, the operator never reconciles multiple + chains — it routes the single trigger by *the trigger's own chain* (event/block) or treats it as + chain-agnostic (cron/manual). There is no `Task.chain_id` to consult — the event/block trigger carries + its own required chain, and the operator routes by it. +2. **Each node** decides which chain to *act on* during execution. This is the executor/aggregator's + concern (already wired via `resolveSmartWalletForNode`), invisible to the operator. + +Example: watch a price event on Ethereum → execute a swap on Base. The operator only ever opens the +Ethereum subscription; Base enters the picture solely at node execution. This is exactly why G2's fix is +"route by the trigger's chain," not "thread the task chain through." + +### Resolution rule — `chain_id` is REQUIRED on chain-aware parts; there is no inheritance + +**Decided (2026-06-26):** chain lives **only** on the part that touches a chain. `0` is the protobuf +zero value — it means "unset", never a real chain — so on any chain-aware trigger/node it is **invalid** +and rejected. There is **no fallback to the task chain, and no aggregator-default fallback, in any mode +(gateway or single-chain).** + +``` +chain-aware trigger/node Config.chain_id: + > 0 and in the configured set → use it + anything else (0 / unconfigured) → ERROR (reject at create AND at execution) +``` + +- Applies to: event & block triggers; contractRead / contractWrite / ethTransfer nodes; the Loop runner + when it wraps one of those. +- Does **not** apply to parts that never touch a chain: manual/cron triggers and off-chain nodes + (customCode, REST, GraphQL, branch, filter, loop-over-data). Those carry no chain — a task built + only from them is genuinely chain-agnostic. +- **Single-chain dev mode is not exempt** (per decision): even with one configured chain, a chain-aware + node must name its `chain_id` explicitly and it must equal the configured chain. No "the one chain + stands in for 0" shortcut — we want zero `0`-special-cases. + +Why no inheritance: `0`-means-inherit is exactly the coupling that produced the wrong-chain paymaster +failures (Sentry EIGENLAYER-AVS-1N/1M) and the silent-wrong-chain footgun (G4). Making `chain_id` +required everywhere collapses ~6 "magic fallback to a default chain" sites (see audit class **A** below) +into one uniform "explicit-or-error" rule — the pattern the strict guards already use +(`requireChainIDFromConfig`, worker-routed readers/token services). + +> Audit context: `chain_id == 0` is special-cased in ~17 places, in three roles. **(A)** magic fallback +> to a real default chain (`resolveSmartWalletForNode` node→task→`chains[0]`, `executor.go:256`, +> `ChainRegistry.ResolveChainID/GetWorker/GetChainConfig`, `operator.triggersForChain`, +> `aggregator/key.go`, and `chainScopedTaskKey`) — **all removed**; per Decision 1 the task storage key +> drops the chain entirely (chain-agnostic addressing), so `chainScopedTaskKey` goes away rather than +> losing only its `0`-default. **(B)** legitimate "chain-agnostic / not applicable" sentinel +> (`operatorsCoveringChain(0)`, cron/manual tasks, `chainHasOperator[0]`) — **kept**, that's how a +> chain-free task routes to any operator; **(C)** guards that already hard-error on 0 — **the target +> pattern**. + +### Decision 1 — Remove the task-level chain entirely; task storage is chain-agnostic + +**Decided (2026-06-26):** there is **no chain tied to a task** — not for execution, not for storage. +`Task.chain_id` and `CreateTaskReq.chain_id` are **removed**. A task is addressed by id (+ owner index), +not by chain. Chain lives **only** on the chain-aware parts (G5); any "by chain" view is *derived* from +the trigger/node chains, never stored on the task. This is the full structural decoupling — a workflow +does not belong to a chain in any sense. + +What this changes: + +- **Storage keys drop the chain.** `t:::` → `t::`; the user index + `u::::` → `u:::`; execution keys + (`ChainTaskExecutionKey`) drop the chain too (an execution can span chains, so keying it by one is the + same coupling). **Breaking storage-key change** — `make storage-check` will flag it; lands with the + migration below. +- **Proto.** Remove `Task.chain_id` (16) and `CreateTaskReq.chain_id` (10); reserve the field numbers. + Update mappers (`aggregator/rest/mapping/workflow.go`), drop the REST handler's authKey-aud backfill + (`handlers_workflows.go:56`), run `make protoc-gen`. The JWT `aud` chain stays — it's **API auth scope**, + no longer a task field. +- **Listing/CRUD goes chain-agnostic.** `get(id)` / `cancel` / `pause` key on id alone; `list(owner)` + returns all of an owner's tasks. A "tasks on chain Y" filter is computed server-side from the parts' + chains, not a key prefix. Studio's per-chain client (`getClientWithToken(chainId)`) moves to a + chain-agnostic list; the dashboard (their #4) shows all chains with an optional derived per-chain filter. +- **Execution has no task-default chain.** `NewExecutor`/`NewVMWithData` no longer pass + `ResolveSmartWalletConfig(task.ChainId)`; the VM default smart-wallet config is **nil**, so every + chain-aware node must resolve its *own* chain or error (exactly G5's rule). The ~30 `task.ChainId` + readers (engine.go, vm.go, summarizer, executor) are updated to derive chain from the relevant part: + operator coverage at create → the **trigger's** chain; summarizer enrichment → the node's chain. +- **Wallet-salt keys stay per-chain (separate concern).** `wsalt:%d:…` and `GetWallet(db, chainID, …)` + (`vm.go:1583`) are about wallet derivation, not task identity. The runner address is chain-invariant + across the 4 core chains (Decision 2), so these can pass *any* configured chain (e.g. the node's). Not + part of this decoupling — leave the wallet keys as-is for now; flag for a follow-up if desired. + +This dissolves studio's "which chain is `CreateTaskReq.chain_id`?" loose end entirely: **there is no such +field.** Studio sends explicit per-part chains and nothing else; the create endpoint's authKey is API auth +only. Their dashboard lists across chains and derives any per-chain view from the parts. + +**Migration: wipe all tasks (decided 2026-06-26).** Two facts make a selective migration pointless: +(1) the storage-key schema changes (`t::…` → `t:…`), so *every* existing row is on the old layout; +(2) legacy tasks carry `chain_id == 0` on their parts and can't be made executable by re-keying — they'd +fail G5's required-chain rule anyway. Re-keying buys nothing. So the one-shot migration **deletes all +existing tasks** (and their user-index / execution rows) at boot — taking a full DB backup, recording +completion so it runs once, *before* the engine serves — and owners re-create with explicit per-part +chains. The deployed set is tiny and we've accepted breaking it; cleanliness over preservation. + +Follows the established delete-task pattern (`DeleteAutoDisabledInvalidTasks`, +`docs/historical-migrations/2026-completed/`): constant-memory `IterateKeysOnly` scan of the old `t:` +prefix, delete every key each task owns. Undecodable rows are deleted too (the whole old namespace is +being retired). *(If preserving chain-agnostic cron/off-chain-only tasks later matters, those — and only +those — could be re-keyed instead of deleted, since they have no chain-aware part to fix. Out of scope +unless asked.)* + +### Decision 2 — One `smart_wallet_address` (runner) stays task-level; chain varies per step + +The studio plan assumes per-node chains require *"multiple runners (a smart wallet per chain)."* **They +do not.** `model.SmartWallet` is keyed by `(owner, factory, salt)` with **no chainId** +(`model/user.go`); the CREATE2 address is deterministic in the **deployer** (Factory Proxy) and the +**wallet init-code hash** (which embeds the Wallet Implementation). So: + +- The task carries **one** `smart_wallet_address`; the same address is the runner on every chain where + those two inputs match. +- What is per-chain is **deployment + funding + paymaster**, which the engine already resolves per chain + via `ResolveSmartWalletConfig(chainID)`. + +**Audit result (`docs/Contract.md`, 2026-06-26) — scoped to the supported chains (Ethereum, Base, +Sepolia, Base Sepolia):** + +| Input to the CREATE2 address | Value | Across the 4 supported chains | +|---|---|---| +| Factory **Proxy** (the deployer) | `0xB99BC2E399e06CddCF5E725c0ea341E8f0322834` | identical ✅ | +| Wallet **Implementation** (in wallet init code) | `0xf5d0c65516f0724242343c4eAA5D9de3ee4291fB` | identical ✅ | +| Factory **Implementation** | varies | irrelevant — the Proxy is the deployer; the impl runs via delegatecall in the proxy context | + +**Decision 2 holds.** Same deployer + same wallet implementation ⇒ identical CREATE2 address across all +four supported chains. One runner address serves all of them; safe to ship per-node multi-chain now. + +(Soneium/Minato are out of scope — not currently supported — so their differing wallet implementation is +not a concern for this work.) + +### Decision 3 — `chain_id` required on every chain-aware part, all modes + +Supersedes the old "gateway requires task chain_id" carve-out: the requirement is now uniform and lives +on the **part**, not the task. Gateway and single-chain mode behave identically — a chain-aware +trigger/node with `chain_id ≤ 0` or an unconfigured chain is rejected at create and at execution. The +existing gateway task-chain check (`engine.go:1637-1660`) is **deleted** along with `Task.chain_id` +(Decision 1) — there is no task chain left to validate. + +--- + +## Implementation — bottom-up phases + +Phases 1–2 are **pure plumbing on the existing model** (per-node/trigger chains already representable) +and unblock studio Phase 3. Phase 3 is the **decided model change** — make `chain_id` required, delete +inheritance (a one-shot delete migration clears stale `0`-chain tasks at boot; no backfill). Phase 4 is +the genuinely new protocol work (cross-chain sequencing) and maps to studio Phase 4. + +### Phase 0 — Lock the contract & prove the gaps (no behavior change) + +- Add a backend integration test that builds a task whose **node** chain ≠ **task** chain and asserts the + deployed execution dials the node's chain (this should already pass — it pins the working path so the + later fixes don't regress it). +- Add tests that assert G1/G3 currently drop the chain (red), to be flipped green by Phases 1–2. +- Audit `SmartWalletConfig.FactoryAddress` equality across all configured chains (Decision 2 risk). +- `make storage-check` baseline. + +### Phase 1 — Close the ingestion + simulation gaps (G1, G3, G4) — **IMPLEMENTED** + +1. **G1 — EventTrigger chain mapping. ✅** `openAPIEventToProto`/`protoEventToOpenAPI` + (`aggregator/rest/mapping/trigger.go`) map `chainId` both directions, mirroring BlockTrigger. Covered + by the `event` case in `trigger_test.go`. +2. **G3 — `ExtractNodeConfiguration` chain. ✅** Emits `chainId` for ContractWrite/ContractRead/ETHTransfer; + `CreateNodeFromType` reads it back onto the proto Config; `requireChainIDFromConfig` now accepts the + camelCase `chainId` key (node maps) alongside snake `chain_id` (trigger maps). Covered by + `chain_per_part_test.go`. +3. **G4 — reject unknown explicit chains at create. ✅** `validateExplicitPartChains` + (`core/taskengine/engine.go`), called in `CreateTask`, rejects a chain-aware trigger/node naming an + unconfigured explicit chain with `InvalidArgument` (gateway mode; checks against `knownChainIDs()`, + walks Loop runners). Pairs with the runtime guard in `resolveSmartWalletForNode`. `chain_id == 0` + (inherit) is still allowed — G5 tightens that. Covered by `chain_per_part_test.go` + + `vm_resolve_smart_wallet_test.go`. + +**Outcome:** per-node and per-event-trigger chains are honored on **every** execution path *except* the +operator's event subscription (G2). Independent, low-risk, no storage change. `chain_id == 0` still +inherits the task chain (legacy path) — tightened in Phase 3. Build + targeted tests green. + +### Phase 2 — Operator routes the trigger by the trigger's own chain (G2) — **IMPLEMENTED** + +Done at the **aggregator** (single source of truth) rather than only the operator: a new +`triggerMonitoringChainID(trigger, fallback)` returns the event/block trigger's own configured chain +(falling back to the task chain when the trigger left it 0 — legacy, until G5). It's applied at all three +`TaskMetadata.ChainId` dispatch sites, the create-time coverage check (`operatorsCoveringChain`), and the +orphan-scan coverage map. The operator keeps routing on `TaskMetadata.GetChainId()` — now the trigger's +monitoring chain — and its variable was renamed `monitorChainID` for clarity. Because there is **one +trigger per task**, there's no union-of-chains problem. Cron/manual/fixed-time stay chain-agnostic (the +helper carries the fallback through; the operator's TimeTrigger ignores it). + +**Outcome:** event triggers fire on their own chain. A workflow can now *watch* chain X and *act* on +chain Y within one task, with independent (non-sequential) steps. This fully satisfies studio Phase 3. +Build + targeted tests (`TestTriggerMonitoringChainID`, operator + engine coverage suites) green. + +### Phase 3 — `chain_id` required per part; remove the task-level chain; chain-agnostic storage (G5) + +The decided model change — the big structural one. Ships as one release with a **wipe-all migration** at +boot (the storage schema changes, so all old rows go; owners re-create with explicit per-part chains). +The migrator runs before the engine serves, so the new schema is clean from first request. + +1. **Wipe-all migration.** Delete every task on the old `t::…` schema (status rows, `u:` user + index, execution rows). Pattern: `DeleteAutoDisabledInvalidTasks` (constant-memory `IterateKeysOnly`, + full DB backup, recorded once) — but scanning the whole old `t:` namespace, not a subset. Register in + `migrations/migrations.go`. +2. **Chain-agnostic storage.** Drop the chain from the key builders — `storage/schema/workflow.go` + + `core/taskengine/schema.go` (`ChainWorkflowStorageKey`→`WorkflowStorageKey`, `ChainTaskUserKey`, + `ChainTaskExecutionKey`). Update list/get/cancel/pause to key on id; the per-chain filter is derived + server-side from the parts. `make storage-check` will flag the template change — that's expected, the + migration covers it. +3. **Remove the task chain field.** Delete `Task.chain_id` (16) + `CreateTaskReq.chain_id` (10) from the + proto (reserve the numbers), update mappers, drop the authKey-aud backfill (`handlers_workflows.go:56`), + `make protoc-gen`. Update the ~30 `task.ChainId` readers to derive chain from the relevant part + (operator coverage at create → trigger chain; summarizer → node chain; executor → nil VM default). +4. **Flip to explicit-or-error.** Make `resolveSmartWalletForNode` reject `chain_id <= 0` (no fallbacks); + the create validator rejects `0`/unconfigured on chain-aware parts in **all** modes; remove the + remaining audit-class-**A** sites (`executor.go`, `ChainRegistry.ResolveChainID/GetWorker/GetChainConfig`, + `operator.triggersForChain`, `aggregator/key.go`). +5. **Fix the Loop runner.** `createNestedNodeFromLoop` (`loop_helpers.go:205`) must carry an explicit + chain on the nested node — from the Loop runner's own config. (Once nodes require a chain, the inner + node already has it; verify the nested-node path preserves it.) +6. **Drop the docs/comments.** Remove "0 = inherit task's chain_id" from `protobuf/avs.proto` (151/222/ + 383/410/455) and the `ChainId` schema note in `api/openapi.yaml:117-124`. +7. **Keep the (B) sentinel.** Do **not** touch `operatorsCoveringChain(0)` / cron-manual chain-agnostic + routing — a chain-free task legitimately has no chain. +8. **SDK + tests.** SDK always writes explicit per-part chains and stops sending a workflow-level chain; + update tests that assumed `0`-inherit or a task chain. + +**Outcome:** chain lives **only** on the parts — explicit-or-error — and a task belongs to no chain in +any sense (not execution, not storage, not listing). Per-chain views are derived. This is the full +structural decoupling. + +### Phase 4 — True cross-chain sequencing (bridge mid-workflow) + +This is the genuinely unbuilt protocol work (studio Phase 4). Per-node chains give *independent* +multi-chain steps; a real bridge needs **sequential cross-chain settlement**: act on B only after the +bridge from A confirms. Requires, at minimum: + +- a node/primitive that submits a bridge and **waits for finality on the destination chain** before + downstream nodes run; +- execution state that survives a cross-chain wait (re-entrant executor, not a single synchronous pass); +- per-step authKey/paymaster minting on the destination chain (the engine already resolves per-chain + configs; the executor must request the right one per step — already true for per-node chains, but the + **wait/resume** is new). + +Scope this separately once Phases 1–3 land; do not block them on it. + +--- + +## Coordination & sequencing + +- **AVS backend (this repo): Phases 0→1→2→3** — self-contained. Phases 1–2 are non-breaking plumbing; + Phase 3 (G5) is the breaking model change, shipped with a one-shot delete migration that clears stale + `0`-chain tasks at boot (no backfill). +- **v4 SDK (`ava-sdk-js`):** already exposes the fields; add a **multi-chain template test** (per-part + explicit chains) once Phase 1 lands, to lock the wire contract. Ensure it always writes explicit chains + (G5 makes that mandatory). No type changes needed. +- **Studio:** its Phase 1/2 (identity/chain decoupling from the live wallet) are independent and can + proceed now. Its Phase 3 (per-node chain in the canvas data model) is unblocked the moment backend + Phase 1–2 ship — and must adopt the **G5 contract**: explicit chain per chain-aware part, no + workflow-level `chainId` inheritance. +- Every change touching `model/` or `protobuf/` must pass `make storage-check` against `origin/main` + before staging→main. + +## Correction to send to the studio plan + +`PLAN_WALLET_CHAIN_DECOUPLING.md` should be amended: + +- Line ~59 *"Triggers/nodes carry no chainId of their own"* — **false** at the AVS/proto/SDK layer; the + fields exist and (for nodes) are already honored end-to-end. The gap is studio's DTO not populating + them, plus backend G1/G2/G3. +- Phase 3 *"Blocked on AVS: confirm the task schema can represent a multi-chain task"* — **unblocked**; + it can. Reframe Phase 3 as "populate per-node/per-trigger chainId; reconcile deploy-time runner." +- Line ~106 *"multiple runners (a smart wallet per chain)"* — **not required**; one CREATE2 address + serves all chains (per the Decision 2 audit, for the 4 core chains). +- **New (decided model):** chain is **required per chain-aware part**, and the **workflow-level `chainId` + is removed entirely** — no `CreateTaskReq.chain_id`, no `Task.chain_id`, and task storage is + chain-agnostic. Studio must drop the workflow-level `chainId` from both the request it sends *and* the + record it reads: set chain explicitly on each event/block trigger and contract/transfer node (restricted + to the 4 configured chains); a part left without a chain is rejected at create. The dashboard lists + across chains and derives any per-chain view from the parts; the create endpoint's authKey remains + API-auth scope only, not a task chain. + +--- + +## Client contract — what the client must send (after #632) + +**Rule:** every **chain-aware** part carries its own `chainId` (`>0`, configured). No workflow-level +chain; no default fallback. Omitting it on a chain-aware part is rejected (`InvalidArgument`); non-chain +parts must not send one. + +**Chain-aware parts** (source of truth: `checkNodeChain` / `validateExplicitPartChains`): + +| Part | needs `chainId`? | proto field | +|---|---|---| +| EventTrigger | ✅ | `EventTrigger.Config.chain_id` | +| BlockTrigger | ✅ | `BlockTrigger.Config.chain_id` | +| ContractWrite node | ✅ | `ContractWriteNode.Config.chain_id` | +| ContractRead node | ✅ | `ContractReadNode.Config.chain_id` | +| ETHTransfer node | ✅ | `ETHTransferNode.Config.chain_id` | +| Loop node with a chain-aware runner | ✅ on the **runner's** config | runner `Config.chain_id` | +| Manual / Cron / FixedTime trigger | ❌ chain-agnostic | — | +| CustomCode / RestAPI / GraphQL / Branch / Filter | ❌ | — | +| Balance node | ⚠️ uses its own `chain` **string** (name or id), not `chain_id` | `BalanceNode.Config.chain` | + +**Per API surface:** + +- **CreateWorkflow** — set `chainId` on every chain-aware part; **stop sending a workflow-level `chainId`** + (`Task.chain_id` / `CreateWorkflowRequest.chainId` removed/ignored). Missing/`0` on any chain-aware part → rejected. +- **RunNodeWithInputs / runNodeImmediately** — set `RunNodeWithInputsReq.chain_id` (or the node's own + `chainId`); the backend stamps the request chain onto the node (`stampNodeChainIfUnset`). +- **SimulateTask** — per-part chains, or `SimulateTaskReq.chain_id`. +- **List / Get / Cancel / Pause / SetEnabled** — chain-agnostic (storage no longer per-chain); don't pass + a workflow chain to scope these. "Workflows on chain Y" is derived client-side from the parts. +- **Unchanged** (still take their own `chain_id`): `WithdrawFundsReq`, `EstimateFeesReq`, `GetTokenMetadataReq`. +- **Ignored:** `TriggerTaskReq.chain_id` (a task has no chain to override). + +**Allowed values** (the configured set; else "chain_id N is not configured on this aggregator"): +Ethereum `1`, Base `8453`, Sepolia `11155111`, Base Sepolia `84532`. (Soneium/Minato out of scope.) + +**SDK (`ava-sdk-js`) mapping** — builders already expose the params; the change is to always populate: +`Triggers.event/block({chainId})`, `Nodes.contractWrite/contractRead/ethTransfer({chainId})`, the Loop +runner's `chainId`, `runNodeWithInputs` request `chainId`; remove any workflow-level `chainId`. + +--- + +## References (code, exact locations) + +- Proto: `protobuf/avs.proto` — task `:817`, CreateTaskReq `:858`, EventTrigger.Config `:222`, + BlockTrigger `:152`, ContractWrite `:410`, ContractRead `:455`, ETHTransfer `:384`. +- REST mapping: `aggregator/rest/mapping/node.go:203` (protojson round-trip), + `aggregator/rest/mapping/trigger.go:117-135` (BlockTrigger maps chainId), `:190-205` (EventTrigger + **does not** — G1). +- Execution: `core/taskengine/vm.go:347-361` (`resolveSmartWalletForNode`), `:1475/1518/1743` + (deployed nodes read proto chain), `:3493` (`ExtractNodeConfiguration` — G3). +- Engine multi-chain pool: `core/taskengine/engine.go:426-433` (`ResolveSmartWalletConfig`), + `:649-682` (`operatorsCoveringChain`), `:1637-1660` (task chain resolution / gateway requirement). +- Operator: `operator/worker_loop.go:1055-1090` (event routing by task chain — G2). +- Model: `model/user.go` (`SmartWallet` has no chainId; `User.ChainID` from JWT aud), + `model/workflow.go` (`Workflow` wraps `*avsproto.Task`). +- OpenAPI: `api/openapi.yaml:402,492,592,618,634`. +- SDK (the `ava-sdk-js` repo): `packages/sdk-js/src/v4/builders/{nodes,triggers}.ts`, + `packages/types/src/openapi.gen.ts` (per-node/trigger `chainId`). diff --git a/aggregator/rest/generated/server.gen.go b/aggregator/rest/generated/server.gen.go index 61e52712..d3751510 100644 --- a/aggregator/rest/generated/server.gen.go +++ b/aggregator/rest/generated/server.gen.go @@ -141,13 +141,6 @@ func (w *ServerInterfaceWrapper) ListExecutions(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter workflowId: %s", err)) } - // ------------- Optional query parameter "chainId" ------------- - - err = runtime.BindQueryParameter("form", true, false, "chainId", ctx.QueryParams(), ¶ms.ChainId) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter chainId: %s", err)) - } - // ------------- Optional query parameter "before" ------------- err = runtime.BindQueryParameter("form", true, false, "before", ctx.QueryParams(), ¶ms.Before) @@ -277,13 +270,6 @@ func (w *ServerInterfaceWrapper) CountExecutions(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter workflowId: %s", err)) } - // ------------- Optional query parameter "chainId" ------------- - - err = runtime.BindQueryParameter("form", true, false, "chainId", ctx.QueryParams(), ¶ms.ChainId) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter chainId: %s", err)) - } - // Invoke the callback with all the unmarshaled arguments err = w.Handler.CountExecutions(ctx, params) return err @@ -304,13 +290,6 @@ func (w *ServerInterfaceWrapper) ExecutionStats(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter workflowId: %s", err)) } - // ------------- Optional query parameter "chainId" ------------- - - err = runtime.BindQueryParameter("form", true, false, "chainId", ctx.QueryParams(), ¶ms.ChainId) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter chainId: %s", err)) - } - // Invoke the callback with all the unmarshaled arguments err = w.Handler.ExecutionStats(ctx, params) return err @@ -592,13 +571,6 @@ func (w *ServerInterfaceWrapper) ListWorkflows(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter status: %s", err)) } - // ------------- Optional query parameter "chainId" ------------- - - err = runtime.BindQueryParameter("form", true, false, "chainId", ctx.QueryParams(), ¶ms.ChainId) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter chainId: %s", err)) - } - // ------------- Optional query parameter "before" ------------- err = runtime.BindQueryParameter("form", true, false, "before", ctx.QueryParams(), ¶ms.Before) @@ -789,13 +761,6 @@ func (w *ServerInterfaceWrapper) CountWorkflows(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter status: %s", err)) } - // ------------- Optional query parameter "chainId" ------------- - - err = runtime.BindQueryParameter("form", true, false, "chainId", ctx.QueryParams(), ¶ms.ChainId) - if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter chainId: %s", err)) - } - // Invoke the callback with all the unmarshaled arguments err = w.Handler.CountWorkflows(ctx, params) return err diff --git a/aggregator/rest/generated/types.gen.go b/aggregator/rest/generated/types.gen.go index 92d047da..5f6ff0e7 100644 --- a/aggregator/rest/generated/types.gen.go +++ b/aggregator/rest/generated/types.gen.go @@ -296,10 +296,10 @@ type BlockTriggerType string // BlockTriggerConfig Fires every N blocks on the target chain. type BlockTriggerConfig struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. - ChainId *ChainId `json:"chainId,omitempty"` + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. + ChainId ChainId `json:"chainId"` // Interval Fire every N blocks. Interval int64 `json:"interval"` @@ -330,9 +330,9 @@ type BranchNodeConfig struct { Conditions []BranchCondition `json:"conditions"` } -// ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default -// chain" — typically only useful in single-chain deployments or for -// chain-agnostic operations. +// ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On +// chain-aware trigger/node configs this is required and must be a +// configured chain; on query/filter params it is optional. type ChainId = int64 // ContractReadNode defines model for ContractReadNode. @@ -346,10 +346,10 @@ type ContractReadNodeType string // ContractReadNodeConfig defines model for ContractReadNodeConfig. type ContractReadNodeConfig struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. - ChainId *ChainId `json:"chainId,omitempty"` + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. + ChainId ChainId `json:"chainId"` ContractAbi *[]map[string]interface{} `json:"contractAbi,omitempty"` // ContractAddress Lowercase or checksummed hex EOA / contract address. @@ -371,10 +371,10 @@ type ContractWriteNodeConfig struct { // CallData Arbitrary-length hex-encoded byte string. CallData *Hex `json:"callData,omitempty"` - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. - ChainId *ChainId `json:"chainId,omitempty"` + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. + ChainId ChainId `json:"chainId"` ContractAbi *[]map[string]interface{} `json:"contractAbi,omitempty"` // ContractAddress Lowercase or checksummed hex EOA / contract address. @@ -393,9 +393,9 @@ type ContractWriteNodeConfig struct { // CreateWalletRequest defines model for CreateWalletRequest. type CreateWalletRequest struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId *ChainId `json:"chainId,omitempty"` // FactoryAddress Lowercase or checksummed hex EOA / contract address. @@ -407,12 +407,8 @@ type CreateWalletRequest struct { // CreateWorkflowRequest defines model for CreateWorkflowRequest. type CreateWorkflowRequest struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. - ChainId *ChainId `json:"chainId,omitempty"` - Edges *[]Edge `json:"edges,omitempty"` - ExpiredAt *int64 `json:"expiredAt,omitempty"` + Edges *[]Edge `json:"edges,omitempty"` + ExpiredAt *int64 `json:"expiredAt,omitempty"` // InputVariables Free-form key-value bag of values used to resolve `{{variable.path}}` // template references inside trigger and node configs. Conventional @@ -504,10 +500,10 @@ type ETHTransferNodeConfig struct { // Amount Amount in wei (decimal string for big-int safety). Special value `max` withdraws the entire balance. Amount string `json:"amount"` - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. - ChainId *ChainId `json:"chainId,omitempty"` + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. + ChainId ChainId `json:"chainId"` // Destination Lowercase or checksummed hex EOA / contract address. Destination EthereumAddress `json:"destination"` @@ -526,9 +522,9 @@ type Edge struct { // EstimateFeesRequest defines model for EstimateFeesRequest. type EstimateFeesRequest struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId *ChainId `json:"chainId,omitempty"` CreatedAt int64 `json:"createdAt"` Edges *[]Edge `json:"edges,omitempty"` @@ -550,9 +546,9 @@ type EstimateFeesRequest struct { // EstimateFeesResponse defines model for EstimateFeesResponse. type EstimateFeesResponse struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId ChainId `json:"chainId"` // Cogs Per-node operational costs (gas, external API). @@ -608,10 +604,10 @@ type EventTriggerType string // EventTriggerConfig Fires when matching on-chain events are observed. type EventTriggerConfig struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. - ChainId *ChainId `json:"chainId,omitempty"` + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. + ChainId ChainId `json:"chainId"` // CooldownSeconds Seconds to wait after a fire before allowing the same task to // trigger again. Default 300. 0 disables cooldown. @@ -644,9 +640,9 @@ type EventTriggerQuery struct { // Execution defines model for Execution. type Execution struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId *ChainId `json:"chainId,omitempty"` // Cogs Per-node actual costs (gas, external API). @@ -831,9 +827,9 @@ type GraphQLQueryNodeConfig struct { // HealthStatus defines model for HealthStatus. type HealthStatus struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId *ChainId `json:"chainId,omitempty"` Status HealthStatusStatus `json:"status"` @@ -1086,9 +1082,9 @@ type RestAPINodeConfig_Options struct { // RunNodeRequest defines model for RunNodeRequest. type RunNodeRequest struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId *ChainId `json:"chainId,omitempty"` // Erc20Overrides Optional ERC20 balance/allowance state overrides applied only during this isolated node simulation. Lets callers seed token balances and approvals so contract-write simulations (e.g. Uniswap swaps) don't revert with "transfer amount exceeds allowance/balance" before the approval/funding transactions have been run. Simulation-only: a real-execution request (isSimulated=false) that sets these is rejected with an error, never silently ignored. @@ -1164,9 +1160,9 @@ type SecretList struct { // SimulateWorkflowRequest defines model for SimulateWorkflowRequest. type SimulateWorkflowRequest struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId *ChainId `json:"chainId,omitempty"` Edges *[]Edge `json:"edges,omitempty"` @@ -1188,9 +1184,9 @@ type TokenMetadataResponse struct { // Address Lowercase or checksummed hex EOA / contract address. Address *EthereumAddress `json:"address,omitempty"` - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId *ChainId `json:"chainId,omitempty"` Decimals *int32 `json:"decimals,omitempty"` Found bool `json:"found"` @@ -1307,9 +1303,9 @@ type WithdrawRequest struct { // Amount Amount in wei (decimal string) or `max` for the full balance. Amount string `json:"amount"` - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. + // ChainId Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + // chain-aware trigger/node configs this is required and must be a + // configured chain; on query/filter params it is optional. ChainId *ChainId `json:"chainId,omitempty"` // RecipientAddress Lowercase or checksummed hex EOA / contract address. @@ -1345,11 +1341,6 @@ type WithdrawResponseStatus string // Workflow defines model for Workflow. type Workflow struct { - // ChainId Numeric chain ID. `0` or omitted means "use the aggregator's default - // chain" — typically only useful in single-chain deployments or for - // chain-agnostic operations. - ChainId *ChainId `json:"chainId,omitempty"` - // CompletedAt Unix-epoch milliseconds — when the workflow reached a terminal state. CompletedAt *int64 `json:"completedAt,omitempty"` @@ -1463,10 +1454,6 @@ type ListExecutionsParams struct { // WorkflowId Filter by workflow ID. Repeat to OR multiple workflows. WorkflowId *[]Ulid `form:"workflowId,omitempty" json:"workflowId,omitempty"` - // ChainId Chain ID filter. Omit to use the aggregator default chain. Repeat - // the parameter to filter by multiple chains. - ChainId *ChainIdQuery `form:"chainId,omitempty" json:"chainId,omitempty"` - // Before Cursor — return items immediately before this position (backward pagination). Before *PageBefore `form:"before,omitempty" json:"before,omitempty"` @@ -1504,19 +1491,11 @@ type StreamExecutionParams struct { // CountExecutionsParams defines parameters for CountExecutions. type CountExecutionsParams struct { WorkflowId *[]Ulid `form:"workflowId,omitempty" json:"workflowId,omitempty"` - - // ChainId Chain ID filter. Omit to use the aggregator default chain. Repeat - // the parameter to filter by multiple chains. - ChainId *ChainIdQuery `form:"chainId,omitempty" json:"chainId,omitempty"` } // ExecutionStatsParams defines parameters for ExecutionStats. type ExecutionStatsParams struct { WorkflowId *[]Ulid `form:"workflowId,omitempty" json:"workflowId,omitempty"` - - // ChainId Chain ID filter. Omit to use the aggregator default chain. Repeat - // the parameter to filter by multiple chains. - ChainId *ChainIdQuery `form:"chainId,omitempty" json:"chainId,omitempty"` } // ListSecretsParams defines parameters for ListSecrets. @@ -1562,10 +1541,6 @@ type ListWorkflowsParams struct { // Status Filter by status. Repeat to OR multiple statuses. Status *[]WorkflowStatus `form:"status,omitempty" json:"status,omitempty"` - // ChainId Chain ID filter. Omit to use the aggregator default chain. Repeat - // the parameter to filter by multiple chains. - ChainId *ChainIdQuery `form:"chainId,omitempty" json:"chainId,omitempty"` - // Before Cursor — return items immediately before this position (backward pagination). Before *PageBefore `form:"before,omitempty" json:"before,omitempty"` @@ -1592,10 +1567,6 @@ type ListExecutionsForWorkflowParams struct { type CountWorkflowsParams struct { SmartWalletAddress *[]EthereumAddress `form:"smartWalletAddress,omitempty" json:"smartWalletAddress,omitempty"` Status *[]WorkflowStatus `form:"status,omitempty" json:"status,omitempty"` - - // ChainId Chain ID filter. Omit to use the aggregator default chain. Repeat - // the parameter to filter by multiple chains. - ChainId *ChainIdQuery `form:"chainId,omitempty" json:"chainId,omitempty"` } // AuthExchangeJSONRequestBody defines body for AuthExchange for application/json ContentType. diff --git a/aggregator/rest/handlers_workflows.go b/aggregator/rest/handlers_workflows.go index e3d2b152..247d8570 100644 --- a/aggregator/rest/handlers_workflows.go +++ b/aggregator/rest/handlers_workflows.go @@ -43,21 +43,9 @@ func (s *Server) CreateWorkflow(ctx echo.Context) error { return badRequest("WORKFLOWS_BAD_PAYLOAD", "Invalid workflow payload", err.Error()) } - // Fall back to the JWT's audience chain when the body omits chainId. - // Studio 4.0.0-dev.1 builds the CreateWorkflowRequest body without a - // chainId field even though the surrounding deploy flow already knows - // the target chain — without this fallback the task lands with - // ChainId=0 and engine.ResolveSmartWalletConfig(0) returns chains[0] - // (Ethereum mainnet via the IsGateway fallback in config.go). The - // deployed-execution VM then dials the mainnet RPC, queries the - // Sepolia paymaster on mainnet, and the bundler returns - // "failed to get paymaster hash: no contract code at given address". - // Same pattern as WithdrawWallet / SimulateWorkflow / RunNode. - if req.ChainId == 0 { - if authed := restmw.UserFromContext(ctx); authed != nil && authed.ChainID != 0 { - req.ChainId = authed.ChainID - } - } + // A task no longer carries a workflow-level chain (G5) — each chain-aware + // trigger/node specifies its own chain_id. The JWT audience chain is API + // auth scope only; it is not stamped onto the task. workflow, err := s.engine.CreateWorkflow(user, req) if err != nil { diff --git a/aggregator/rest/mapping/node_test.go b/aggregator/rest/mapping/node_test.go index eab6d2d0..952c515d 100644 --- a/aggregator/rest/mapping/node_test.go +++ b/aggregator/rest/mapping/node_test.go @@ -111,6 +111,55 @@ func TestNodeRoundTrip(t *testing.T) { } } +// TestNodeRoundTrip_PerNodeChainId proves the persisted create() path decodes a +// per-node chain_id (int64) for every chain-aware node — OpenAPIToProtoNode is +// the same mapper CreateWorkflow runs, and it carries chainId via the protojson +// round-trip (jsonRetargetProto). This is the SDK's "create()-path int64" check, +// verifiable here without a deploy. +func TestNodeRoundTrip_PerNodeChainId(t *testing.T) { + const chainID = int64(11155111) + + t.Run("contractWrite", func(t *testing.T) { + typ := generated.ContractWrite + inner := generated.ContractWriteNode{Type: &typ, Config: &generated.ContractWriteNodeConfig{ + ContractAddress: generated.EthereumAddress("0x1234567890123456789012345678901234567890"), + ChainId: generated.ChainId(chainID), + }} + n := generated.Node{Id: "cw1", Type: generated.NodeTypeContractWrite} + require.NoError(t, n.FromContractWriteNode(inner)) + pn, err := OpenAPIToProtoNode(n) + require.NoError(t, err) + assert.Equal(t, chainID, pn.GetContractWrite().GetConfig().GetChainId()) + }) + + t.Run("contractRead", func(t *testing.T) { + typ := generated.ContractRead + inner := generated.ContractReadNode{Type: &typ, Config: &generated.ContractReadNodeConfig{ + ContractAddress: generated.EthereumAddress("0x1234567890123456789012345678901234567890"), + ChainId: generated.ChainId(chainID), + }} + n := generated.Node{Id: "cr1", Type: generated.NodeTypeContractRead} + require.NoError(t, n.FromContractReadNode(inner)) + pn, err := OpenAPIToProtoNode(n) + require.NoError(t, err) + assert.Equal(t, chainID, pn.GetContractRead().GetConfig().GetChainId()) + }) + + t.Run("ethTransfer", func(t *testing.T) { + typ := generated.EthTransfer + inner := generated.ETHTransferNode{Type: &typ, Config: &generated.ETHTransferNodeConfig{ + Destination: generated.EthereumAddress("0x1234567890123456789012345678901234567890"), + Amount: "1", + ChainId: generated.ChainId(chainID), + }} + n := generated.Node{Id: "et1", Type: generated.NodeTypeEthTransfer} + require.NoError(t, n.FromETHTransferNode(inner)) + pn, err := OpenAPIToProtoNode(n) + require.NoError(t, err) + assert.Equal(t, chainID, pn.GetEthTransfer().GetConfig().GetChainId()) + }) +} + func TestNodeRoundTrip_LoopWithCustomCodeRunner(t *testing.T) { innerTyp := generated.CustomCode runnerNode := generated.Node{Id: "inner", Type: generated.NodeTypeCustomCode} diff --git a/aggregator/rest/mapping/trigger.go b/aggregator/rest/mapping/trigger.go index 30d274b2..f37b2557 100644 --- a/aggregator/rest/mapping/trigger.go +++ b/aggregator/rest/mapping/trigger.go @@ -116,9 +116,8 @@ func openAPIBlockToProto(in generated.BlockTrigger) *avsproto.BlockTrigger { out := &avsproto.BlockTrigger{Config: &avsproto.BlockTrigger_Config{}} if in.Config != nil { out.Config.Interval = in.Config.Interval - if in.Config.ChainId != nil { - out.Config.ChainId = *in.Config.ChainId - } + // chainId is required on the BlockTrigger config (G5) — a plain value. + out.Config.ChainId = int64(in.Config.ChainId) } return out } @@ -127,9 +126,9 @@ func protoBlockToOpenAPI(in *avsproto.BlockTrigger) generated.BlockTrigger { t := generated.BlockTriggerTypeBlock out := generated.BlockTrigger{Type: &t} if cfg := in.GetConfig(); cfg != nil { - c := generated.BlockTriggerConfig{Interval: cfg.GetInterval()} - if cid := cfg.GetChainId(); cid != 0 { - c.ChainId = &cid + c := generated.BlockTriggerConfig{ + Interval: cfg.GetInterval(), + ChainId: generated.ChainId(cfg.GetChainId()), } out.Config = &c } @@ -191,6 +190,12 @@ func openAPIEventToProto(in generated.EventTrigger) *avsproto.EventTrigger { out := &avsproto.EventTrigger{Config: &avsproto.EventTrigger_Config{}} if in.Config != nil { out.Config.Queries = openAPIEventQueriesToProto(in.Config.Queries) + // chainId is required on the EventTrigger config (G5) — a plain value. + out.Config.ChainId = int64(in.Config.ChainId) + if in.Config.CooldownSeconds != nil { + cs := uint32(*in.Config.CooldownSeconds) + out.Config.CooldownSeconds = &cs + } } return out } @@ -199,7 +204,15 @@ func protoEventToOpenAPI(in *avsproto.EventTrigger) generated.EventTrigger { t := generated.Event out := generated.EventTrigger{Type: &t} if cfg := in.GetConfig(); cfg != nil { - out.Config = &generated.EventTriggerConfig{Queries: protoEventQueriesToOpenAPI(cfg.GetQueries())} + c := generated.EventTriggerConfig{ + Queries: protoEventQueriesToOpenAPI(cfg.GetQueries()), + ChainId: generated.ChainId(cfg.GetChainId()), + } + if cfg.CooldownSeconds != nil { + cs := int32(cfg.GetCooldownSeconds()) + c.CooldownSeconds = &cs + } + out.Config = &c } return out } diff --git a/aggregator/rest/mapping/trigger_test.go b/aggregator/rest/mapping/trigger_test.go index ebf07dea..e74fd538 100644 --- a/aggregator/rest/mapping/trigger_test.go +++ b/aggregator/rest/mapping/trigger_test.go @@ -24,7 +24,7 @@ func TestTriggerRoundTrip(t *testing.T) { tr := generated.Trigger{Name: "blockTrigger", Type: generated.TriggerTypeBlock} chainID := int64(11155111) typ := generated.BlockTriggerTypeBlock - inner := generated.BlockTrigger{Type: &typ, Config: &generated.BlockTriggerConfig{Interval: 10, ChainId: &chainID}} + inner := generated.BlockTrigger{Type: &typ, Config: &generated.BlockTriggerConfig{Interval: 10, ChainId: generated.ChainId(chainID)}} require.NoError(t, tr.FromBlockTrigger(inner)) return tr }, @@ -32,8 +32,7 @@ func TestTriggerRoundTrip(t *testing.T) { v, err := out.AsBlockTrigger() require.NoError(t, err) assert.Equal(t, int64(10), v.Config.Interval) - require.NotNil(t, v.Config.ChainId) - assert.Equal(t, int64(11155111), *v.Config.ChainId) + assert.Equal(t, int64(11155111), int64(v.Config.ChainId)) }, }, { @@ -87,10 +86,12 @@ func TestTriggerRoundTrip(t *testing.T) { tr := generated.Trigger{Name: "eventTrigger", Type: generated.TriggerTypeEvent} addr := generated.EthereumAddress("0x1234567890123456789012345678901234567890") sig := "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" + eventChainID := int64(8453) typ := generated.Event inner := generated.EventTrigger{ Type: &typ, Config: &generated.EventTriggerConfig{ + ChainId: generated.ChainId(eventChainID), Queries: []generated.EventTriggerQuery{{ Addresses: &[]generated.EthereumAddress{addr}, Topics: &[]string{sig, ""}, @@ -109,6 +110,7 @@ func TestTriggerRoundTrip(t *testing.T) { check: func(t *testing.T, out generated.Trigger) { v, err := out.AsEventTrigger() require.NoError(t, err) + assert.Equal(t, int64(8453), int64(v.Config.ChainId), "event trigger chain_id must survive the round-trip (G1)") require.Len(t, v.Config.Queries, 1) require.NotNil(t, v.Config.Queries[0].Addresses) assert.Equal(t, "0x1234567890123456789012345678901234567890", string((*v.Config.Queries[0].Addresses)[0])) diff --git a/aggregator/rest/mapping/workflow.go b/aggregator/rest/mapping/workflow.go index 81f00b0d..f979faf2 100644 --- a/aggregator/rest/mapping/workflow.go +++ b/aggregator/rest/mapping/workflow.go @@ -67,9 +67,8 @@ func OpenAPIToProtoCreateWorkflow(in generated.CreateWorkflowRequest) (*avsproto if in.MaxExecution != nil { out.MaxExecution = *in.MaxExecution } - if in.ChainId != nil { - out.ChainId = *in.ChainId - } + // chain_id is no longer a task-level field (G5); each chain-aware + // trigger/node carries its own. A request-level chainId is ignored here. // Note: in.SmartWalletAddress still flows through // inputVariables.settings.runner per the existing engine contract. // in.Name is mirrored into settings.name above. @@ -138,9 +137,7 @@ func ProtoToOpenAPIWorkflow(in *avsproto.Task) (generated.Workflow, error) { if v := in.GetExecutionCount(); v != 0 { out.ExecutionCount = &v } - if v := in.GetChainId(); v != 0 { - out.ChainId = &v - } + // chain_id removed from Task (G5) — no workflow-level chain to surface. trig, err := ProtoToOpenAPITrigger(in.GetTrigger()) if err != nil { diff --git a/api/openapi.yaml b/api/openapi.yaml index 88c51a2b..5148601d 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -118,9 +118,9 @@ components: type: integer format: int64 description: | - Numeric chain ID. `0` or omitted means "use the aggregator's default - chain" — typically only useful in single-chain deployments or for - chain-agnostic operations. + Numeric chain ID (e.g. 11155111 for Sepolia, 8453 for Base). On + chain-aware trigger/node configs this is required and must be a + configured chain; on query/filter params it is optional. example: 11155111 EthereumAddress: @@ -392,7 +392,7 @@ components: BlockTriggerConfig: type: object description: Fires every N blocks on the target chain. - required: [interval] + required: [interval, chainId] properties: interval: type: integer @@ -401,7 +401,7 @@ components: description: Fire every N blocks. chainId: $ref: '#/components/schemas/ChainId' - description: Chain to watch blocks on. 0 = inherit workflow chainId. + description: Chain to watch blocks on. Required — a workflow carries no chain to inherit. EventTriggerQuery: type: object @@ -476,7 +476,7 @@ components: EventTriggerConfig: type: object description: Fires when matching on-chain events are observed. - required: [queries] + required: [queries, chainId] properties: queries: type: array @@ -491,7 +491,7 @@ components: trigger again. Default 300. 0 disables cooldown. chainId: $ref: '#/components/schemas/ChainId' - description: Chain to watch events on. 0 = inherit workflow chainId. + description: Chain to watch events on. Required — a workflow carries no chain to inherit. # ---- Trigger union ---- @@ -583,7 +583,7 @@ components: ETHTransferNodeConfig: type: object - required: [destination, amount] + required: [destination, amount, chainId] properties: destination: { $ref: '#/components/schemas/EthereumAddress' } amount: @@ -591,11 +591,11 @@ components: description: Amount in wei (decimal string for big-int safety). Special value `max` withdraws the entire balance. chainId: $ref: '#/components/schemas/ChainId' - description: Chain to execute on. 0 = inherit workflow chainId. + description: Chain to execute on. Required — a workflow carries no chain to inherit. ContractWriteNodeConfig: type: object - required: [contractAddress] + required: [contractAddress, chainId] properties: contractAddress: { $ref: '#/components/schemas/EthereumAddress' } callData: { $ref: '#/components/schemas/Hex' } @@ -617,11 +617,11 @@ components: description: Custom gas limit (decimal string). chainId: $ref: '#/components/schemas/ChainId' - description: Chain to execute on. 0 = inherit workflow chainId. + description: Chain to execute on. Required — a workflow carries no chain to inherit. ContractReadNodeConfig: type: object - required: [contractAddress] + required: [contractAddress, chainId] properties: contractAddress: { $ref: '#/components/schemas/EthereumAddress' } contractAbi: @@ -633,7 +633,7 @@ components: items: { $ref: '#/components/schemas/MethodCall' } chainId: $ref: '#/components/schemas/ChainId' - description: Chain to read from. 0 = inherit workflow chainId. + description: Chain to read from. Required — a workflow carries no chain to inherit. GraphQLQueryNodeConfig: type: object @@ -894,9 +894,6 @@ components: name: { type: string } owner: { $ref: '#/components/schemas/EthereumAddress' } smartWalletAddress: { $ref: '#/components/schemas/EthereumAddress' } - chainId: - $ref: '#/components/schemas/ChainId' - description: Workflow-level default chain. Triggers and nodes may override. trigger: { $ref: '#/components/schemas/Trigger' } nodes: type: array @@ -939,9 +936,6 @@ components: properties: name: { type: string } smartWalletAddress: { $ref: '#/components/schemas/EthereumAddress' } - chainId: - $ref: '#/components/schemas/ChainId' - description: Default chain. Triggers and nodes may override. trigger: { $ref: '#/components/schemas/Trigger' } nodes: type: array @@ -1702,7 +1696,6 @@ paths: schema: type: array items: { $ref: '#/components/schemas/WorkflowStatus' } - - $ref: '#/components/parameters/ChainIdQuery' - $ref: '#/components/parameters/PageBefore' - $ref: '#/components/parameters/PageAfter' - $ref: '#/components/parameters/PageLimit' @@ -1900,7 +1893,6 @@ paths: schema: type: array items: { $ref: '#/components/schemas/WorkflowStatus' } - - $ref: '#/components/parameters/ChainIdQuery' responses: '200': description: Workflow count for the filter set. @@ -1925,7 +1917,6 @@ paths: schema: type: array items: { $ref: '#/components/schemas/Ulid' } - - $ref: '#/components/parameters/ChainIdQuery' - $ref: '#/components/parameters/PageBefore' - $ref: '#/components/parameters/PageAfter' - $ref: '#/components/parameters/PageLimit' @@ -2063,7 +2054,6 @@ paths: schema: type: array items: { $ref: '#/components/schemas/Ulid' } - - $ref: '#/components/parameters/ChainIdQuery' responses: '200': description: Execution count. @@ -2083,7 +2073,6 @@ paths: schema: type: array items: { $ref: '#/components/schemas/Ulid' } - - $ref: '#/components/parameters/ChainIdQuery' responses: '200': description: Execution stats. diff --git a/core/taskengine/branch_email_summary_test.go b/core/taskengine/branch_email_summary_test.go deleted file mode 100644 index dd39c8fb..00000000 --- a/core/taskengine/branch_email_summary_test.go +++ /dev/null @@ -1,303 +0,0 @@ -package taskengine - -import ( - "testing" - - "github.com/AvaProtocol/EigenLayer-AVS/core/testutil" - "github.com/AvaProtocol/EigenLayer-AVS/model" - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "github.com/stretchr/testify/assert" -) - -// TestBranchNode_EmailSummaryGeneration tests that branch evaluation details -// are properly stored in metadata and used to generate improved email HTML -func TestBranchNode_EmailSummaryGeneration(t *testing.T) { - // Create VM with a branch node - vm, err := NewVMWithData(&model.Workflow{ - Task: &avsproto.Task{ - Id: "test-task", - Trigger: &avsproto.TaskTrigger{ - Id: "test-trigger", - Name: "test", - TriggerType: &avsproto.TaskTrigger_Manual{ - Manual: &avsproto.ManualTrigger{ - Config: &avsproto.ManualTrigger_Config{}, - }, - }, - }, - }, - }, nil, testutil.GetTestSmartWalletConfig(), nil) - - assert.NoError(t, err, "Failed to create VM") - - // Set up the real scenario: balance is 0, so If condition should fail - balance1Data := []interface{}{ - map[string]interface{}{ - "balance": "0", - "balanceFormatted": "0", - "decimals": 18, - "name": "Test Token", - "symbol": "TEST", - "tokenAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - }, - } - - settings := map[string]interface{}{ - "name": "Test Workflow", - "amount": "1", - "uniswapv3_pool": map[string]interface{}{ - "token1": map[string]interface{}{ - "id": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - }, - }, - } - - // Create branch TaskNode - branchTaskNode := &avsproto.TaskNode{ - Id: "branch1", - Name: "branch1", - Type: avsproto.NodeType_NODE_TYPE_BRANCH, - TaskType: &avsproto.TaskNode_Branch{ - Branch: &avsproto.BranchNode{ - Config: &avsproto.BranchNode_Config{ - Conditions: []*avsproto.BranchNode_Condition{ - { - Id: "0", - Type: "if", - Expression: "{{balance1.data.find(token => token?.tokenAddress?.toLowerCase() === settings.uniswapv3_pool.token1.id.toLowerCase()).balance > Number(settings.amount)}}", - }, - { - Id: "1", - Type: "else", - Expression: "", - }, - }, - }, - }, - }, - } - - // Execute using RunNodeWithInputs - step, err := vm.RunNodeWithInputs(branchTaskNode, map[string]interface{}{ - "balance1": map[string]interface{}{"data": balance1Data}, - "settings": settings, - }) - assert.NoError(t, err, "Branch execution should not error") - assert.True(t, step.Success, "Branch should succeed") - - // Add step to outer VM for summary generation - vm.ExecutionLogs = append(vm.ExecutionLogs, step) - - // Verify metadata contains evaluation details - assert.NotNil(t, step.Metadata, "Metadata should be set") - metaMap, ok := step.Metadata.AsInterface().(map[string]interface{}) - assert.True(t, ok, "Metadata should be a map") - - evals, ok := metaMap["conditionEvaluations"].([]interface{}) - assert.True(t, ok, "conditionEvaluations should exist in metadata") - assert.Equal(t, 2, len(evals), "Should have 2 condition evaluations") - - // Check first evaluation (If: false) - eval0, ok := evals[0].(map[string]interface{}) - assert.True(t, ok, "First evaluation should be a map") - assert.Equal(t, "If", eval0["label"], "First condition should be labeled 'If'") - assert.Equal(t, false, eval0["result"], "First condition should evaluate to false") - assert.Equal(t, false, eval0["taken"], "First condition should not be taken") - assert.Contains(t, eval0["expression"], "balance1.data.find", "Expression should be the original template") - - // Verify comparison operands are extracted (not empty object) - varVals, hasVarVals := eval0["variableValues"].(map[string]interface{}) - assert.True(t, hasVarVals, "variableValues should exist") - assert.NotNil(t, varVals, "variableValues should not be nil") - - // Check if it has comparison operand structure - if len(varVals) > 0 { - t.Logf("variableValues content: %+v", varVals) - // Should have leftExpr, rightExpr, operator, left, right keys - if leftExpr, ok := varVals["leftExpr"].(string); ok { - assert.NotEmpty(t, leftExpr, "leftExpr should not be empty") - assert.Contains(t, leftExpr, "balance1.data.find", "leftExpr should contain the left side of comparison") - t.Logf("leftExpr: %s", leftExpr) - } - if rightExpr, ok := varVals["rightExpr"].(string); ok { - assert.NotEmpty(t, rightExpr, "rightExpr should not be empty") - assert.Contains(t, rightExpr, "settings.amount", "rightExpr should contain the right side of comparison") - t.Logf("rightExpr: %s", rightExpr) - } - if operator, ok := varVals["operator"].(string); ok { - assert.Equal(t, ">", operator, "operator should be '>'") - t.Logf("operator: %s", operator) - } - // Check evaluated values - if left := varVals["left"]; left != nil { - t.Logf("left operand value: %v", left) - } - if right := varVals["right"]; right != nil { - t.Logf("right operand value: %v", right) - } - } else { - t.Error("variableValues is empty - comparison operands were not extracted!") - } - - // Check second evaluation (Else: taken) - eval1, ok := evals[1].(map[string]interface{}) - assert.True(t, ok, "Second evaluation should be a map") - assert.Equal(t, "Else", eval1["label"], "Second condition should be labeled 'Else'") - assert.Equal(t, true, eval1["result"], "Else condition should result in true") - assert.Equal(t, true, eval1["taken"], "Else condition should be taken") - - // Generate email summary HTML - text, html := BuildBranchAndSkippedSummary(vm) - - t.Logf("Generated text summary:\n%s", text) - t.Logf("Generated HTML summary:\n%s", html) - - // NOTE: For successful workflow runs, we don't include detailed branch condition explanations - // in the email summary. Branch conditions are only shown in detail when debugging failures. - // For success cases, the summary just shows "All nodes were executed successfully". - // The branch metadata is still captured for debugging but not displayed in success summaries. - - // Verify the summary shows successful execution - assert.Contains(t, text, "All nodes were executed successfully", "Text should show successful execution") - assert.Contains(t, html, "All nodes were executed successfully", "HTML should show successful execution") -} - -// TestBranchNode_TrueConditionLogging tests that true conditions also log comparison operands -func TestBranchNode_TrueConditionLogging(t *testing.T) { - // Create VM with a branch node - vm, err := NewVMWithData(&model.Workflow{ - Task: &avsproto.Task{ - Id: "test-task", - Trigger: &avsproto.TaskTrigger{ - Id: "test-trigger", - Name: "test", - TriggerType: &avsproto.TaskTrigger_Manual{ - Manual: &avsproto.ManualTrigger{ - Config: &avsproto.ManualTrigger_Config{}, - }, - }, - }, - }, - }, nil, testutil.GetTestSmartWalletConfig(), nil) - - assert.NoError(t, err, "Failed to create VM") - - // Set up scenario where If condition is TRUE (balance > amount) - balance1Data := []interface{}{ - map[string]interface{}{ - "balance": "20000000", // 20M - greater than 10000 - "balanceFormatted": "20000000", - "decimals": 18, - "name": "Test Token", - "symbol": "TEST", - "tokenAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - }, - } - - settings := map[string]interface{}{ - "name": "Test Workflow", - "amount": "10000", - "uniswapv3_pool": map[string]interface{}{ - "token1": map[string]interface{}{ - "id": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - }, - }, - } - - // Create branch TaskNode - branchTaskNode := &avsproto.TaskNode{ - Id: "branch1", - Name: "branch1", - Type: avsproto.NodeType_NODE_TYPE_BRANCH, - TaskType: &avsproto.TaskNode_Branch{ - Branch: &avsproto.BranchNode{ - Config: &avsproto.BranchNode_Config{ - Conditions: []*avsproto.BranchNode_Condition{ - { - Id: "0", - Type: "if", - Expression: "{{balance1.data.find(token => token?.tokenAddress?.toLowerCase() === settings.uniswapv3_pool.token1.id.toLowerCase()).balance > Number(settings.amount)}}", - }, - { - Id: "1", - Type: "else", - Expression: "", - }, - }, - }, - }, - }, - } - - // Execute using RunNodeWithInputs - step, err := vm.RunNodeWithInputs(branchTaskNode, map[string]interface{}{ - "balance1": map[string]interface{}{"data": balance1Data}, - "settings": settings, - }) - assert.NoError(t, err, "Branch execution should not error") - assert.True(t, step.Success, "Branch should succeed") - - // Add step to outer VM for summary generation - vm.ExecutionLogs = append(vm.ExecutionLogs, step) - - // Verify metadata contains evaluation details with operand values - assert.NotNil(t, step.Metadata, "Metadata should be set") - metaMap, ok := step.Metadata.AsInterface().(map[string]interface{}) - assert.True(t, ok, "Metadata should be a map") - - evals, ok := metaMap["conditionEvaluations"].([]interface{}) - assert.True(t, ok, "conditionEvaluations should exist in metadata") - assert.Equal(t, 1, len(evals), "Should have 1 condition evaluation (only If, not Else)") - - // Check the If evaluation (true and taken) - eval0, ok := evals[0].(map[string]interface{}) - assert.True(t, ok, "Evaluation should be a map") - assert.Equal(t, "If", eval0["label"], "Should be labeled 'If'") - assert.Equal(t, true, eval0["result"], "If condition should evaluate to true") - assert.Equal(t, true, eval0["taken"], "If condition should be taken") - - // Verify comparison operands are extracted for true conditions too - varVals, hasVarVals := eval0["variableValues"].(map[string]interface{}) - assert.True(t, hasVarVals, "variableValues should exist for true conditions") - assert.NotNil(t, varVals, "variableValues should not be nil") - assert.NotEmpty(t, varVals, "variableValues should not be empty for true conditions") - - if len(varVals) > 0 { - t.Logf("variableValues for TRUE condition: %+v", varVals) - leftExpr, _ := varVals["leftExpr"].(string) - rightExpr, _ := varVals["rightExpr"].(string) - operator, _ := varVals["operator"].(string) - - assert.Contains(t, leftExpr, "balance1.data.find", "leftExpr should contain balance1 expression") - assert.Contains(t, rightExpr, "settings.amount", "rightExpr should contain settings.amount") - assert.Equal(t, ">", operator, "operator should be '>'") - - t.Logf("True condition operands - left: %v, right: %v", varVals["left"], varVals["right"]) - } - - // Verify execution log shows comparison details for TRUE conditions - log := step.Log - t.Logf("Execution log:\n%s", log) - - assert.Contains(t, log, "If condition resolved to true", "Log should show 'If condition resolved to true'") - assert.Contains(t, log, "Expression:", "Log should show 'Expression:' prefix for consistency") - assert.Contains(t, log, "balance1.data.find", "Log should show the left operand expression") - assert.Contains(t, log, "Number(settings.amount)", "Log should show the right operand expression") - assert.Contains(t, log, "Evaluated:", "Log should show 'Evaluated:' with operand values") - assert.Contains(t, log, ">", "Log should show the comparison operator") - - // Should show evaluated values, not entire variable dumps - assert.NotContains(t, log, `"balance":"20000000"`, "Log should NOT dump the entire balance1 object") - assert.NotContains(t, log, `"name":"Test Workflow"`, "Log should NOT dump the entire settings object") - - // Verify HTML email summary for successful workflows - _, htmlSummary := BuildBranchAndSkippedSummary(vm) - t.Logf("HTML summary:\n%s", htmlSummary) - - // NOTE: For successful workflow runs, we don't include detailed branch condition explanations - // in the email summary. Branch conditions are only shown in detail when debugging failures. - // For success cases, the summary just shows "All nodes were executed successfully". - - // Verify the summary shows successful execution - assert.Contains(t, htmlSummary, "All nodes were executed successfully", "HTML should show successful execution") -} diff --git a/core/taskengine/chain_per_part_test.go b/core/taskengine/chain_per_part_test.go new file mode 100644 index 00000000..9c906bce --- /dev/null +++ b/core/taskengine/chain_per_part_test.go @@ -0,0 +1,195 @@ +package taskengine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/AvaProtocol/EigenLayer-AVS/core/config" + "github.com/AvaProtocol/EigenLayer-AVS/model" + avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" +) + +// TestExtractNodeConfiguration_CarriesChainId locks G3: the per-node chain must +// survive ExtractNodeConfiguration (the map-based config used by +// runNodeImmediately / simulation / loop-nested) and be readable back by +// CreateNodeFromType. Before the fix these paths dropped chainId and ran on the +// wrong chain. +func TestExtractNodeConfiguration_CarriesChainId(t *testing.T) { + const chainID = int64(8453) + + cases := []struct { + name string + node *avsproto.TaskNode + read func(*avsproto.TaskNode) int64 + }{ + { + name: "contractWrite", + node: &avsproto.TaskNode{ + Id: "cw1", Name: "cw", Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE, + TaskType: &avsproto.TaskNode_ContractWrite{ContractWrite: &avsproto.ContractWriteNode{ + Config: &avsproto.ContractWriteNode_Config{ + ContractAddress: "0x1234567890123456789012345678901234567890", + ChainId: chainID, + }, + }}, + }, + read: func(n *avsproto.TaskNode) int64 { return n.GetContractWrite().GetConfig().GetChainId() }, + }, + { + name: "contractRead", + node: &avsproto.TaskNode{ + Id: "cr1", Name: "cr", Type: avsproto.NodeType_NODE_TYPE_CONTRACT_READ, + TaskType: &avsproto.TaskNode_ContractRead{ContractRead: &avsproto.ContractReadNode{ + Config: &avsproto.ContractReadNode_Config{ + ContractAddress: "0x1234567890123456789012345678901234567890", + ChainId: chainID, + }, + }}, + }, + read: func(n *avsproto.TaskNode) int64 { return n.GetContractRead().GetConfig().GetChainId() }, + }, + { + name: "ethTransfer", + node: &avsproto.TaskNode{ + Id: "et1", Name: "et", Type: avsproto.NodeType_NODE_TYPE_ETH_TRANSFER, + TaskType: &avsproto.TaskNode_EthTransfer{EthTransfer: &avsproto.ETHTransferNode{ + Config: &avsproto.ETHTransferNode_Config{ + Destination: "0x1234567890123456789012345678901234567890", + Amount: "1", + ChainId: chainID, + }, + }}, + }, + read: func(n *avsproto.TaskNode) int64 { return n.GetEthTransfer().GetConfig().GetChainId() }, + }, + } + + nodeTypeString := map[string]string{ + "contractWrite": "contractWrite", + "contractRead": "contractRead", + "ethTransfer": "ethTransfer", + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := ExtractNodeConfiguration(tc.node) + require.NotNil(t, cfg) + assert.EqualValues(t, chainID, cfg["chainId"], "ExtractNodeConfiguration must emit chainId") + + rebuilt, err := CreateNodeFromType(nodeTypeString[tc.name], cfg, tc.node.Id) + require.NoError(t, err) + assert.Equal(t, chainID, tc.read(rebuilt), "CreateNodeFromType must read chainId back onto the proto") + }) + } +} + +// TestValidateExplicitPartChains locks G4: in gateway mode, a chain-aware part +// naming an explicit chain the aggregator isn't configured for is rejected at +// create; chain_id == 0 (inherit) and configured chains are accepted. +func TestValidateExplicitPartChains(t *testing.T) { + // Engine configured (gateway) for chains 1 and 8453 only. + n := &Engine{ + config: &config.Config{IsGateway: true}, + chainConfigs: map[int64]*config.ChainConfig{1: {ChainID: 1}, 8453: {ChainID: 8453}}, + } + + cwNode := func(chainID int64) *avsproto.TaskNode { + return &avsproto.TaskNode{ + Id: "cw", Name: "cw", Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE, + TaskType: &avsproto.TaskNode_ContractWrite{ContractWrite: &avsproto.ContractWriteNode{ + Config: &avsproto.ContractWriteNode_Config{ChainId: chainID}, + }}, + } + } + + t.Run("configured node chain passes", func(t *testing.T) { + task := &model.Workflow{Task: &avsproto.Task{Nodes: []*avsproto.TaskNode{cwNode(8453)}}} + require.NoError(t, n.validateExplicitPartChains(task)) + }) + + t.Run("chain_id 0 is rejected (explicit chain required)", func(t *testing.T) { + task := &model.Workflow{Task: &avsproto.Task{Nodes: []*avsproto.TaskNode{cwNode(0)}}} + err := n.validateExplicitPartChains(task) + require.Error(t, err) + assert.Contains(t, err.Error(), "explicit chain_id") + }) + + t.Run("unconfigured explicit node chain is rejected", func(t *testing.T) { + task := &model.Workflow{Task: &avsproto.Task{Nodes: []*avsproto.TaskNode{cwNode(137)}}} + err := n.validateExplicitPartChains(task) + require.Error(t, err) + assert.Contains(t, err.Error(), "137") + }) + + t.Run("unconfigured event trigger chain is rejected", func(t *testing.T) { + task := &model.Workflow{Task: &avsproto.Task{Trigger: &avsproto.TaskTrigger{ + TriggerType: &avsproto.TaskTrigger_Event{Event: &avsproto.EventTrigger{ + Config: &avsproto.EventTrigger_Config{ChainId: 999999}, + }}, + }}} + err := n.validateExplicitPartChains(task) + require.Error(t, err) + assert.Contains(t, err.Error(), "event trigger") + }) + + t.Run("unconfigured block trigger chain is rejected", func(t *testing.T) { + task := &model.Workflow{Task: &avsproto.Task{Trigger: &avsproto.TaskTrigger{ + TriggerType: &avsproto.TaskTrigger_Block{Block: &avsproto.BlockTrigger{ + Config: &avsproto.BlockTrigger_Config{ChainId: 999999, Interval: 1}, + }}, + }}} + err := n.validateExplicitPartChains(task) + require.Error(t, err) + assert.Contains(t, err.Error(), "block trigger") + }) + + t.Run("configured block trigger chain passes", func(t *testing.T) { + task := &model.Workflow{Task: &avsproto.Task{Trigger: &avsproto.TaskTrigger{ + TriggerType: &avsproto.TaskTrigger_Block{Block: &avsproto.BlockTrigger{ + Config: &avsproto.BlockTrigger_Config{ChainId: 1, Interval: 1}, + }}, + }}} + require.NoError(t, n.validateExplicitPartChains(task)) + }) + + t.Run("non-gateway mode skips validation", func(t *testing.T) { + single := &Engine{config: &config.Config{IsGateway: false}} + task := &model.Workflow{Task: &avsproto.Task{Nodes: []*avsproto.TaskNode{cwNode(137)}}} + require.NoError(t, single.validateExplicitPartChains(task)) + }) +} + +// TestTriggerMonitoringChainID locks G2: the operator monitors a chain-watching +// trigger on the trigger's OWN chain, falling back to the task chain only when +// the trigger leaves its chain at 0 (legacy). Non-chain triggers carry the +// fallback through. +func TestTriggerMonitoringChainID(t *testing.T) { + const taskChain = int64(11155111) + + eventTrigger := func(chainID int64) *avsproto.TaskTrigger { + return &avsproto.TaskTrigger{TriggerType: &avsproto.TaskTrigger_Event{ + Event: &avsproto.EventTrigger{Config: &avsproto.EventTrigger_Config{ChainId: chainID}}, + }} + } + blockTrigger := func(chainID int64) *avsproto.TaskTrigger { + return &avsproto.TaskTrigger{TriggerType: &avsproto.TaskTrigger_Block{ + Block: &avsproto.BlockTrigger{Config: &avsproto.BlockTrigger_Config{ChainId: chainID, Interval: 1}}, + }} + } + cronTrigger := &avsproto.TaskTrigger{TriggerType: &avsproto.TaskTrigger_Cron{ + Cron: &avsproto.CronTrigger{Config: &avsproto.CronTrigger_Config{Schedules: []string{"* * * * *"}}}, + }} + + assert.Equal(t, int64(8453), triggerMonitoringChainID(eventTrigger(8453), taskChain), + "event trigger uses its own chain, not the task chain") + assert.Equal(t, taskChain, triggerMonitoringChainID(eventTrigger(0), taskChain), + "event trigger chain 0 falls back to the task chain (legacy)") + assert.Equal(t, int64(8453), triggerMonitoringChainID(blockTrigger(8453), taskChain), + "block trigger uses its own chain") + assert.Equal(t, taskChain, triggerMonitoringChainID(cronTrigger, taskChain), + "cron trigger carries the fallback (chain-agnostic)") + assert.Equal(t, taskChain, triggerMonitoringChainID(nil, taskChain), + "nil trigger carries the fallback") +} diff --git a/core/taskengine/engine.go b/core/taskengine/engine.go index 399f6b25..15246ea4 100644 --- a/core/taskengine/engine.go +++ b/core/taskengine/engine.go @@ -332,10 +332,6 @@ func New(db storage.Storage, config *config.Config, queue *apqueue.Queue, logger logger: logger, } - // Wire global fee rates so Summary.Fees population (in both ComposeSummary - // and the context-memory summarizer) uses the aggregator's configured rates. - SetFeeRates(config.FeeRates) - // Initialize AI summarizer (global) from aggregator config // Only context-memory API is supported - all email content generation is delegated to context-memory // The aggregator acts as a pass-through for the context-memory response to SendGrid @@ -505,70 +501,127 @@ func (n *Engine) knownChainIDs() []int64 { // the aggregator default chain so writers never produce zero-prefixed // (and therefore never-readable) keys. func (n *Engine) chainScopedTaskKey(task *model.Workflow) []byte { - chainID := task.ChainId - if chainID == 0 { - chainID = n.defaultChainID() + return WorkflowStorageKey(task.Id, task.Status) +} + +// isChainConfigured reports whether the aggregator serves chainID, using the +// canonical set knownChainIDs() (default chain + chainConfigs). ResolveSmart- +// WalletConfig is NOT a substitute — it falls back to the default config for +// an unknown chain, so it can't tell "configured" from "unconfigured". +func (n *Engine) isChainConfigured(chainID int64) bool { + for _, id := range n.knownChainIDs() { + if id == chainID { + return true + } } - return ChainWorkflowStorageKey(chainID, task.Id, task.Status) + return false } -// findTaskKey searches every known chain bucket for a task by id at the -// given status, returning the matching chain-scoped key. Used by lookup -// paths that have only a task_id (no chain context). Returns the -// default-chain key when the task is not found on any chain so callers -// get a deterministic key to attempt the read with. -func (n *Engine) findTaskKey(taskID string, status avsproto.TaskStatus) []byte { - for _, chainID := range n.knownChainIDs() { - key := ChainWorkflowStorageKey(chainID, taskID, status) - if exists, err := n.db.Exist(key); err == nil && exists { - return key +// validateExplicitPartChains rejects, at create time, a task whose chain-aware +// trigger or nodes either omit chain_id (<= 0) or name a chain the aggregator +// isn't configured for. Post-G5 a task carries no chain, so every chain-aware part must name an +// explicit, configured chain; chain_id 0 or an unconfigured chain is rejected. Only enforced in gateway +// mode, where chainConfigs enumerates the served chains; single-chain mode has +// one chain and nothing to validate against. +func (n *Engine) validateExplicitPartChains(task *model.Workflow) error { + if n.config == nil || !n.config.IsGateway || task == nil { + return nil + } + check := func(kind string, chainID int64) error { + // A task carries no chain, so every chain-aware part must name an + // explicit, configured chain — chain_id 0 is rejected at create. + if chainID <= 0 { + return status.Errorf(codes.InvalidArgument, + "%s requires an explicit chain_id (a task no longer provides a default chain)", kind) + } + if n.isChainConfigured(chainID) { + return nil } + return status.Errorf(codes.InvalidArgument, + "%s targets chain_id=%d, which is not configured on this aggregator", kind, chainID) } - // Not found — return a key for the default chain so the subsequent - // read produces a clean badger.ErrKeyNotFound the caller already handles. - return ChainWorkflowStorageKey(n.defaultChainID(), taskID, status) + if t := task.Trigger; t != nil { + if et := t.GetEvent(); et != nil && et.Config != nil { + if err := check("event trigger", et.Config.GetChainId()); err != nil { + return err + } + } + if bt := t.GetBlock(); bt != nil && bt.Config != nil { + if err := check("block trigger", bt.Config.GetChainId()); err != nil { + return err + } + } + } + for _, node := range task.Nodes { + if err := checkNodeChain(node, check); err != nil { + return err + } + } + return nil } -// taskExecutionPrefixesBytes returns the execution-history prefix for taskID -// across every known chain bucket. Used by aggregation paths (ListExecutions, -// counts) that must scan all chains because they have only a task ID. -func (n *Engine) taskExecutionPrefixesBytes(taskID string) [][]byte { - out := make([][]byte, 0, len(n.knownChainIDs())) - for _, chainID := range n.knownChainIDs() { - out = append(out, ChainTaskExecutionPrefix(chainID, taskID)) +// checkNodeChain runs check against a node's chain-aware config, looking inside +// a Loop runner too (it wraps one chain-aware node inline). +func checkNodeChain(node *avsproto.TaskNode, check func(string, int64) error) error { + if node == nil { + return nil } - return out + if cw := node.GetContractWrite(); cw != nil && cw.Config != nil { + return check("contract write node", cw.Config.GetChainId()) + } + if cr := node.GetContractRead(); cr != nil && cr.Config != nil { + return check("contract read node", cr.Config.GetChainId()) + } + if et := node.GetEthTransfer(); et != nil && et.Config != nil { + return check("eth transfer node", et.Config.GetChainId()) + } + if loop := node.GetLoop(); loop != nil { + if cw := loop.GetContractWrite(); cw != nil && cw.Config != nil { + return check("loop contract write runner", cw.Config.GetChainId()) + } + if cr := loop.GetContractRead(); cr != nil && cr.Config != nil { + return check("loop contract read runner", cr.Config.GetChainId()) + } + if et := loop.GetEthTransfer(); et != nil && et.Config != nil { + return check("loop eth transfer runner", et.Config.GetChainId()) + } + } + // NOTE: chain-aware nodes nested inside a Branch node's conditional paths are + // not validated here — they fall through to the strict resolveSmartWalletForNode + // check at execution time instead of being rejected at create time. If Branch + // gains nested chain-aware runners, add a case here (mirroring Loop above). + return nil +} + +// findTaskKey returns the chain-agnostic storage key for a task by id at the +// given status (G5: storage is no longer chain-bucketed, so the key is direct). +func (n *Engine) findTaskKey(taskID string, status avsproto.TaskStatus) []byte { + return WorkflowStorageKey(taskID, status) +} + +// taskExecutionPrefixesBytes returns the chain-agnostic execution-history +// prefix for taskID (G5: one bucket, so a single prefix). +func (n *Engine) taskExecutionPrefixesBytes(taskID string) [][]byte { + return [][]byte{TaskExecutionPrefix(taskID)} } // taskExecutionPrefixes is the string form for ListKeysMulti. func (n *Engine) taskExecutionPrefixes(taskID string) []string { - out := make([]string, 0, len(n.knownChainIDs())) - for _, chainID := range n.knownChainIDs() { - out = append(out, string(ChainTaskExecutionPrefix(chainID, taskID))) - } - return out + return []string{string(TaskExecutionPrefix(taskID))} } -// chainUserPrefixesBytes returns per-chain "u:{chainID}:{owner}" prefixes -// (matches every task this owner has on every chain). +// chainUserPrefixesBytes returns the chain-agnostic "u:{owner}" prefix +// (matches every task this owner has). func (n *Engine) chainUserPrefixesBytes(owner common.Address) [][]byte { - out := make([][]byte, 0, len(n.knownChainIDs())) - for _, chainID := range n.knownChainIDs() { - out = append(out, []byte(fmt.Sprintf("u:%d:%s", chainID, strings.ToLower(owner.Hex())))) - } - return out + return [][]byte{[]byte(fmt.Sprintf("u:%s", strings.ToLower(owner.Hex())))} } -// chainSmartWalletPrefixesBytes returns per-chain -// "u:{chainID}:{owner}:{wallet}" prefixes (matches every task this owner has -// on the given smart wallet on every chain). +// chainSmartWalletPrefixesBytes returns the chain-agnostic +// "u:{owner}:{wallet}" prefix (matches every task this owner has on the given +// smart wallet). func (n *Engine) chainSmartWalletPrefixesBytes(owner common.Address, smartWallet common.Address) [][]byte { - out := make([][]byte, 0, len(n.knownChainIDs())) - for _, chainID := range n.knownChainIDs() { - out = append(out, []byte(fmt.Sprintf("u:%d:%s:%s", - chainID, strings.ToLower(owner.Hex()), strings.ToLower(smartWallet.Hex())))) - } - return out + return [][]byte{[]byte(fmt.Sprintf("u:%s:%s", + strings.ToLower(owner.Hex()), strings.ToLower(smartWallet.Hex())))} } // GetTenderlyClient returns the shared Tenderly client for fee estimation and simulation @@ -578,10 +631,6 @@ func (n *Engine) GetTenderlyClient() *TenderlyClient { func (n *Engine) SetPriceService(priceService PriceService) { n.priceService = priceService - // Also wire as the package-level price service so Summary.Fees population - // (in both ComposeSummary and ContextMemorySummarizer.Summarize) can compute - // native-token totals from USD platform fees and value-fee legs. - SetPriceService(priceService) } func (n *Engine) Stop() { @@ -640,6 +689,26 @@ func chainNeedsOperatorMonitoring(tt avsproto.TriggerType) bool { } } +// triggerMonitoringChainID returns the chain an operator must subscribe to in +// order to watch this task's trigger fire (G2). For chain-watching triggers +// (event/block) it is the trigger's OWN configured chain — so a workflow can +// watch chain X while its nodes act on chain Y. Post-G5 there is no task-level +// chain: every caller passes fallbackChainID=0, and strict create-time validation +// already rejects a chain-watching trigger with chain_id<=0, so a 0 trigger chain +// here only occurs for non-chain triggers (cron/fixedtime/manual), which carry the +// fallback through unchanged — the operator's TimeTrigger is chain-agnostic and +// ignores it. Proto getters are nil-safe, so the GetEvent()/GetBlock() chain reads +// are safe for any trigger type. +func triggerMonitoringChainID(trigger *avsproto.TaskTrigger, fallbackChainID int64) int64 { + if cid := trigger.GetEvent().GetConfig().GetChainId(); cid != 0 { + return cid + } + if cid := trigger.GetBlock().GetConfig().GetChainId(); cid != 0 { + return cid + } + return fallbackChainID +} + // operatorsCoveringChain returns the addresses of currently-connected // operators that advertise the given chain_id. Empty // SupportedChainIDs is "covers everything" ONLY when @@ -752,10 +821,13 @@ func (n *Engine) collectOrphans() []orphanedTaskInfo { if hasLegacyOperator { continue } - if !chainHasOperator[task.ChainId] { + // Coverage is judged on the trigger's monitoring chain (G2), which is + // where the operator actually subscribes — not the task chain. + monitorChainID := triggerMonitoringChainID(task.Trigger, 0) + if !chainHasOperator[monitorChainID] { out = append(out, orphanedTaskInfo{ taskID: taskID, - chainID: task.ChainId, + chainID: monitorChainID, triggerType: task.Trigger.Type.String(), }) } @@ -833,12 +905,11 @@ func (n *Engine) MustStart() error { panic(err) } - // Upon booting we load all enabled tasks across every chain bucket the - // aggregator hosts. Chain-scoped keys (t:{chainID}:a:...) share no - // common prefix below "t:", so we must iterate each chain explicitly. + // Upon booting we load all enabled tasks. Storage is chain-agnostic (G5), + // so a single "t:a:" prefix scan covers every enabled task. loadedCount := 0 - for _, chainID := range n.knownChainIDs() { - kvs, e := n.db.GetByPrefix(ChainWorkflowByStatusStoragePrefix(chainID, avsproto.TaskStatus_Enabled)) + { + kvs, e := n.db.GetByPrefix(WorkflowByStatusStoragePrefix(avsproto.TaskStatus_Enabled)) if e != nil { panic(e) } @@ -1384,7 +1455,7 @@ func (n *Engine) GetWalletWithContext(ctx context.Context, user *model.User, pay IsHidden: dbModelWallet.IsHidden, } - statSvc := NewStatServiceWithChains(n.db, n.knownChainIDs()) + statSvc := NewStatService(n.db) stat, statErr := statSvc.GetTaskCount(dbModelWallet) if statErr != nil { n.logger.Warn("Failed to get task count for GetWallet response", "walletAddress", dbModelWallet.Address.Hex(), "error", statErr) @@ -1464,7 +1535,7 @@ func (n *Engine) SetWallet(owner common.Address, payload *avsproto.SetWalletReq) IsHidden: updatedModelWallet.IsHidden, } - statSvc := NewStatServiceWithChains(n.db, n.knownChainIDs()) + statSvc := NewStatService(n.db) stat, statErr := statSvc.GetTaskCount(updatedModelWallet) if statErr != nil { n.logger.Warn("Failed to get task count for SetWallet response", "walletAddress", updatedModelWallet.Address.Hex(), "error", statErr) @@ -1644,20 +1715,9 @@ func (n *Engine) CreateWorkflow(user *model.User, taskPayload *avsproto.CreateTa // (non-gateway) deployments keep the legacy behavior — there's only one // chain there, so the inference is unambiguous. // - // Resolve chain_id BEFORE the wallet-ownership check below — that check - // reads `w:::` and would miss the canonical row - // if it ran against task.ChainId == 0. - if task.ChainId == 0 { - if n.config != nil && n.config.IsGateway { - return nil, status.Errorf(codes.InvalidArgument, - "chain_id is required when running in gateway mode; the SDK / REST handler "+ - "must populate it (e.g. from the JWT audience or an explicit request field) "+ - "so the task is bound to the chain it should execute on") - } - if n.config != nil && n.config.SmartWallet != nil { - task.ChainId = n.config.SmartWallet.ChainID - } - } + // A task no longer carries a chain (G5). Chain-aware validation below uses + // the trigger's monitoring chain (coverage) and the aggregator default + // chain (wallet ownership — the runner address is chain-invariant). // Reject tasks for chains no connected operator advertises. Block // and event triggers need a live operator subscription on the @@ -1668,16 +1728,19 @@ func (n *Engine) CreateWorkflow(user *model.User, taskPayload *avsproto.CreateTa // and the legacy back-compat path counts no-chain-list operators // as covering everything. if n.config != nil && n.config.IsGateway && task.Trigger != nil && chainNeedsOperatorMonitoring(task.Trigger.Type) { + // Coverage is judged on the trigger's monitoring chain (G2) — the chain + // the operator subscribes to — not the task chain. + monitorChainID := triggerMonitoringChainID(task.Trigger, 0) n.lock.Lock() - covering := n.operatorsCoveringChain(task.ChainId) + covering := n.operatorsCoveringChain(monitorChainID) n.lock.Unlock() if len(covering) == 0 { n.logger.Warn("🚫 CreateTask rejected: no operator advertises this chain", - "chain_id", task.ChainId, "trigger_type", task.Trigger.Type.String(), "user", userAddr) + "chain_id", monitorChainID, "trigger_type", task.Trigger.Type.String(), "user", userAddr) return nil, status.Errorf(codes.FailedPrecondition, "no connected operator currently monitors chain_id=%d for %s triggers; "+ "task cannot fire until coverage is restored", - task.ChainId, task.Trigger.Type.String()) + monitorChainID, task.Trigger.Type.String()) } } @@ -1686,7 +1749,10 @@ func (n *Engine) CreateWorkflow(user *model.User, taskPayload *avsproto.CreateTa // as task.SmartWalletAddress. We must verify the caller owns this wallet // on the chain this task targets. if task.SmartWalletAddress != "" { - valid, ownErr := ValidWalletOwner(n.db, task.ChainId, user, common.HexToAddress(task.SmartWalletAddress)) + // The runner wallet may have been registered on any chain (its CREATE2 + // address is chain-invariant), and a task no longer names a chain — so + // check ownership across every configured chain, not just the default. + valid, ownErr := n.userOwnsWalletOnAnyChain(user, common.HexToAddress(task.SmartWalletAddress)) if ownErr != nil { // Storage failure on the ownership check is NOT the same as // "wallet not owned" — surfacing it as InvalidArgument would @@ -1694,7 +1760,7 @@ func (n *Engine) CreateWorkflow(user *model.User, taskPayload *avsproto.CreateTa n.logger.Error("Wallet ownership check failed (storage error)", "owner", user.Address.Hex(), "smart_wallet", task.SmartWalletAddress, - "chain_id", task.ChainId, + "chain_id", n.defaultChainID(), "error", ownErr) return nil, status.Errorf(codes.Code(avsproto.ErrorCode_STORAGE_UNAVAILABLE), "failed to validate smart wallet ownership: %v", ownErr) @@ -1709,6 +1775,11 @@ func (n *Engine) CreateWorkflow(user *model.User, taskPayload *avsproto.CreateTa return nil, status.Errorf(codes.InvalidArgument, "node name validation failed: %v", err) } + // Reject chain-aware parts that name an explicit, unconfigured chain (G4). + if err := n.validateExplicitPartChains(task); err != nil { + return nil, err + } + updates := map[string][]byte{} taskJSON, err := task.ToJSON() @@ -1716,8 +1787,8 @@ func (n *Engine) CreateWorkflow(user *model.User, taskPayload *avsproto.CreateTa return nil, status.Errorf(codes.Internal, "Failed to serialize task: %v", err) } - updates[string(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status))] = taskJSON - updates[string(ChainTaskUserKey(task.ChainId, task))] = []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Enabled)) + updates[string(WorkflowStorageKey(task.Id, task.Status))] = taskJSON + updates[string(TaskUserKey(task))] = []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Enabled)) if err = n.db.BatchWrite(updates); err != nil { return nil, err @@ -2151,7 +2222,7 @@ func (n *Engine) StreamCheckToOperator(payload *avsproto.SyncMessagesReq, srv av n.logger.Debug("⏭️ Skipping task - operator does not monitor this chain", "task_id", task.Id, "operator", address, - "task_chain_id", task.ChainId) + "trigger_chain_id", triggerMonitoringChainID(task.Trigger, 0)) continue } @@ -2196,7 +2267,9 @@ func (n *Engine) StreamCheckToOperator(payload *avsproto.SyncMessagesReq, srv av ExpiredAt: task.ExpiredAt, Trigger: task.Trigger, StartAt: task.StartAt, - ChainId: task.ChainId, + // Operators route by the trigger's monitoring chain (G2), + // not the task chain — watch X, act Y. + ChainId: triggerMonitoringChainID(task.Trigger, 0), }, } @@ -2491,7 +2564,9 @@ func (n *Engine) sendMonitorTaskTriggerToOperators(task *model.Workflow) { ExpiredAt: task.ExpiredAt, Trigger: task.Trigger, StartAt: task.StartAt, - ChainId: task.ChainId, + // Operators route by the trigger's monitoring chain (G2), not the + // task chain — a workflow may watch chain X and act on chain Y. + ChainId: triggerMonitoringChainID(task.Trigger, 0), }, } @@ -3095,12 +3170,12 @@ func (n *Engine) instructOperatorImmediateTrigger(ctx context.Context, taskID st return fmt.Errorf("failed to get task for immediate trigger: %w", err) } - // The current-block read must target the task's chain. Required — there - // is no engine-default chain; a zero/unset chain_id is a hard error so - // the read never silently targets the wrong chain. - chainID := task.ChainId + // The current-block read must target the block trigger's own chain (G5) — + // a zero/unset chain is a hard error so the read never silently targets the + // wrong chain. + chainID := triggerMonitoringChainID(task.Trigger, 0) if chainID == 0 { - return fmt.Errorf("task %s has no chain_id; cannot resolve chain for immediate block trigger", taskID) + return fmt.Errorf("task %s block trigger has no chain_id; cannot resolve chain for immediate block trigger", taskID) } reader := GetChainStateReaderForChain(uint64(chainID)) if reader == nil { @@ -3150,7 +3225,8 @@ func (n *Engine) instructOperatorImmediateTrigger(ctx context.Context, taskID st ExpiredAt: task.ExpiredAt, Trigger: task.Trigger, StartAt: task.StartAt, - ChainId: task.ChainId, + // Operators route by the trigger's monitoring chain (G2). + ChainId: triggerMonitoringChainID(task.Trigger, 0), }, } @@ -3227,17 +3303,9 @@ func (n *Engine) TriggerWorkflowWithContext(ctx context.Context, user *model.Use return nil, status.Errorf(codes.NotFound, TaskNotFoundError) } - // Allow caller to override the task's stored chain_id for this invocation. - // Useful for testing a task against a different chain without re-creating it. - if reqChainID := payload.GetChainId(); reqChainID != 0 && reqChainID != task.ChainId { - if n.logger != nil { - n.logger.Info("TriggerTask overriding task chain_id", - "task_id", task.Id, - "task_chain_id", task.ChainId, - "requested_chain_id", reqChainID) - } - task.ChainId = reqChainID - } + // A task no longer carries a chain (G5), so there is nothing to override — + // the payload's chain_id is ignored. Each chain-aware node runs on its own + // configured chain. // Important business logic validation: Check if task is runnable if !task.IsRunable() { @@ -3340,7 +3408,7 @@ func (n *Engine) TriggerWorkflowWithContext(ctx context.Context, user *model.Use } if payload.IsBlocking { - executor := NewExecutor(n.ResolveSmartWalletConfig(task.ChainId), n.db, n.logger, n, n.priceService) + executor := NewExecutor(n.ResolveSmartWalletConfig(n.defaultChainID()), n.db, n.logger, n, n.priceService) execution, runErr := executor.RunTaskWithContext(ctx, task, &queueTaskData) if runErr != nil { n.logger.Error("failed to run blocking task", runErr) @@ -3372,7 +3440,7 @@ func (n *Engine) TriggerWorkflowWithContext(ctx context.Context, user *model.Use sanitizeExecutionForPersistence(execution) mo := protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true} if b, mErr := mo.Marshal(execution); mErr == nil { - key := ChainTaskExecutionKey(task.ChainId, task, execution.Id) + key := TaskExecutionKey(task, execution.Id) if setErr := n.db.Set(key, b); setErr != nil { n.logger.Error("TriggerTask: failed to persist execution synchronously", "task_id", task.Id, "execution_id", execution.Id, "error", setErr) } else if n.logger != nil { @@ -3474,7 +3542,6 @@ func (n *Engine) SimulateWorkflowWithContext(ctx context.Context, user *model.Us Nodes: nodes, Edges: edges, Status: avsproto.TaskStatus_Enabled, // Set as enabled for simulation - ChainId: chainID, // Workflow-level chain override; 0 = aggregator default }, } @@ -3583,9 +3650,16 @@ func (n *Engine) SimulateWorkflowWithContext(ctx context.Context, user *model.Us secrets = make(map[string]string) } - // Step 5: Create VM with simulated trigger data (similar to RunTask) + // Step 5: Create VM with simulated trigger data (similar to RunTask). + // The simulation's default config chain comes from the optional chainID + // arg (falling back to the aggregator default); per-node chains still win + // via the chainConfigResolver wired below. + simChainID := chainID + if simChainID == 0 { + simChainID = n.defaultChainID() + } triggerReason := GetTriggerReasonOrDefault(queueData, task.Id, n.logger) - vm, err := NewVMWithData(task, triggerReason, n.ResolveSmartWalletConfig(task.ChainId), secrets) + vm, err := NewVMWithData(task, triggerReason, n.ResolveSmartWalletConfig(simChainID), secrets) if err != nil { return nil, fmt.Errorf("failed to create VM for simulation: %w", err) } @@ -4226,7 +4300,7 @@ func (n *Engine) GetExecution(user *model.User, payload *avsproto.ExecutionReq) } // First try to get completed execution from storage - rawExecution, err := n.db.GetKey(ChainTaskExecutionKey(task.ChainId, task, payload.ExecutionId)) + rawExecution, err := n.db.GetKey(TaskExecutionKey(task, payload.ExecutionId)) if err == nil { exec := &avsproto.Execution{} // DiscardUnknown: tolerate proto fields renamed/removed since @@ -4292,7 +4366,7 @@ func (n *Engine) GetExecutionStatus(user *model.User, payload *avsproto.Executio } // First check if execution is completed and stored - rawExecution, err := n.db.GetKey(ChainTaskExecutionKey(task.ChainId, task, payload.ExecutionId)) + rawExecution, err := n.db.GetKey(TaskExecutionKey(task, payload.ExecutionId)) if err == nil { exec := &avsproto.Execution{} // DiscardUnknown: tolerate proto fields renamed/removed since @@ -4398,7 +4472,7 @@ func (n *Engine) DeleteWorkflowByUser(user *model.User, taskID string) (*avsprot deletedAt := time.Now().UnixMilli() n.logger.Info("🗑️ Deleting task storage", "task_id", taskID) - if err := n.db.Delete(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status)); err != nil { + if err := n.db.Delete(WorkflowStorageKey(task.Id, task.Status)); err != nil { n.logger.Error("failed to delete task storage", "error", err, "task_id", task.Id) return &avsproto.DeleteTaskResp{ Success: false, @@ -4409,7 +4483,7 @@ func (n *Engine) DeleteWorkflowByUser(user *model.User, taskID string) (*avsprot } n.logger.Info("🗑️ Deleting task user key", "task_id", taskID) - if err := n.db.Delete(ChainTaskUserKey(task.ChainId, task)); err != nil { + if err := n.db.Delete(TaskUserKey(task)); err != nil { n.logger.Error("failed to delete task user key", "error", err, "task_id", task.Id) return &avsproto.DeleteTaskResp{ Success: false, @@ -4519,8 +4593,8 @@ func (n *Engine) SetWorkflowEnabledByUser(user *model.User, taskID string, enabl PreviousStatus: getTaskStatusString(oldStatus), }, nil } - updates[string(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status))] = taskJSON - updates[string(ChainTaskUserKey(task.ChainId, task))] = []byte(fmt.Sprintf("%d", task.Status)) + updates[string(WorkflowStorageKey(task.Id, task.Status))] = taskJSON + updates[string(TaskUserKey(task))] = []byte(fmt.Sprintf("%d", task.Status)) if err = n.db.BatchWrite(updates); err != nil { return &avsproto.SetTaskEnabledResp{ @@ -4534,7 +4608,7 @@ func (n *Engine) SetWorkflowEnabledByUser(user *model.User, taskID string, enabl // Delete old record if different status if oldStatus != task.Status { - if delErr := n.db.Delete(ChainWorkflowStorageKey(task.ChainId, task.Id, oldStatus)); delErr != nil { + if delErr := n.db.Delete(WorkflowStorageKey(task.Id, oldStatus)); delErr != nil { n.logger.Error("failed to delete old task status entry", "error", delErr, "task_id", task.Id, "old_status", oldStatus) } } @@ -4597,13 +4671,13 @@ func (n *Engine) DisableWorkflow(taskID string) (bool, error) { return false, fmt.Errorf("failed to serialize task during disabling: %w", err) } - updates[string(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status))] = taskJSON - updates[string(ChainTaskUserKey(task.ChainId, task))] = []byte(fmt.Sprintf("%d", task.Status)) + updates[string(WorkflowStorageKey(task.Id, task.Status))] = taskJSON + updates[string(TaskUserKey(task))] = []byte(fmt.Sprintf("%d", task.Status)) if err = n.db.BatchWrite(updates); err == nil { // Delete the old record if oldStatus != task.Status { - if delErr := n.db.Delete(ChainWorkflowStorageKey(task.ChainId, task.Id, oldStatus)); delErr != nil { + if delErr := n.db.Delete(WorkflowStorageKey(task.Id, oldStatus)); delErr != nil { n.logger.Error("failed to delete old task status entry", "error", delErr, "task_id", task.Id, "old_status", oldStatus) } } @@ -4993,11 +5067,15 @@ func (n *Engine) supportsTaskChain(operatorAddr string, task *model.Workflow) bo if len(state.SupportedChainIDs) == 0 { return true } - if task.ChainId == 0 { + // Capability is judged on the trigger's monitoring chain (G2) — the chain + // the operator must subscribe to. A chain-agnostic trigger (cron/manual) + // yields 0 and is covered by any operator. + monitorChainID := triggerMonitoringChainID(task.Trigger, 0) + if monitorChainID == 0 { return true } for _, id := range state.SupportedChainIDs { - if id == task.ChainId { + if id == monitorChainID { return true } } @@ -6218,7 +6296,6 @@ func (n *Engine) DetectAndHandleInvalidTasks() error { "task_id", taskID, "owner", task.GetOwner(), "smart_wallet", task.GetSmartWalletAddress(), - "chain_id", task.GetChainId(), "created_at", createdAt, "error", err.Error()) @@ -6237,14 +6314,14 @@ func (n *Engine) DetectAndHandleInvalidTasks() error { } // Prepare the task status update in storage - updates[string(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status))] = taskJSON - updates[string(ChainTaskUserKey(task.ChainId, task))] = []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Failed)) + updates[string(WorkflowStorageKey(task.Id, task.Status))] = taskJSON + updates[string(TaskUserKey(task))] = []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Failed)) // Queue the old status-keyed row for deletion (skip when the // status didn't actually change, to avoid deleting what we just // wrote). if oldStatus != task.Status { - staleStatusKeys = append(staleStatusKeys, ChainWorkflowStorageKey(task.ChainId, task.Id, oldStatus)) + staleStatusKeys = append(staleStatusKeys, WorkflowStorageKey(task.Id, oldStatus)) } // Drop from the in-memory active set so it isn't treated as active diff --git a/core/taskengine/engine_invalid_tasks_test.go b/core/taskengine/engine_invalid_tasks_test.go index dc64f8c8..d2a3f892 100644 --- a/core/taskengine/engine_invalid_tasks_test.go +++ b/core/taskengine/engine_invalid_tasks_test.go @@ -18,9 +18,8 @@ func seedInvalidEnabledTask(t *testing.T, db interface { }, chainID int64, id string) *model.Workflow { t.Helper() task := &model.Workflow{Task: &avsproto.Task{ - Id: id, - ChainId: chainID, - Status: avsproto.TaskStatus_Enabled, + Id: id, + Status: avsproto.TaskStatus_Enabled, Trigger: &avsproto.TaskTrigger{ Name: "trigger1", // Block trigger with a nil Config → fails ValidateWithError with @@ -32,7 +31,7 @@ func seedInvalidEnabledTask(t *testing.T, db interface { taskJSON, err := task.ToJSON() require.NoError(t, err) - require.NoError(t, db.Set(ChainWorkflowStorageKey(chainID, id, avsproto.TaskStatus_Enabled), taskJSON)) + require.NoError(t, db.Set(WorkflowStorageKey(id, avsproto.TaskStatus_Enabled), taskJSON)) return task } @@ -58,7 +57,7 @@ func TestDetectAndHandleInvalidTasks_DeletesStaleEnabledKey(t *testing.T) { assert.Empty(t, enabledItems, "stale Enabled-status key must be deleted so the next boot can't reload it") // ...and the task must now live under the Failed key. - failedExists, err := db.Exist(ChainWorkflowStorageKey(chainID, task.Id, avsproto.TaskStatus_Failed)) + failedExists, err := db.Exist(WorkflowStorageKey(task.Id, avsproto.TaskStatus_Failed)) require.NoError(t, err) assert.True(t, failedExists, "task should now be stored under the Failed status key") @@ -84,9 +83,8 @@ func TestDetectAndHandleInvalidTasks_LeavesValidTasksAlone(t *testing.T) { const chainID = int64(11155111) valid := &model.Workflow{Task: &avsproto.Task{ - Id: "valid-task-1", - ChainId: chainID, - Status: avsproto.TaskStatus_Enabled, + Id: "valid-task-1", + Status: avsproto.TaskStatus_Enabled, Trigger: &avsproto.TaskTrigger{ Name: "trigger1", TriggerType: &avsproto.TaskTrigger_Block{Block: &avsproto.BlockTrigger{ @@ -104,7 +102,7 @@ func TestDetectAndHandleInvalidTasks_LeavesValidTasksAlone(t *testing.T) { n.lock.Unlock() assert.True(t, stillActive, "valid task must remain active") - failedExists, err := db.Exist(ChainWorkflowStorageKey(chainID, valid.Id, avsproto.TaskStatus_Failed)) + failedExists, err := db.Exist(WorkflowStorageKey(valid.Id, avsproto.TaskStatus_Failed)) require.NoError(t, err) assert.False(t, failedExists, "valid task must not be written under the Failed key") } diff --git a/core/taskengine/eth_transfer_integration_test.go b/core/taskengine/eth_transfer_integration_test.go index d791d2a9..7cb46628 100644 --- a/core/taskengine/eth_transfer_integration_test.go +++ b/core/taskengine/eth_transfer_integration_test.go @@ -205,6 +205,7 @@ func TestETHTransferTaskWithInvalidConfig(t *testing.T) { Config: &avsproto.ETHTransferNode_Config{ Destination: "invalid-address", // Invalid address Amount: "1000000000000000000", + ChainId: 1, // explicit chain (G5) so destination validation is reached }, }, }, diff --git a/core/taskengine/execute_uniswap_approval_test.go b/core/taskengine/execute_uniswap_approval_test.go index e99f29e0..ab3ef11c 100644 --- a/core/taskengine/execute_uniswap_approval_test.go +++ b/core/taskengine/execute_uniswap_approval_test.go @@ -160,6 +160,7 @@ func TestExecuteTask_UniswapApprovalAndSwap_DifferentApprovalAmounts_Sepolia(t * TaskType: &avsproto.TaskNode_ContractWrite{ ContractWrite: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (strict) ContractAddress: "{{settings.uniswap_v3_pool.token1.id}}", // USDC ContractAbi: func() []*structpb.Value { abi, _ := structpb.NewValue(map[string]interface{}{ @@ -195,6 +196,7 @@ func TestExecuteTask_UniswapApprovalAndSwap_DifferentApprovalAmounts_Sepolia(t * TaskType: &avsproto.TaskNode_ContractWrite{ ContractWrite: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (strict) ContractAddress: "{{settings.uniswap_v3_contracts.quoterV2}}", ContractAbi: func() []*structpb.Value { abi, _ := structpb.NewValue(map[string]interface{}{ @@ -243,6 +245,7 @@ func TestExecuteTask_UniswapApprovalAndSwap_DifferentApprovalAmounts_Sepolia(t * TaskType: &avsproto.TaskNode_ContractWrite{ ContractWrite: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (strict) ContractAddress: "{{settings.uniswap_v3_contracts.swapRouter02}}", ContractAbi: func() []*structpb.Value { abi, _ := structpb.NewValue(map[string]interface{}{ diff --git a/core/taskengine/executor.go b/core/taskengine/executor.go index 4a74a330..da16e5fe 100644 --- a/core/taskengine/executor.go +++ b/core/taskengine/executor.go @@ -252,22 +252,12 @@ func (x *WorkflowExecutor) RunTaskWithContext(ctx context.Context, task *model.W // Create VM with trigger reason data. // In gateway mode, resolve the SmartWalletConfig for the task's target chain. + // A task carries no chain (G5); the VM's default config is the aggregator + // default. Each chain-aware node resolves its OWN chain via the + // chainConfigResolver wired into the VM, so this default is only a fallback. swConfig := x.smartWalletConfig if x.engine != nil { - if task.ChainId == 0 { - // Legacy task without chain_id. ResolveSmartWalletConfig(0) returns - // the gateway's default smart_wallet config, which in gateway mode - // is populated from chains[0] (mainnet by convention; see - // core/config/config.go:367). For any task whose actual chain isn't - // chains[0], paymaster + bundler + RPC will be wrong and on-chain - // nodes (ETHTransfer, ContractWrite) will fail with - // "no contract code at given address". Log loudly so affected - // workflows are discoverable and can be migrated. - x.logger.Warn("task missing chain_id; falling back to gateway default chain", - "task_id", task.Id, - "workflow_name", task.Name) - } - swConfig = x.engine.ResolveSmartWalletConfig(task.ChainId) + swConfig = x.engine.ResolveSmartWalletConfig(x.engine.defaultChainID()) } vm, err := NewVMWithData(task, triggerReason, swConfig, secrets) if err != nil { @@ -460,7 +450,17 @@ func (x *WorkflowExecutor) RunTaskWithContext(ctx context.Context, task *model.W smartWalletAddr := common.HexToAddress(task.SmartWalletAddress) // Enhanced wallet validation that handles any legitimately derived wallet - isValid, err := x.validateWalletOwnership(ctx, task.ChainId, user, smartWalletAddr) + isValid, err := x.validateWalletOwnership(ctx, x.engine.defaultChainID(), user, smartWalletAddr) + if err == nil && !isValid { + // The smart-wallet runner address is chain-invariant across configured + // chains, but its ownership record (w::...) may have been written + // under a different chain than the gateway default. Mirror the create-time + // cross-chain check (userOwnsWalletOnAnyChain) so a wallet registered on a + // non-default chain isn't spuriously rejected at execution → auto-disabled. + if ok, ownErr := x.engine.userOwnsWalletOnAnyChain(user, smartWalletAddr); ownErr == nil && ok { + isValid = true + } + } if err != nil { execution.EndAt = time.Now().UnixMilli() execution.Error = fmt.Sprintf("failed to validate wallet ownership for owner %s: %v", owner.Hex(), err) @@ -480,12 +480,19 @@ func (x *WorkflowExecutor) RunTaskWithContext(ctx context.Context, task *model.W x.logger.Info("Executor: AA sender resolved", "sender", task.SmartWalletAddress) } - // Look up the wallet's salt from DB for auto-deployment of non-salt-0 wallets + // Look up the wallet's salt from DB for auto-deployment of non-salt-0 wallets. + // The salt is chain-invariant (same salt derives the same address on every + // configured chain), but the record (w::...) is keyed by the chain it + // was registered on, which may differ from the gateway default. Scan all + // configured chains so the salt isn't missed → wallet auto-deployed wrong. if x.db != nil { - if wallet, walletErr := GetWallet(x.db, task.ChainId, owner, task.SmartWalletAddress); walletErr == nil && wallet != nil && wallet.Salt != nil { - vm.AddVar("aa_salt", wallet.Salt) - if x.logger != nil { - x.logger.Info("Executor: AA salt resolved from wallet DB", "salt", wallet.Salt.String(), "wallet", task.SmartWalletAddress) + for _, walletChainID := range x.engine.knownChainIDs() { + if wallet, walletErr := GetWallet(x.db, walletChainID, owner, task.SmartWalletAddress); walletErr == nil && wallet != nil && wallet.Salt != nil { + vm.AddVar("aa_salt", wallet.Salt) + if x.logger != nil { + x.logger.Info("Executor: AA salt resolved from wallet DB", "salt", wallet.Salt.String(), "wallet", task.SmartWalletAddress, "chain_id", walletChainID) + } + break } } } @@ -543,15 +550,23 @@ func (x *WorkflowExecutor) RunTaskWithContext(ctx context.Context, task *model.W if creditLimitWei != nil { taskOwner := common.HexToAddress(task.Owner) - withinLimit, outstanding, checkErr := x.feeLedger.CheckCreditLimit(task.ChainId, taskOwner, creditLimitWei) - if checkErr != nil { - x.logger.Warn("Fee ledger check failed, proceeding with execution", "error", checkErr) - } else if !withinLimit { - execution.EndAt = time.Now().UnixMilli() - execution.Error = fmt.Sprintf("[INSUFFICIENT_CREDIT] outstanding value fees (%s wei) exceed credit limit", outstanding.String()) - execution.Status = avsproto.ExecutionStatus_EXECUTION_STATUS_FAILED - x.persistFailedExecution(task, execution, initialTaskStatus) - return execution, nil + // Value fees accrue per execution chain (fl::) and a task's + // nodes can act on different chains, so an outstanding balance may sit on a + // non-default chain. Gate against every configured chain rather than just + // the gateway default, so credit limits can't be bypassed cross-chain. + for _, feeChainID := range x.engine.knownChainIDs() { + withinLimit, outstanding, checkErr := x.feeLedger.CheckCreditLimit(feeChainID, taskOwner, creditLimitWei) + if checkErr != nil { + x.logger.Warn("Fee ledger check failed, proceeding with execution", "chain_id", feeChainID, "error", checkErr) + continue + } + if !withinLimit { + execution.EndAt = time.Now().UnixMilli() + execution.Error = fmt.Sprintf("[INSUFFICIENT_CREDIT] outstanding value fees (%s wei) exceed credit limit", outstanding.String()) + execution.Status = avsproto.ExecutionStatus_EXECUTION_STATUS_FAILED + x.persistFailedExecution(task, execution, initialTaskStatus) + return execution, nil + } } } } @@ -731,8 +746,8 @@ func (x *WorkflowExecutor) RunTaskWithContext(ctx context.Context, task *model.W // batch update storage for task + execution log updates := map[string][]byte{} - updates[string(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status))], err = task.ToJSON() - updates[string(ChainTaskUserKey(task.ChainId, task))] = []byte(fmt.Sprintf("%d", task.Status)) + updates[string(WorkflowStorageKey(task.Id, task.Status))], err = task.ToJSON() + updates[string(TaskUserKey(task))] = []byte(fmt.Sprintf("%d", task.Status)) // update execution log var executionByte []byte @@ -749,7 +764,7 @@ func (x *WorkflowExecutor) RunTaskWithContext(ctx context.Context, task *model.W } if mErr == nil { executionByte = b - key := string(ChainTaskExecutionKey(task.ChainId, task, execution.Id)) + key := string(TaskExecutionKey(task, execution.Id)) updates[key] = executionByte if x.logger != nil { x.logger.Info("Executor: persisting execution", "task_id", task.Id, "execution_id", execution.Id, "key", key) @@ -766,7 +781,7 @@ func (x *WorkflowExecutor) RunTaskWithContext(ctx context.Context, task *model.W // whenever a task change its status, we moved it, therefore we will need to clean up the old storage if task.Status != initialTaskStatus { - if err = x.db.Delete(ChainWorkflowStorageKey(task.ChainId, task.Id, initialTaskStatus)); err != nil { + if err = x.db.Delete(WorkflowStorageKey(task.Id, initialTaskStatus)); err != nil { x.logger.Errorf("error updating task status. %w", err, "task_id", task.Id) } } @@ -1083,8 +1098,8 @@ func (x *WorkflowExecutor) persistFailedExecution(task *model.Workflow, executio // Update task data if taskJSON, err := task.ToJSON(); err == nil { - updates[string(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status))] = taskJSON - updates[string(ChainTaskUserKey(task.ChainId, task))] = []byte(fmt.Sprintf("%d", task.Status)) + updates[string(WorkflowStorageKey(task.Id, task.Status))] = taskJSON + updates[string(TaskUserKey(task))] = []byte(fmt.Sprintf("%d", task.Status)) } else { x.logger.Error("Failed to serialize task for persistence", "task_id", task.Id, "error", err) } @@ -1092,7 +1107,7 @@ func (x *WorkflowExecutor) persistFailedExecution(task *model.Workflow, executio // Update execution log mo := protojson.MarshalOptions{UseProtoNames: true, EmitUnpopulated: true} if executionByte, mErr := mo.Marshal(execution); mErr == nil { - key := string(ChainTaskExecutionKey(task.ChainId, task, execution.Id)) + key := string(TaskExecutionKey(task, execution.Id)) updates[key] = executionByte x.logger.Info("Executor: persisting failed execution", "task_id", task.Id, "execution_id", execution.Id, "key", key) } else { @@ -1106,7 +1121,7 @@ func (x *WorkflowExecutor) persistFailedExecution(task *model.Workflow, executio // Clean up old task status if it changed if task.Status != initialTaskStatus { - if err := x.db.Delete(ChainWorkflowStorageKey(task.ChainId, task.Id, initialTaskStatus)); err != nil { + if err := x.db.Delete(WorkflowStorageKey(task.Id, initialTaskStatus)); err != nil { x.logger.Error("error cleaning up old task status", "task_id", task.Id, "error", err) } } @@ -1134,13 +1149,12 @@ func (x *WorkflowExecutor) reportTaskAutoDisabled(task *model.Workflow, reason s createdAt = time.UnixMilli(int64(parsed.Time())).UTC().Format(time.RFC3339) } factoryAddress := "" - if swCfg := x.resolveSmartWalletConfig(task.ChainId); swCfg != nil { + if swCfg := x.resolveSmartWalletConfig(x.engine.defaultChainID()); swCfg != nil { factoryAddress = swCfg.FactoryAddress.Hex() } x.logger.Warn("task auto-disabled after consecutive validation failures", "task_id", task.Id, - "chain_id", task.ChainId, "owner", task.Owner, "smart_wallet", task.SmartWalletAddress, "factory_address", factoryAddress, @@ -1150,7 +1164,6 @@ func (x *WorkflowExecutor) reportTaskAutoDisabled(task *model.Workflow, reason s sentry.WithScope(func(scope *sentry.Scope) { scope.SetTag("event", "task_auto_disabled") scope.SetTag("task_id", task.Id) - scope.SetTag("chain_id", strconv.FormatInt(task.ChainId, 10)) scope.SetTag("owner", task.Owner) scope.SetTag("smart_wallet", task.SmartWalletAddress) if factoryAddress != "" { diff --git a/core/taskengine/executor_validation_log_level_test.go b/core/taskengine/executor_validation_log_level_test.go index 8fbdde8f..22edb230 100644 --- a/core/taskengine/executor_validation_log_level_test.go +++ b/core/taskengine/executor_validation_log_level_test.go @@ -134,7 +134,6 @@ func TestReportTaskAutoDisabledIncludesOwnerContext(t *testing.T) { Id: taskID, Owner: "0x000000000000000000000000000000000000beef", SmartWalletAddress: "0x000000000000000000000000000000000000dead", - ChainId: 11155111, Status: avsproto.TaskStatus_Enabled, }, } @@ -159,7 +158,6 @@ func TestReportTaskAutoDisabledIncludesOwnerContext(t *testing.T) { "task_id": taskID, "owner": task.Owner, "smart_wallet": task.SmartWalletAddress, - "chain_id": task.ChainId, "reason": errMsg, } for k, want := range wantFields { diff --git a/core/taskengine/fee_billing_integration_test.go b/core/taskengine/fee_billing_integration_test.go index c6eced73..9a56eed8 100644 --- a/core/taskengine/fee_billing_integration_test.go +++ b/core/taskengine/fee_billing_integration_test.go @@ -54,7 +54,6 @@ func TestFeeBilling_CreditGating_BlocksOnOutstandingBalance(t *testing.T) { task := &model.Workflow{ Task: &avsproto.Task{ Id: "test-fee-gating", - ChainId: int64(1), Owner: strings.ToLower(owner.Hex()), SmartWalletAddress: smartWalletAddr, Status: avsproto.TaskStatus_Enabled, @@ -166,7 +165,6 @@ func TestFeeBilling_CreditGating_AllowsWithHighCreditLimit(t *testing.T) { task := &model.Workflow{ Task: &avsproto.Task{ Id: "test-fee-high-limit", - ChainId: int64(1), Owner: strings.ToLower(owner.Hex()), SmartWalletAddress: smartWalletAddr, Status: avsproto.TaskStatus_Enabled, diff --git a/core/taskengine/manual_trigger_loop_test.go b/core/taskengine/manual_trigger_loop_test.go index 260b9a74..f83b2fc4 100644 --- a/core/taskengine/manual_trigger_loop_test.go +++ b/core/taskengine/manual_trigger_loop_test.go @@ -293,6 +293,7 @@ func TestLoopNode_ContractWrite_Approve_PerIterationData(t *testing.T) { "type": "contractWrite", "config": map[string]interface{}{ "contractAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "chainId": 11155111, // explicit chain (G5 strict) "contractAbi": []interface{}{ map[string]interface{}{ "type": "function", @@ -398,6 +399,7 @@ func TestLoopNode_ContractWrite_InvalidAddress_PartialFailure(t *testing.T) { "type": "contractWrite", "config": map[string]interface{}{ "contractAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "chainId": 11155111, // explicit chain (G5 strict) "contractAbi": []interface{}{ map[string]interface{}{ "type": "function", @@ -488,6 +490,7 @@ func TestLoopNode_ContractWrite_MetadataTransactionHash(t *testing.T) { "type": "contractWrite", "config": map[string]interface{}{ "contractAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", + "chainId": 11155111, // explicit chain (G5 strict) "contractAbi": []interface{}{ map[string]interface{}{ "type": "function", @@ -569,6 +572,7 @@ func TestLoopNode_EthTransfer_MetadataTransactionHash(t *testing.T) { "type": "ethTransfer", "config": map[string]interface{}{ "destination": "{{value}}", + "chainId": 11155111, // explicit chain (G5 strict) "amount": "1000000000000000", // 0.001 ETH }, }, diff --git a/core/taskengine/operator_capability_test.go b/core/taskengine/operator_capability_test.go index d08d39b0..6d1884e4 100644 --- a/core/taskengine/operator_capability_test.go +++ b/core/taskengine/operator_capability_test.go @@ -220,25 +220,30 @@ func TestScanOrphanedTasks_NoDeadlock(t *testing.T) { seedOperator(engine, "0xMainnet", []int64{1, 8453}) + // Chain lives on the event trigger (G2/G5), not the task — coverage is + // judged on the trigger's monitoring chain. + eventOnChain := func(chainID int64) *avsproto.TaskTrigger { + return &avsproto.TaskTrigger{ + Type: avsproto.TriggerType_TRIGGER_TYPE_EVENT, + TriggerType: &avsproto.TaskTrigger_Event{Event: &avsproto.EventTrigger{Config: &avsproto.EventTrigger_Config{ChainId: chainID}}}, + } + } engine.lock.Lock() engine.tasks["covered-event"] = &model.Workflow{ Task: &avsproto.Task{ Id: "covered-event", - ChainId: 1, - Trigger: &avsproto.TaskTrigger{Type: avsproto.TriggerType_TRIGGER_TYPE_EVENT}, + Trigger: eventOnChain(1), }, } engine.tasks["orphan-event"] = &model.Workflow{ Task: &avsproto.Task{ Id: "orphan-event", - ChainId: 56, // BNB — no operator covers - Trigger: &avsproto.TaskTrigger{Type: avsproto.TriggerType_TRIGGER_TYPE_EVENT}, + Trigger: eventOnChain(56), // BNB — no operator covers }, } engine.tasks["cron-chain-agnostic"] = &model.Workflow{ Task: &avsproto.Task{ Id: "cron-chain-agnostic", - ChainId: 56, Trigger: &avsproto.TaskTrigger{Type: avsproto.TriggerType_TRIGGER_TYPE_CRON}, }, } diff --git a/core/taskengine/operator_notification_test.go b/core/taskengine/operator_notification_test.go index 357ab10c..beea40dc 100644 --- a/core/taskengine/operator_notification_test.go +++ b/core/taskengine/operator_notification_test.go @@ -615,13 +615,13 @@ func TestTerminalStatesCannotBeToggled(t *testing.T) { assert.NoError(t, err) updates := map[string][]byte{ - string(ChainWorkflowStorageKey(taskObj.ChainId, taskObj.Id, avsproto.TaskStatus_Completed)): taskJSON, - string(ChainTaskUserKey(taskObj.ChainId, taskObj)): []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Completed)), + string(WorkflowStorageKey(taskObj.Id, avsproto.TaskStatus_Completed)): taskJSON, + string(TaskUserKey(taskObj)): []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Completed)), } err = engine.db.BatchWrite(updates) assert.NoError(t, err) // Delete old key - _ = engine.db.Delete(ChainWorkflowStorageKey(taskObj.ChainId, taskObj.Id, oldStatus)) + _ = engine.db.Delete(WorkflowStorageKey(taskObj.Id, oldStatus)) // Remove from active tasks engine.lock.Lock() diff --git a/core/taskengine/partial_success_test.go b/core/taskengine/partial_success_test.go index 5da16111..83d1ae1a 100644 --- a/core/taskengine/partial_success_test.go +++ b/core/taskengine/partial_success_test.go @@ -190,11 +190,10 @@ func TestGetExecutionStatus_StepFailures(t *testing.T) { // Create a test task task := &model.Workflow{ Task: &avsproto.Task{ - Id: "test-task-id", - ChainId: int64(1), - Owner: user.Address.Hex(), - Status: avsproto.TaskStatus_Enabled, - Name: "Test Task", + Id: "test-task-id", + Owner: user.Address.Hex(), + Status: avsproto.TaskStatus_Enabled, + Name: "Test Task", }, } @@ -233,7 +232,7 @@ func TestGetExecutionStatus_StepFailures(t *testing.T) { if err != nil { t.Fatalf("Failed to serialize task: %v", err) } - err = db.Set(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status), taskJSON) + err = db.Set(WorkflowStorageKey(task.Id, task.Status), taskJSON) if err != nil { t.Fatalf("Failed to store task: %v", err) } @@ -243,7 +242,7 @@ func TestGetExecutionStatus_StepFailures(t *testing.T) { if err != nil { t.Fatalf("Failed to serialize execution: %v", err) } - err = db.Set(ChainTaskExecutionKey(task.ChainId, task, execution.Id), executionJSON) + err = db.Set(TaskExecutionKey(task, execution.Id), executionJSON) if err != nil { t.Fatalf("Failed to store execution: %v", err) } @@ -279,11 +278,10 @@ func TestGetExecutionStatus_FullSuccess(t *testing.T) { // Create a test task task := &model.Workflow{ Task: &avsproto.Task{ - Id: "test-task-id", - ChainId: int64(1), - Owner: user.Address.Hex(), - Status: avsproto.TaskStatus_Enabled, - Name: "Test Task", + Id: "test-task-id", + Owner: user.Address.Hex(), + Status: avsproto.TaskStatus_Enabled, + Name: "Test Task", }, } @@ -316,7 +314,7 @@ func TestGetExecutionStatus_FullSuccess(t *testing.T) { if err != nil { t.Fatalf("Failed to serialize task: %v", err) } - err = db.Set(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status), taskJSON) + err = db.Set(WorkflowStorageKey(task.Id, task.Status), taskJSON) if err != nil { t.Fatalf("Failed to store task: %v", err) } @@ -326,7 +324,7 @@ func TestGetExecutionStatus_FullSuccess(t *testing.T) { if err != nil { t.Fatalf("Failed to serialize execution: %v", err) } - err = db.Set(ChainTaskExecutionKey(task.ChainId, task, execution.Id), executionJSON) + err = db.Set(TaskExecutionKey(task, execution.Id), executionJSON) if err != nil { t.Fatalf("Failed to store execution: %v", err) } @@ -362,11 +360,10 @@ func TestGetExecutionStatus_FullFailure(t *testing.T) { // Create a test task task := &model.Workflow{ Task: &avsproto.Task{ - Id: "test-task-id", - ChainId: int64(1), - Owner: user.Address.Hex(), - Status: avsproto.TaskStatus_Enabled, - Name: "Test Task", + Id: "test-task-id", + Owner: user.Address.Hex(), + Status: avsproto.TaskStatus_Enabled, + Name: "Test Task", }, } @@ -399,7 +396,7 @@ func TestGetExecutionStatus_FullFailure(t *testing.T) { if err != nil { t.Fatalf("Failed to serialize task: %v", err) } - err = db.Set(ChainWorkflowStorageKey(task.ChainId, task.Id, task.Status), taskJSON) + err = db.Set(WorkflowStorageKey(task.Id, task.Status), taskJSON) if err != nil { t.Fatalf("Failed to store task: %v", err) } @@ -409,7 +406,7 @@ func TestGetExecutionStatus_FullFailure(t *testing.T) { if err != nil { t.Fatalf("Failed to serialize execution: %v", err) } - err = db.Set(ChainTaskExecutionKey(task.ChainId, task, execution.Id), executionJSON) + err = db.Set(TaskExecutionKey(task, execution.Id), executionJSON) if err != nil { t.Fatalf("Failed to store execution: %v", err) } diff --git a/core/taskengine/run_node_immediately.go b/core/taskengine/run_node_immediately.go index bd34bc24..e7870f82 100644 --- a/core/taskengine/run_node_immediately.go +++ b/core/taskengine/run_node_immediately.go @@ -28,6 +28,39 @@ const erc20OverridesConfigKey = "__erc20Overrides" // getRealisticBlockNumberForChain returns a realistic block number for simulation based on chain ID // Only includes chains that the aggregator actually supports: Ethereum and Base + +// stampNodeChainIfUnset sets chainID on a chain-aware node's config when the +// node didn't specify one. Used by RunNodeImmediately, where the chain is +// supplied via the request rather than baked into the node (G5: the strict +// resolver only reads node.Config.chain_id). +func stampNodeChainIfUnset(node *avsproto.TaskNode, chainID int64) { + if node == nil || chainID == 0 { + return + } + if cw := node.GetContractWrite(); cw != nil && cw.Config != nil && cw.Config.ChainId == 0 { + cw.Config.ChainId = chainID + } + if cr := node.GetContractRead(); cr != nil && cr.Config != nil && cr.Config.ChainId == 0 { + cr.Config.ChainId = chainID + } + if et := node.GetEthTransfer(); et != nil && et.Config != nil && et.Config.ChainId == 0 { + et.Config.ChainId = chainID + } + // Loop node's inner runner is itself chain-aware — stamp it too, so a directly + // constructed (test/SDK) Loop whose runner has chain_id=0 still resolves. + if loop := node.GetLoop(); loop != nil { + if cw := loop.GetContractWrite(); cw != nil && cw.Config != nil && cw.Config.ChainId == 0 { + cw.Config.ChainId = chainID + } + if cr := loop.GetContractRead(); cr != nil && cr.Config != nil && cr.Config.ChainId == 0 { + cr.Config.ChainId = chainID + } + if et := loop.GetEthTransfer(); et != nil && et.Config != nil && et.Config.ChainId == 0 { + et.Config.ChainId = chainID + } + } +} + func getRealisticBlockNumberForChain(chainID int64) uint64 { switch chainID { case 1: // Ethereum mainnet @@ -194,7 +227,13 @@ func (n *Engine) runBlockTriggerImmediately(ctx context.Context, triggerConfig m func requireChainIDFromConfig(config map[string]interface{}) (int64, error) { raw, ok := config["chain_id"] if !ok { - return 0, fmt.Errorf("chain_id not specified in trigger config") + // Node config maps (ExtractNodeConfiguration) carry the camelCase + // `chainId`; trigger config maps carry snake_case `chain_id`. Accept + // either so per-node chains resolve on the isolated-run output path. + raw, ok = config["chainId"] + } + if !ok { + return 0, fmt.Errorf("chain_id not specified in trigger/node config") } var chainID int64 switch v := raw.(type) { @@ -1463,11 +1502,13 @@ func (n *Engine) executeMethodCallForSimulation(ctx context.Context, methodCall // Get method params as strings (ContractReadNode expects []string) methodParams := methodCall.GetMethodParams() - // Create a temporary contractRead node for execution (same as direct calls) + // Create a temporary contractRead node for execution (same as direct calls). + // chain_id is required (G5 strict) — carry the caller's chain onto the node. contractReadNode := &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ ContractAddress: contractAddressStr, ContractAbi: abiValues, + ChainId: chainID, MethodCalls: []*avsproto.ContractReadNode_MethodCall{ { MethodName: methodCall.GetMethodName(), @@ -2987,6 +3028,11 @@ func (n *Engine) RunNodeImmediatelyRPCWithContext(ctx context.Context, user *mod settings["chain_id"] = reqChainID inputVariables["settings"] = settings } + // A chain-aware node must carry an explicit chain (G5). For an isolated + // node run the request supplies the chain, so stamp it onto the node's + // own config when the node didn't specify one — the strict resolver + // (resolveSmartWalletForNode) only reads node.Config.chain_id. + stampNodeChainIfUnset(node, reqChainID) } // Get node type string from the node's Type field diff --git a/core/taskengine/run_node_immediately_missing_dependencies_test.go b/core/taskengine/run_node_immediately_missing_dependencies_test.go index 5949b835..8508b2a4 100644 --- a/core/taskengine/run_node_immediately_missing_dependencies_test.go +++ b/core/taskengine/run_node_immediately_missing_dependencies_test.go @@ -64,6 +64,7 @@ func TestRunNodeImmediately_ContractWrite_MissingNodeDependency(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", // Uniswap SwapRouter02 + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ @@ -150,6 +151,7 @@ func TestRunNodeImmediately_ContractWrite_MissingNodeDependency(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ @@ -199,6 +201,7 @@ func TestRunNodeImmediately_ContractWrite_MissingNodeDependency(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ diff --git a/core/taskengine/run_node_immediately_rpc_test.go b/core/taskengine/run_node_immediately_rpc_test.go index 2fe78cef..882b3942 100644 --- a/core/taskengine/run_node_immediately_rpc_test.go +++ b/core/taskengine/run_node_immediately_rpc_test.go @@ -113,6 +113,7 @@ func TestRunNodeImmediatelyRPC(t *testing.T) { TaskType: &avsproto.TaskNode_ContractWrite{ ContractWrite: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0xA0b86a33E6441d0be3c7bb50e65Eb42d5E0b2b4b", ContractAbi: contractAbi, MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ @@ -315,6 +316,7 @@ func TestRunNodeImmediatelyRPC(t *testing.T) { TaskType: &avsproto.TaskNode_EthTransfer{ EthTransfer: &avsproto.ETHTransferNode{ Config: &avsproto.ETHTransferNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) Destination: destinationAddr, Amount: amount, }, @@ -591,6 +593,7 @@ func exactInputSingleSwapNode(t *testing.T, runner string) *avsproto.TaskNode { TaskType: &avsproto.TaskNode_ContractWrite{ ContractWrite: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: overridesSwapRouter02, ContractAbi: []*structpb.Value{abi}, MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ diff --git a/core/taskengine/run_node_immediately_settings_test.go b/core/taskengine/run_node_immediately_settings_test.go index 8d7fcbc5..f02bcd3b 100644 --- a/core/taskengine/run_node_immediately_settings_test.go +++ b/core/taskengine/run_node_immediately_settings_test.go @@ -59,6 +59,7 @@ func TestContractWrite_WithSettingsAndUserAuth(t *testing.T) { // Contract configuration for USDC approve nodeConfig := map[string]interface{}{ "contractAddress": "0xA0b86a33E6441d0be3c7bb50e65Eb42d5E0b2b4b", // USDC Sepolia + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ @@ -144,6 +145,7 @@ func TestContractWrite_WithSettingsAndUserAuth(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": "0xA0b86a33E6441d0be3c7bb50e65Eb42d5E0b2b4b", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ @@ -197,6 +199,7 @@ func TestContractWrite_WithSettingsAndUserAuth(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": "0xA0b86a33E6441d0be3c7bb50e65Eb42d5E0b2b4b", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ @@ -245,6 +248,7 @@ func TestContractWrite_WithSettingsAndUserAuth(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": "0xA0b86a33E6441d0be3c7bb50e65Eb42d5E0b2b4b", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ diff --git a/core/taskengine/run_node_immediately_tuple_test.go b/core/taskengine/run_node_immediately_tuple_test.go index 3be0606c..be222471 100644 --- a/core/taskengine/run_node_immediately_tuple_test.go +++ b/core/taskengine/run_node_immediately_tuple_test.go @@ -95,6 +95,7 @@ func TestRunNodeImmediately_ContractWrite_TupleWithTemplates(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3", // Sepolia QuoterV2 + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ @@ -218,6 +219,7 @@ func TestRunNodeImmediately_ContractWrite_TupleWithTemplates(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ @@ -304,6 +306,7 @@ func TestRunNodeImmediately_ContractWrite_TupleWithTemplates(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": quoterV2ABI, "methodCalls": []interface{}{ map[string]interface{}{ @@ -363,6 +366,7 @@ func TestRunNodeImmediately_ContractWrite_TupleWithTemplates(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": quoterV2ABI, "methodCalls": []interface{}{ map[string]interface{}{ @@ -433,6 +437,7 @@ func TestRunNodeImmediately_ContractWrite_TupleWithTemplates(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", // USDC Sepolia + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{ diff --git a/core/taskengine/run_node_with_inputs_simulation_mode_test.go b/core/taskengine/run_node_with_inputs_simulation_mode_test.go index a8afc644..60469e26 100644 --- a/core/taskengine/run_node_with_inputs_simulation_mode_test.go +++ b/core/taskengine/run_node_with_inputs_simulation_mode_test.go @@ -143,6 +143,7 @@ func TestRunNodeWithInputsRespectsIsSimulatedFlag(t *testing.T) { TaskType: &avsproto.TaskNode_ContractWrite{ ContractWrite: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (strict) ContractAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", // USDC on Sepolia ContractAbi: contractAbi, MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ @@ -161,7 +162,8 @@ func TestRunNodeWithInputsRespectsIsSimulatedFlag(t *testing.T) { // Create protobuf request with the full TaskNode req := &avsproto.RunNodeWithInputsReq{ - Node: contractWriteNode, + ChainId: 11155111, // explicit chain (strict) + Node: contractWriteNode, } // Settings for the workflow @@ -307,6 +309,7 @@ func TestRunNodeWithInputsDefaultsToSimulation(t *testing.T) { TaskType: &avsproto.TaskNode_ContractWrite{ ContractWrite: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (strict) ContractAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", ContractAbi: contractAbi, MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ @@ -324,7 +327,8 @@ func TestRunNodeWithInputsDefaultsToSimulation(t *testing.T) { } req := &avsproto.RunNodeWithInputsReq{ - Node: contractWriteNode, + ChainId: 11155111, // explicit chain (strict) + Node: contractWriteNode, } settingsData := map[string]interface{}{ diff --git a/core/taskengine/simulation_state_test.go b/core/taskengine/simulation_state_test.go index 4a022ef1..fdbd012a 100644 --- a/core/taskengine/simulation_state_test.go +++ b/core/taskengine/simulation_state_test.go @@ -144,6 +144,7 @@ func TestSimulationStateMap_ContractWriteWithBalanceOverride(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": usdcContract, + "chainId": 11155111, // explicit chain (G5 strict) "contractAbi": usdcABI, "methodCalls": []interface{}{ map[string]interface{}{ @@ -196,6 +197,7 @@ func TestSimulationStateMap_ContractWriteWithBalanceOverride(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": usdcContract, + "chainId": 11155111, // explicit chain (G5 strict) "contractAbi": usdcABI, "methodCalls": []interface{}{ map[string]interface{}{ diff --git a/core/taskengine/stamp_node_chain_test.go b/core/taskengine/stamp_node_chain_test.go new file mode 100644 index 00000000..dcf0570c --- /dev/null +++ b/core/taskengine/stamp_node_chain_test.go @@ -0,0 +1,65 @@ +package taskengine + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" +) + +// TestStampNodeChainIfUnset covers the chain stamping used by RunNodeImmediately, +// including the Loop inner-runner path (regression: the runner was previously not +// stamped, so a Loop with a chain_id=0 contractWrite/Read/ethTransfer runner failed +// resolveSmartWalletForNode at execution). +func TestStampNodeChainIfUnset(t *testing.T) { + const chainID = int64(11155111) + + t.Run("top-level chain-aware nodes get stamped", func(t *testing.T) { + cw := &avsproto.TaskNode{TaskType: &avsproto.TaskNode_ContractWrite{ + ContractWrite: &avsproto.ContractWriteNode{Config: &avsproto.ContractWriteNode_Config{}}, + }} + stampNodeChainIfUnset(cw, chainID) + assert.Equal(t, chainID, cw.GetContractWrite().GetConfig().GetChainId()) + + cr := &avsproto.TaskNode{TaskType: &avsproto.TaskNode_ContractRead{ + ContractRead: &avsproto.ContractReadNode{Config: &avsproto.ContractReadNode_Config{}}, + }} + stampNodeChainIfUnset(cr, chainID) + assert.Equal(t, chainID, cr.GetContractRead().GetConfig().GetChainId()) + + et := &avsproto.TaskNode{TaskType: &avsproto.TaskNode_EthTransfer{ + EthTransfer: &avsproto.ETHTransferNode{Config: &avsproto.ETHTransferNode_Config{}}, + }} + stampNodeChainIfUnset(et, chainID) + assert.Equal(t, chainID, et.GetEthTransfer().GetConfig().GetChainId()) + }) + + t.Run("loop inner runner gets stamped", func(t *testing.T) { + loop := &avsproto.TaskNode{TaskType: &avsproto.TaskNode_Loop{ + Loop: &avsproto.LoopNode{Runner: &avsproto.LoopNode_ContractWrite{ + ContractWrite: &avsproto.ContractWriteNode{Config: &avsproto.ContractWriteNode_Config{}}, + }}, + }} + stampNodeChainIfUnset(loop, chainID) + assert.Equal(t, chainID, loop.GetLoop().GetContractWrite().GetConfig().GetChainId(), + "loop runner chain_id should be stamped") + }) + + t.Run("already-set chain_id is not overwritten", func(t *testing.T) { + const explicit = int64(8453) + cw := &avsproto.TaskNode{TaskType: &avsproto.TaskNode_ContractWrite{ + ContractWrite: &avsproto.ContractWriteNode{Config: &avsproto.ContractWriteNode_Config{ChainId: explicit}}, + }} + stampNodeChainIfUnset(cw, chainID) + assert.Equal(t, explicit, cw.GetContractWrite().GetConfig().GetChainId()) + }) + + t.Run("zero stamp chainID is a no-op", func(t *testing.T) { + cw := &avsproto.TaskNode{TaskType: &avsproto.TaskNode_ContractWrite{ + ContractWrite: &avsproto.ContractWriteNode{Config: &avsproto.ContractWriteNode_Config{}}, + }} + stampNodeChainIfUnset(cw, 0) + assert.Equal(t, int64(0), cw.GetContractWrite().GetConfig().GetChainId()) + }) +} diff --git a/core/taskengine/stats.go b/core/taskengine/stats.go index b5b1e54a..d76b4007 100644 --- a/core/taskengine/stats.go +++ b/core/taskengine/stats.go @@ -1,9 +1,7 @@ package taskengine import ( - "fmt" "strconv" - "strings" "github.com/AvaProtocol/EigenLayer-AVS/model" avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" @@ -12,58 +10,23 @@ import ( type StatService struct { db storage.Storage - // chainIDs is the list of chains the service should scan when counting - // per-wallet tasks. Empty falls back to the legacy single-chain key - // prefix for backward compat with older callers and tests. - chainIDs []int64 } -// NewStatService constructs a StatService that scans the chain_id=0 bucket. -// Useful for tests and tools that lack an Engine reference — chain_id=0 -// matches what the engine writes when SmartWallet.ChainID is unset. -// Production callers should use NewStatServiceWithChains. +// NewStatService constructs a StatService. Task storage is chain-agnostic +// (G5), so counting scans a single per-wallet prefix regardless of chain. func NewStatService(db storage.Storage) *StatService { - return &StatService{ - db: db, - chainIDs: []int64{0}, - } -} - -// NewStatServiceWithChains constructs a StatService that scans the given -// chains when counting per-wallet tasks. Pass the aggregator's knownChainIDs -// so chain-scoped storage is enumerated. -func NewStatServiceWithChains(db storage.Storage, chainIDs []int64) *StatService { - return &StatService{ - db: db, - chainIDs: chainIDs, - } + return &StatService{db: db} } func (svc *StatService) GetTaskCount(smartWalletAddress *model.SmartWallet) (*model.SmartWalletTaskStat, error) { stat := &model.SmartWalletTaskStat{} - // Build per-chain prefixes; fall back to legacy single-key prefix when - // no chain context was supplied (older callers / tests). - var items []*storage.KeyValueItem - if len(svc.chainIDs) == 0 { - prefix := SmartWalletTaskStoragePrefix(*smartWalletAddress.Owner, *smartWalletAddress.Address) - chunk, err := svc.db.GetByPrefix(prefix) - if err != nil { - return stat, err - } - items = chunk - } else { - for _, chainID := range svc.chainIDs { - prefix := []byte(fmt.Sprintf("u:%d:%s:%s", - chainID, - strings.ToLower(smartWalletAddress.Owner.Hex()), - strings.ToLower(smartWalletAddress.Address.Hex()))) - chunk, err := svc.db.GetByPrefix(prefix) - if err != nil { - return stat, err - } - items = append(items, chunk...) - } + // Task storage is chain-agnostic (G5): a single "u:{owner}:{wallet}" + // prefix matches every task for this wallet regardless of chain. + prefix := SmartWalletTaskStoragePrefix(*smartWalletAddress.Owner, *smartWalletAddress.Address) + items, err := svc.db.GetByPrefix(prefix) + if err != nil { + return stat, err } for _, item := range items { diff --git a/core/taskengine/stats_test.go b/core/taskengine/stats_test.go index 187dd98b..0ff8dc07 100644 --- a/core/taskengine/stats_test.go +++ b/core/taskengine/stats_test.go @@ -34,10 +34,9 @@ func TestTaskStatCount(t *testing.T) { tr1.MaxExecution = 1 n.CreateWorkflow(testutil.TestUser1(), tr1) - // CreateWorkflow stamps the task with the engine's defaultChainID - // (config.SmartWallet.ChainID), so the stat service must scan that - // chain — the chain-0 default in NewStatService would miss it. - statSvc := NewStatServiceWithChains(db, []int64{config.SmartWallet.ChainID}) + // Task storage is chain-agnostic (G5); the stat service scans a single + // per-wallet prefix. + statSvc := NewStatService(db) // Query statistics using the same smart wallet address used for task creation addr := common.HexToAddress(smartWalletAddress) owner := testutil.TestUser1().Address @@ -69,7 +68,7 @@ func TestTaskStatCountCompleted(t *testing.T) { }, } - db.Set(ChainTaskUserKey(task1.ChainId, task1), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Completed))) + db.Set(TaskUserKey(task1), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Completed))) statSvc := NewStatService(db) result, _ := statSvc.GetTaskCount(user1.ToSmartWallet()) @@ -122,10 +121,10 @@ func TestTaskStatCountAllStatus(t *testing.T) { }, } - db.Set(ChainTaskUserKey(task1.ChainId, task1), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Completed))) - db.Set(ChainTaskUserKey(task2.ChainId, task2), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Failed))) - db.Set(ChainTaskUserKey(task3.ChainId, task3), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Disabled))) - db.Set(ChainTaskUserKey(task4.ChainId, task4), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Enabled))) + db.Set(TaskUserKey(task1), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Completed))) + db.Set(TaskUserKey(task2), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Failed))) + db.Set(TaskUserKey(task3), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Disabled))) + db.Set(TaskUserKey(task4), []byte(fmt.Sprintf("%d", avsproto.TaskStatus_Enabled))) statSvc := NewStatService(db) result, _ := statSvc.GetTaskCount(user1.ToSmartWallet()) diff --git a/core/taskengine/summarizer.go b/core/taskengine/summarizer.go index 542478fd..89b76911 100644 --- a/core/taskengine/summarizer.go +++ b/core/taskengine/summarizer.go @@ -1,125 +1,65 @@ package taskengine import ( - "context" "fmt" "net/http" "strings" "time" - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "github.com/AvaProtocol/EigenLayer-AVS/core/config" ) -// Constants for example execution messages shown when no executions are available -const ( - // ExampleExecutionMessage is the base message for example executions - ExampleExecutionMessage = "On-chain transaction successfully completed" - // ExampleExecutionAnnotation is the annotation text explaining that this is an example - ExampleExecutionAnnotation = "This is an example. Actual execution details will appear when the workflow is simulated or triggered by a real event." -) - -// Summarizer defines an interface for generating human-readable summaries -// from the current VM execution context. Implementations must be resilient -// and return concise content suitable for email/IM notifications. -type Summarizer interface { - Summarize(ctx context.Context, vm *VM, currentStepName string) (Summary, error) -} - -var globalSummarizer Summarizer - -// SetSummarizer sets the global summarizer implementation used by ComposeSummarySmart. -// Pass nil to disable AI summarization and use deterministic fallback only. -func SetSummarizer(s Summarizer) { - globalSummarizer = s -} - -// globalFeeRates is the aggregator's fee config, set once at engine startup. -// Used by Runner/Fees population helpers in both Summarize() and ComposeSummary() -// so notifications surface the same fee numbers as EstimateFees() and the -// persisted Execution.Fee — single source of truth. -var globalFeeRates *config.FeeRatesConfig - -// SetFeeRates sets the global fee rates config used by Summary.Fees population. -// Engine startup wires this from config.FeeRates. nil falls back to defaults. -func SetFeeRates(rates *config.FeeRatesConfig) { - globalFeeRates = rates -} - -// globalPriceService is the chain price oracle used by Summary.Fees population -// to convert USD platform fees and value-fee legs into native-token amounts. -// Wired at engine startup via Engine.SetPriceService → SetPriceService. -var globalPriceService PriceService - -// SetPriceService sets the global price service used by Summary.Fees population. -// nil disables USD/native conversion — Total entries that need a price will be -// emitted with empty USD amounts (formatter renders "$?" placeholder). -func SetPriceService(svc PriceService) { - globalPriceService = svc -} - -// ComposeSummarySmart tries the configured summarizer (context-memory API) with strict timeout -// and falls back to deterministic ComposeSummary on any failure. The summary is automatically -// formatted for the appropriate channel (email or chat) by the REST API runner -// when used in notification nodes. -func ComposeSummarySmart(vm *VM, currentStepName string) Summary { - if globalSummarizer == nil { - if vm != nil && vm.logger != nil { - vm.logger.Info("ComposeSummarySmart: no global summarizer, using deterministic") - } - return ComposeSummary(vm, currentStepName) +// formatValueConcise renders an arbitrary value as a short, human-readable string for +// branch-condition logging (used by vm_runner_branch.go). Long strings/objects are truncated. +func formatValueConcise(v interface{}) string { + if v == nil { + return "null" } - if vm != nil && vm.logger != nil { - vm.logger.Info("ComposeSummarySmart: starting summarization via context-memory API") - } - - // Use a generous timeout to allow AI summarization to complete - // The context-memory API may take 10-20 seconds for complex workflows - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - s, err := globalSummarizer.Summarize(ctx, vm, currentStepName) - if err != nil { - if vm != nil && vm.logger != nil { - vm.logger.Info("Context-memory API not available: summarization failed, falling back to deterministic", "error", err) + switch val := v.(type) { + case string: + if len(val) > 50 { + return fmt.Sprintf("\"%s...\"", val[:47]) } - return ComposeSummary(vm, currentStepName) - } - - // Validate minimal fields; fall back if empty - if strings.TrimSpace(s.Subject) == "" || strings.TrimSpace(s.Body) == "" { - if vm != nil && vm.logger != nil { - vm.logger.Warn("ComposeSummarySmart: summary has empty fields, falling back to deterministic") + return fmt.Sprintf("\"%s\"", val) + case bool: + return fmt.Sprintf("%t", val) + case float64, int, int64: + return fmt.Sprintf("%v", val) + case map[string]interface{}: + return fmt.Sprintf("{object with %d keys}", len(val)) + case []interface{}: + return fmt.Sprintf("[array with %d items]", len(val)) + default: + s := fmt.Sprintf("%v", val) + if len(s) > 50 { + return s[:47] + "..." } - return ComposeSummary(vm, currentStepName) + return s } +} - if len(strings.TrimSpace(s.Body)) < 40 { - if vm != nil && vm.logger != nil { - vm.logger.Warn("ComposeSummarySmart: summary body too short, falling back to deterministic", "bodyLength", len(s.Body)) - } - return ComposeSummary(vm, currentStepName) - } +// globalSummarizer holds the Studio /api/notify payload builder (Path B). It is the only +// summarizer path: the gateway forwards raw execution data and Studio summarizes + sends. +var globalSummarizer *ContextMemorySummarizer - if vm != nil && vm.logger != nil { - vm.logger.Info("ComposeSummarySmart: summarization successful", "subject", s.Subject, "bodyLength", len(s.Body)) - } - return s +// SetSummarizer sets the global summarizer used to build /api/notify payloads. +// Pass nil to disable (notification nodes then forward their body unchanged). +func SetSummarizer(s *ContextMemorySummarizer) { + globalSummarizer = s } // NewContextMemorySummarizerFromAggregatorConfig builds the workflow summarizer from the // aggregator's notifications.summary config (api_endpoint + api_key). // -// Returns (nil, nil) when summarization is disabled — a valid "off" mode in which callers -// use the deterministic summarizer. Returns (nil, error) when summarization is ENABLED but +// Returns (nil, nil) when summarization is disabled — a valid "off" mode in which notification +// nodes forward their body unchanged. Returns (nil, error) when summarization is ENABLED but // misconfigured (unsupported provider, or empty endpoint/key) so the daemon fails fast at // startup instead of silently degrading. There is no hardcoded endpoint default: the origin // must come from config (avs-infra: ${SUMMARIZER_API_URL} / ${SUMMARIZER_API_KEY}). -func NewContextMemorySummarizerFromAggregatorConfig(c *config.Config) (Summarizer, error) { +func NewContextMemorySummarizerFromAggregatorConfig(c *config.Config) (*ContextMemorySummarizer, error) { if c == nil || !c.NotificationsSummary.Enabled { - return nil, nil // Not enabled — deterministic fallback is used. + return nil, nil // Not enabled — notification nodes forward their body unchanged. } if strings.ToLower(c.NotificationsSummary.Provider) != "context-memory" { // NOTE: "context-memory" is a legacy provider identifier — the endpoint now points at @@ -141,238 +81,3 @@ func NewContextMemorySummarizerFromAggregatorConfig(c *config.Config) (Summarize httpClient: &http.Client{Timeout: 30 * time.Second}, }, nil } - -// FormatForMessageChannels converts a Summary into a concise chat message -// suitable for messaging channels like Telegram or Discord. It prioritizes -// AI-generated structured fields (Trigger, Executions, Errors) when available. -// -// Note: Email is handled separately via SendGridDynamicData(), not this function. -// -// Thread-safety: This function reads from the Summary struct and VM object. -// The Summary struct should not be modified after creation. The VM object is -// accessed read-only (for transfer event extraction). If the Summary or VM -// may be accessed concurrently, ensure proper synchronization at the caller level. -// -// Fallback order: -// 1. AI-generated structured data from context-memory API -// 2. Transfer event detection (for simple transfer notifications without API) -// 3. Plain text body (legacy) -func FormatForMessageChannels(s Summary, channel string, vm *VM) string { - // Prioritize AI-generated structured format (from context-memory API) - // Check for new PRD format (transfers/workflow) or legacy format (executions/errors/trigger) - hasStructuredData := len(s.Transfers) > 0 || s.Workflow != nil || len(s.Executions) > 0 || len(s.Errors) > 0 || s.Trigger != "" - if hasStructuredData { - switch strings.ToLower(channel) { - case "telegram": - return formatTelegramFromStructured(s) - case "discord": - return formatDiscordFromStructured(s) - default: - return formatPlainTextFromStructured(s) - } - } - - // Fallback: Check for transfer event data (when API not available) - if vm != nil { - if transferData := ExtractTransferEventData(vm); transferData != nil { - return FormatTransferMessage(transferData) - } - } - - // Fallback: For single-node executions without meaningful data, show example message - if vm != nil && isSingleNodeImmediate(vm) { - return formatSingleNodeExampleMessage(vm, channel) - } - - // Legacy fallback: use plain text body - return formatChannelFromBody(s, channel) -} - -// truncateAddress truncates an Ethereum address to format "0x5d814...434f" -func truncateAddress(addr string) string { - if len(addr) < 12 { - return addr - } - return addr[:7] + "..." + addr[len(addr)-4:] -} - -// truncateTxHash truncates a transaction hash to format "0x1234...cdef" -func truncateTxHash(hash string) string { - if len(hash) < 14 { - return hash - } - return hash[:6] + "..." + hash[len(hash)-4:] -} - -// isValidTxHash checks if a string is a valid Ethereum transaction hash (0x + 64 hex chars). -// This filters out fake hashes like raw BigInt values from Tenderly simulation IDs. -func isValidTxHash(hash string) bool { - if len(hash) != 66 || !strings.HasPrefix(hash, "0x") { - return false - } - for _, c := range hash[2:] { - if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - return false - } - } - return true -} - -// isStepSimulated checks whether an execution step was run in simulation mode -// by reading the ExecutionContext attached to the step. -// Handles both naming conventions: "is_simulated" (snake_case from engine.go/node_utils.go) -// and "isSimulated" (camelCase from execution_providers.go). -func isStepSimulated(st *avsproto.Execution_Step) bool { - if st.GetExecutionContext() == nil { - return false - } - ctx, ok := st.GetExecutionContext().AsInterface().(map[string]interface{}) - if !ok { - return false - } - for _, key := range []string{"is_simulated", "isSimulated"} { - if sim, exists := ctx[key]; exists { - switch v := sim.(type) { - case bool: - if v { - return true - } - case string: - if strings.EqualFold(strings.TrimSpace(v), "true") { - return true - } - } - } - } - return false -} - -// buildTxExplorerURL constructs a full block explorer transaction URL from a Summary and tx hash. -// It resolves chainID from Workflow.ChainID first, then falls back to reverse-mapping the Network name. -// Returns "" if the txHash is not a valid Ethereum transaction hash or the chain is unknown. -func buildTxExplorerURL(s Summary, txHash string) string { - if !isValidTxHash(txHash) { - return "" - } - var chainID int64 - if s.Workflow != nil { - chainID = s.Workflow.ChainID - } - // Fallback: reverse-map the network display name to a chain ID - if chainID == 0 { - chainID = mapNameToChainID(s.Network) - } - baseURL := getBlockExplorerURL(chainID) - if baseURL == "" { - return "" - } - return baseURL + "/tx/" + txHash -} - -// getBlockExplorerURL returns the block explorer base URL for a chain ID. -// Returns an empty string for unknown chains — callers must not assume a default. -func getBlockExplorerURL(chainID int64) string { - switch chainID { - case 1: - return "https://etherscan.io" - case 11155111: - return "https://sepolia.etherscan.io" - case 137: - return "https://polygonscan.com" - case 42161: - return "https://arbiscan.io" - case 10: - return "https://optimistic.etherscan.io" - case 8453: - return "https://basescan.org" - case 84532: - return "https://sepolia.basescan.org" - case 56: - return "https://bscscan.com" - case 43114: - return "https://snowtrace.io" - default: - return "" - } -} - -// mapNameToChainID reverse-maps a display chain name to a numeric chain ID. -// Used as a fallback when chainID is not directly available (e.g. deterministic summarizer path). -func mapNameToChainID(name string) int64 { - switch strings.ToLower(strings.TrimSpace(name)) { - case "mainnet", "ethereum": - return 1 - case "sepolia": - return 11155111 - case "polygon": - return 137 - case "arbitrum", "arbitrum one": - return 42161 - case "optimism": - return 10 - case "base": - return 8453 - case "base-sepolia", "base sepolia": - return 84532 - case "bsc", "binance smart chain", "bnb chain": - return 56 - case "avalanche": - return 43114 - default: - return 0 - } -} - -// getChainDisplayName returns a display-friendly chain name from chain ID -// This provides a local fallback when the API doesn't return a chain name -func getChainDisplayName(chainID int64) string { - switch chainID { - case 1: - return "Ethereum" - case 11155111: - return "Sepolia" - case 137: - return "Polygon" - case 80001: - return "Polygon Mumbai" - case 42161: - return "Arbitrum One" - case 421614: - return "Arbitrum Sepolia" - case 10: - return "Optimism" - case 11155420: - return "Optimism Sepolia" - case 8453: - return "Base" - case 84532: - return "Base Sepolia" - case 56: - return "BNB Chain" - case 97: - return "BNB Testnet" - case 43114: - return "Avalanche" - case 43113: - return "Avalanche Fuji" - default: - return "" - } -} - -// formatSingleNodeExampleMessage creates an example message for single-node executions -// when no meaningful execution data (like transfer_monitor) is available. -// This helps users understand what the notification format will look like. -func formatSingleNodeExampleMessage(vm *VM, channel string) string { - workflowName := resolveWorkflowName(vm) - chainName := resolveChainName(vm) - - switch strings.ToLower(channel) { - case "telegram": - return formatTelegramExampleMessage(workflowName, chainName) - case "discord": - return formatDiscordExampleMessage(workflowName, chainName) - default: - return formatPlainTextExampleMessage(workflowName, chainName) - } -} diff --git a/core/taskengine/summarizer_build_steps_overview_test.go b/core/taskengine/summarizer_build_steps_overview_test.go deleted file mode 100644 index 394030e3..00000000 --- a/core/taskengine/summarizer_build_steps_overview_test.go +++ /dev/null @@ -1,1062 +0,0 @@ -package taskengine - -import ( - "fmt" - "strings" - "testing" - - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "github.com/stretchr/testify/require" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestBuildStepsOverview_SimulatedPrefix(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "uniswapv3_pool": map[string]interface{}{ - "token0": map[string]interface{}{ - "symbol": "USDC", - }, - "token1": map[string]interface{}{ - "symbol": "USDT", - }, - }, - "uniswapv3_contracts": map[string]interface{}{ - "swapRouter02": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - }, - } - vm.mu.Unlock() - - // Create a simulated exactInputSingle step - metadataValue, err := structpb.NewValue([]interface{}{ - map[string]interface{}{ - "value": map[string]interface{}{ - "amountOut": "1833869241732629", - }, - }, - }) - require.NoError(t, err) - - configValue, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "exactInputSingle", - }, - }, - }) - require.NoError(t, err) - - executionContextValue, err := structpb.NewValue(map[string]interface{}{ - "isSimulated": true, - "chainId": 11155111, // Sepolia - "provider": "simulation", - }) - require.NoError(t, err) - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "swap1", - Name: "swap1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Config: configValue, - Metadata: metadataValue, - ExecutionContext: executionContextValue, - }, - } - - result := buildStepsOverview(vm) - - // Check that "(Simulated)" appears as a prefix, not suffix - if !strings.Contains(result, "✓ (Simulated)") { - t.Fatalf("expected '(Simulated)' prefix, got: %q", result) - } - - // Check that "(Simulated)" is NOT at the end - if strings.HasSuffix(result, "(Simulated)") { - t.Fatalf("'(Simulated)' should be a prefix, not suffix. Got: %q", result) - } - - // Check that amount is formatted (should contain decimal point) - // USDC has 6 decimals, so 1833869241732629 / 10^6 = 1833869241.732629 - if !strings.Contains(result, ".") { - t.Fatalf("expected formatted amount with decimal point, got: %q", result) - } - // Should not contain the raw number - if strings.Contains(result, "1833869241732629") { - t.Fatalf("expected formatted amount, not raw number, got: %q", result) - } - - // Check that token symbol is included - if !strings.Contains(result, "USDC") { - t.Fatalf("expected token symbol USDC, got: %q", result) - } -} - -func TestBuildStepsOverview_ApproveWithTokenSymbol(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "uniswapv3_pool": map[string]interface{}{ - "token1": map[string]interface{}{ - "symbol": "USDT", - }, - }, - "uniswapv3_contracts": map[string]interface{}{ - "swapRouter02": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - }, - } - vm.mu.Unlock() - - // Create Approval event data - approvalData, err := structpb.NewValue(map[string]interface{}{ - "Approval": map[string]interface{}{ - "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "spender": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - "value": "1000000", // 1 USDT with 6 decimals - }, - }) - require.NoError(t, err) - - configValue, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", // Token address - "isSimulated": false, - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "approve", - }, - }, - }) - require.NoError(t, err) - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "approve1", - Name: "approve1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Config: configValue, - OutputData: &avsproto.Execution_Step_ContractWrite{ - ContractWrite: &avsproto.ContractWriteNode_Output{ - Data: approvalData, - }, - }, - }, - } - - result := buildStepsOverview(vm) - - // Check that token symbol is included - if !strings.Contains(result, "USDT") { - t.Fatalf("expected token symbol USDT, got: %q", result) - } - - // Check that amount is formatted - if !strings.Contains(result, "1.0") || !strings.Contains(result, "1.0000") { - t.Fatalf("expected formatted amount (1.0 or 1.0000), got: %q", result) - } - - // Check that spender address is included (Uniswap V3 router) - if !strings.Contains(result, "Uniswap V3 router") { - t.Fatalf("expected 'Uniswap V3 router' in description, got: %q", result) - } - - // Check that template variables are NOT present - if strings.Contains(result, "{{") { - t.Fatalf("expected no template variables, got: %q", result) - } -} - -func TestBuildStepsOverview_RealAndSimulatedTransactions(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "uniswapv3_pool": map[string]interface{}{ - "token0": map[string]interface{}{ - "symbol": "USDC", - }, - "token1": map[string]interface{}{ - "symbol": "USDT", - }, - }, - "uniswapv3_contracts": map[string]interface{}{ - "swapRouter02": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - }, - } - vm.mu.Unlock() - - // Create Approval event data - approvalData, err := structpb.NewValue(map[string]interface{}{ - "Approval": map[string]interface{}{ - "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "spender": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - "value": "1000000", // 1 USDT with 6 decimals - }, - }) - require.NoError(t, err) - - approveConfig, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "approve", - }, - }, - }) - require.NoError(t, err) - - approveExecutionContext, err := structpb.NewValue(map[string]interface{}{ - "isSimulated": false, // Real transaction - "chainId": 11155111, // Sepolia - "provider": "bundler", - }) - require.NoError(t, err) - - // Create swap metadata - swapMetadata, err := structpb.NewValue([]interface{}{ - map[string]interface{}{ - "value": map[string]interface{}{ - "amountOut": "1833869241732629", - }, - }, - }) - require.NoError(t, err) - - swapConfig, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "exactInputSingle", - }, - }, - }) - require.NoError(t, err) - - swapExecutionContext, err := structpb.NewValue(map[string]interface{}{ - "isSimulated": true, // Simulated transaction - "chainId": 11155111, // Sepolia - "provider": "simulation", - }) - require.NoError(t, err) - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "approve1", - Name: "approve1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Config: approveConfig, - ExecutionContext: approveExecutionContext, - OutputData: &avsproto.Execution_Step_ContractWrite{ - ContractWrite: &avsproto.ContractWriteNode_Output{ - Data: approvalData, - }, - }, - }, - { - Id: "swap1", - Name: "swap1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Config: swapConfig, - Metadata: swapMetadata, - ExecutionContext: swapExecutionContext, - }, - } - - result := buildStepsOverview(vm) - - // Split by newlines to check each step - lines := strings.Split(result, "\n") - if len(lines) < 2 { - t.Fatalf("expected at least 2 steps, got %d lines: %q", len(lines), result) - } - - // Check approve step (should NOT have Simulated prefix) - approveLine := "" - for _, line := range lines { - if strings.Contains(line, "Approved") { - approveLine = line - break - } - } - if approveLine == "" { - t.Fatalf("expected approve step, got: %q", result) - } - if strings.Contains(approveLine, "(Simulated)") { - t.Fatalf("approve step should NOT have (Simulated) prefix, got: %q", approveLine) - } - if !strings.Contains(approveLine, "USDT") { - t.Fatalf("expected USDT in approve step, got: %q", approveLine) - } - - // Check swap step (should HAVE Simulated prefix) - swapLine := "" - for _, line := range lines { - if strings.Contains(line, "Swapped") { - swapLine = line - break - } - } - if swapLine == "" { - t.Fatalf("expected swap step, got: %q", result) - } - if !strings.Contains(swapLine, "(Simulated)") { - t.Fatalf("swap step should have (Simulated) prefix, got: %q", swapLine) - } - // Check that (Simulated) is at the beginning (after checkmark) - if !strings.Contains(swapLine, "✓ (Simulated)") { - t.Fatalf("expected '(Simulated)' prefix after checkmark, got: %q", swapLine) - } - // Check that amount is formatted (should contain decimal point) - // USDC has 6 decimals, so 1833869241732629 / 10^6 = 1833869241.732629 - if !strings.Contains(swapLine, ".") { - t.Fatalf("expected formatted amount with decimal point, got: %q", swapLine) - } - // Should not contain the raw number - if strings.Contains(swapLine, "1833869241732629") { - t.Fatalf("expected formatted amount, not raw number, got: %q", swapLine) - } - if !strings.Contains(swapLine, "USDC") { - t.Fatalf("expected USDC in swap step, got: %q", swapLine) - } -} - -func TestBuildBranchAndSkippedSummary_IncludesStepsOverview(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "uniswapv3_pool": map[string]interface{}{ - "token0": map[string]interface{}{ - "symbol": "USDC", - }, - "token1": map[string]interface{}{ - "symbol": "USDT", - }, - }, - "uniswapv3_contracts": map[string]interface{}{ - "swapRouter02": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - }, - } - vm.mu.Unlock() - - // Create swap metadata - swapMetadata, err := structpb.NewValue([]interface{}{ - map[string]interface{}{ - "value": map[string]interface{}{ - "amountOut": "1833869241732629", - }, - }, - }) - require.NoError(t, err) - - swapConfig, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "exactInputSingle", - }, - }, - }) - require.NoError(t, err) - - swapExecutionContext, err := structpb.NewValue(map[string]interface{}{ - "isSimulated": true, - "chainId": 11155111, // Sepolia - "provider": "simulation", - }) - require.NoError(t, err) - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "swap1", - Name: "swap1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Config: swapConfig, - Metadata: swapMetadata, - ExecutionContext: swapExecutionContext, - }, - } - - // Call BuildBranchAndSkippedSummary (which calls buildStepsOverview internally) - text, html := BuildBranchAndSkippedSummary(vm) - - // Check that the HTML contains the "What Executed Successfully" section - if !strings.Contains(html, "What Executed Successfully") { - t.Fatalf("expected 'What Executed Successfully' section in HTML, got: %q", html) - } - - // Check that (Simulated) appears as a prefix in the HTML - if !strings.Contains(html, "(Simulated) Swapped") { - t.Fatalf("expected '(Simulated)' prefix before 'Swapped' in HTML, got: %q", html) - } - - // Check that amount is formatted (contains decimal point) - if !strings.Contains(html, ".") { - t.Fatalf("expected formatted amount with decimal point in HTML, got: %q", html) - } - - // Check that token symbol is included - if !strings.Contains(html, "USDC") { - t.Fatalf("expected USDC token symbol in HTML, got: %q", html) - } - - // Check plain text version too - if !strings.Contains(text, "What Executed Successfully") { - t.Fatalf("expected 'What Executed Successfully' section in text, got: %q", text) - } - - if !strings.Contains(text, "✓ (Simulated)") { - t.Fatalf("expected '(Simulated)' prefix in text, got: %q", text) - } -} - -func TestBuildBranchAndSkippedSummary_BundlerFailure(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "name": "Test Stoploss", - "uniswapv3_pool": map[string]interface{}{ - "token0": map[string]interface{}{ - "symbol": "USDC", - }, - "token1": map[string]interface{}{ - "symbol": "USDT", - }, - }, - "uniswapv3_contracts": map[string]interface{}{ - "swapRouter02": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - }, - } - // Set up TaskNodes to represent a workflow with 7 total steps (1 trigger + 6 nodes) - // This ensures getTotalWorkflowSteps returns 7 - vm.TaskNodes["balance1"] = &avsproto.TaskNode{Name: "balance1"} - vm.TaskNodes["eventTrigger"] = &avsproto.TaskNode{Name: "eventTrigger"} - vm.TaskNodes["get_quote"] = &avsproto.TaskNode{Name: "get_quote"} - vm.TaskNodes["approve1"] = &avsproto.TaskNode{Name: "approve1"} - vm.TaskNodes["swap1"] = &avsproto.TaskNode{Name: "swap1"} - vm.TaskNodes["contractWrite1"] = &avsproto.TaskNode{Name: "contractWrite1"} - vm.mu.Unlock() - - // Create successful steps before the failure - // Step 1: Successful balance check (not a contract write, so won't appear in "What Executed Successfully") - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "balance1", - Name: "balance1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_BALANCE.String(), - }, - { - Id: "eventTrigger", - Name: "eventTrigger", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_REST_API.String(), // Using REST_API as a placeholder for trigger - }, - { - Id: "get_quote", - Name: "get_quote", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_READ.String(), - }, - { - Id: "approve1", - Name: "approve1", - Success: false, // Failed step - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Error: "Bundler service unavailable: dial tcp [::1]:4437: connect: connection refused", - Config: func() *structpb.Value { - configValue, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", - "isSimulated": false, - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "approve", - }, - }, - }) - require.NoError(t, err) - return configValue - }(), - }, - // Additional failed step (contractWrite1) - matches screenshot showing 2 failed nodes - { - Id: "contractWrite1", - Name: "contractWrite1", - Success: false, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Error: "ERC20: transfer amount exceeds allowance", - }, - // Note: swap1 is not included as it may have been skipped or not executed - // The screenshot shows only 2 failed nodes: approve1 and contractWrite1 - } - - // Call BuildBranchAndSkippedSummary - text, html := BuildBranchAndSkippedSummary(vm) - - // Calculate expected total from ExecutionLogs (source of truth) - executedSteps := len(vm.ExecutionLogs) - // Count skipped nodes (nodes in TaskNodes but not in ExecutionLogs) - skippedCount := 0 - vm.mu.Lock() - for nodeID, n := range vm.TaskNodes { - if n == nil || strings.Contains(nodeID, ".") { - continue - } - found := false - for _, st := range vm.ExecutionLogs { - if st.GetName() == n.Name { - found = true - break - } - } - if !found { - skippedCount++ - } - } - vm.mu.Unlock() - expectedTotal := executedSteps - if skippedCount > 0 { - expectedTotal = executedSteps + skippedCount - } - - // Verify the summary line shows failure count - expectedSummary := fmt.Sprintf("2 out of %d nodes failed during execution", expectedTotal) - if !strings.Contains(text, expectedSummary) { - t.Fatalf("expected '%s' in text, got: %q", expectedSummary, text) - } - if strings.Contains(text, "All nodes were executed successfully") { - t.Fatalf("should NOT contain 'All nodes were executed successfully' when there are failures, got: %q", text) - } - - // Verify HTML also shows failure count - if !strings.Contains(html, expectedSummary) { - t.Fatalf("expected '%s' in HTML, got: %q", expectedSummary, html) - } - - // Verify "What Went Wrong" section appears - if !strings.Contains(text, "What Went Wrong") { - t.Fatalf("expected 'What Went Wrong' section in text, got: %q", text) - } - if !strings.Contains(html, "What Went Wrong") { - t.Fatalf("expected 'What Went Wrong' section in HTML, got: %q", html) - } - - // Verify failed step appears in "What Went Wrong" - if !strings.Contains(text, "✗ approve1") { - t.Fatalf("expected failed step 'approve1' in 'What Went Wrong' section, got: %q", text) - } - if !strings.Contains(html, "approve1") { - t.Fatalf("expected failed step 'approve1' in HTML, got: %q", html) - } - - // Verify error message about bundler is included - if !strings.Contains(text, "Bundler") || !strings.Contains(text, "unavailable") { - t.Fatalf("expected bundler error message in text, got: %q", text) - } - if !strings.Contains(html, "Bundler") || !strings.Contains(html, "unavailable") { - t.Fatalf("expected bundler error message in HTML, got: %q", html) - } - - // Verify "No on-chain transactions executed" message (without "contract writes failed") - if !strings.Contains(text, "No on-chain transactions executed") { - t.Fatalf("expected 'No on-chain transactions executed' message, got: %q", text) - } - if strings.Contains(text, "contract writes failed") { - t.Fatalf("should NOT contain 'contract writes failed' message (redundant), got: %q", text) - } - - // Verify "What Executed Successfully" section is NOT present (no successful contract writes) - if strings.Contains(text, "What Executed Successfully") { - t.Fatalf("should NOT have 'What Executed Successfully' section when no successful contract writes, got: %q", text) - } - if strings.Contains(html, "What Executed Successfully") { - t.Fatalf("should NOT have 'What Executed Successfully' section in HTML when no successful contract writes, got: %q", html) - } -} - -func TestBuildBranchAndSkippedSummary_PartialFailureWithSuccessfulWrites(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "name": "Test Workflow", - "uniswapv3_pool": map[string]interface{}{ - "token0": map[string]interface{}{ - "symbol": "USDC", - }, - "token1": map[string]interface{}{ - "symbol": "USDT", - }, - }, - "uniswapv3_contracts": map[string]interface{}{ - "swapRouter02": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - }, - } - // Set up TaskNodes to represent a workflow with 3 total steps (1 trigger + 2 nodes) - vm.TaskNodes["approve1"] = &avsproto.TaskNode{Name: "approve1"} - vm.TaskNodes["swap1"] = &avsproto.TaskNode{Name: "swap1"} - vm.mu.Unlock() - - // Create Approval event data for successful approve - approvalData, err := structpb.NewValue(map[string]interface{}{ - "Approval": map[string]interface{}{ - "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "spender": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - "value": "1000000", // 1 USDT with 6 decimals - }, - }) - require.NoError(t, err) - - approveConfig, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", - "isSimulated": false, - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "approve", - }, - }, - }) - require.NoError(t, err) - - // Create swap metadata for failed swap - swapConfig, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - "isSimulated": false, - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "exactInputSingle", - }, - }, - }) - require.NoError(t, err) - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "approve1", - Name: "approve1", - Success: true, // Successful approve - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Config: approveConfig, - OutputData: &avsproto.Execution_Step_ContractWrite{ - ContractWrite: &avsproto.ContractWriteNode_Output{ - Data: approvalData, - }, - }, - }, - { - Id: "swap1", - Name: "swap1", - Success: false, // Failed swap - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Error: "Bundler service unavailable: dial tcp [::1]:4437: connect: connection refused", - Config: swapConfig, - }, - } - - // Call BuildBranchAndSkippedSummary - text, html := BuildBranchAndSkippedSummary(vm) - - // Calculate expected total from ExecutionLogs (source of truth) - executedSteps := len(vm.ExecutionLogs) - // Count skipped nodes (nodes in TaskNodes but not in ExecutionLogs) - skippedCount := 0 - vm.mu.Lock() - for nodeID, n := range vm.TaskNodes { - if n == nil || strings.Contains(nodeID, ".") { - continue - } - found := false - for _, st := range vm.ExecutionLogs { - if st.GetName() == n.Name { - found = true - break - } - } - if !found { - skippedCount++ - } - } - vm.mu.Unlock() - expectedTotal := executedSteps - if skippedCount > 0 { - expectedTotal = executedSteps + skippedCount - } - - // Verify both sections appear - if !strings.Contains(text, "What Executed Successfully") { - t.Fatalf("expected 'What Executed Successfully' section when there are successful writes, got: %q", text) - } - if !strings.Contains(text, "What Went Wrong") { - t.Fatalf("expected 'What Went Wrong' section when there are failures, got: %q", text) - } - - // Verify successful approve appears in "What Executed Successfully" - if !strings.Contains(text, "✓ Approved") { - t.Fatalf("expected successful approve in 'What Executed Successfully', got: %q", text) - } - - // Verify failed swap appears in "What Went Wrong" - if !strings.Contains(text, "✗ swap1") { - t.Fatalf("expected failed swap in 'What Went Wrong', got: %q", text) - } - - // Verify summary line shows failure count - expectedSummary := fmt.Sprintf("1 out of %d nodes failed during execution", expectedTotal) - if !strings.Contains(text, expectedSummary) { - t.Fatalf("expected '%s' in text, got: %q", expectedSummary, text) - } - - // Verify on-chain transaction count includes successful writes - if !strings.Contains(text, "Executed 1 on-chain transaction") { - t.Fatalf("expected 'Executed 1 on-chain transaction' message, got: %q", text) - } - - // Verify HTML also has both sections - if !strings.Contains(html, "What Executed Successfully") { - t.Fatalf("expected 'What Executed Successfully' section in HTML, got: %q", html) - } - if !strings.Contains(html, "What Went Wrong") { - t.Fatalf("expected 'What Went Wrong' section in HTML, got: %q", html) - } -} - -func TestBuildBranchAndSkippedSummary_WithSkippedNodes(t *testing.T) { - // Test case matching the scenario from the user's screenshot: - // - Top message: "executed 4 out of 7 total steps" - // - Summary: should say "2 out of 7 nodes failed" (not "2 out of 8") - // - 3 skipped nodes: contractRead1, approve1, contractWrite1 - // - 2 failed nodes: balance1, code1 - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "name": "Test Stoploss", - } - // Set up TaskNodes to include nodes that will be skipped - // Note: vm.TaskNodes includes nodes from ALL branch paths, but only one path executes - // For this test: 4 executed nodes (eventTrigger, balance1, code1, branch1) + 3 skipped nodes = 7 total - vm.TaskNodes["eventTrigger"] = &avsproto.TaskNode{Name: "eventTrigger"} - vm.TaskNodes["balance1"] = &avsproto.TaskNode{Name: "balance1"} - vm.TaskNodes["code1"] = &avsproto.TaskNode{Name: "code1"} - vm.TaskNodes["branch1"] = &avsproto.TaskNode{Name: "branch1"} - // These nodes are on the skipped branch path (3 skipped nodes) - vm.TaskNodes["contractRead1"] = &avsproto.TaskNode{Name: "contractRead1"} - vm.TaskNodes["approve1"] = &avsproto.TaskNode{Name: "approve1"} - vm.TaskNodes["contractWrite1"] = &avsproto.TaskNode{Name: "contractWrite1"} - // Note: email1 is not in TaskNodes because it's not part of the workflow definition - // in this scenario (it might be executed but not tracked as a workflow node) - vm.mu.Unlock() - - // Create execution logs for executed steps - // Based on the scenario: 4 executed steps (eventTrigger, balance1, code1, branch1) - // Note: email1 is executed but might not be counted in the "executed steps" for the top message - // For this test, we'll match the scenario: 4 executed + 3 skipped = 7 total - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "eventTrigger", - Name: "eventTrigger", - Success: true, - Type: "eventTrigger", - }, - { - Id: "balance1", - Name: "balance1", - Success: false, - Type: avsproto.NodeType_NODE_TYPE_BALANCE.String(), - Error: "failed to fetch balances: moralis API returned status 502: error code: 500", - }, - { - Id: "code1", - Name: "code1", - Success: false, - Type: avsproto.NodeType_NODE_TYPE_CUSTOM_CODE.String(), - Error: "failed to execute script: TypeError: Cannot read property 'find' of undefined or null at :4:31(3)", - }, - { - Id: "branch1", - Name: "branch1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_BRANCH.String(), - }, - // Note: email1 is not included in ExecutionLogs to match the scenario - // where "executed 4 out of 7 total steps" (4 executed + 3 skipped = 7) - } - - // Call BuildBranchAndSkippedSummary - text, html := BuildBranchAndSkippedSummary(vm) - - // Verify the summary line shows correct count: 2 out of 7 nodes failed - // (4 executed + 3 skipped = 7 total, not 8) - if !strings.Contains(text, "2 out of 7 nodes failed during execution") { - t.Fatalf("expected '2 out of 7 nodes failed during execution' in text, got: %q", text) - } - if strings.Contains(text, "2 out of 8 nodes failed") { - t.Fatalf("should NOT contain '2 out of 8 nodes failed' (should be 7 total), got: %q", text) - } - - // Verify HTML also shows correct count - if !strings.Contains(html, "2 out of 7 nodes failed during execution") { - t.Fatalf("expected '2 out of 7 nodes failed during execution' in HTML, got: %q", html) - } - if strings.Contains(html, "2 out of 8 nodes failed") { - t.Fatalf("should NOT contain '2 out of 8 nodes failed' in HTML (should be 7 total), got: %q", html) - } - - // Verify "What Went Wrong" section appears - if !strings.Contains(text, "What Went Wrong") { - t.Fatalf("expected 'What Went Wrong' section in text, got: %q", text) - } - - // Verify failed steps appear in "What Went Wrong" - if !strings.Contains(text, "✗ balance1") { - t.Fatalf("expected failed step 'balance1' in 'What Went Wrong' section, got: %q", text) - } - if !strings.Contains(text, "✗ code1") { - t.Fatalf("expected failed step 'code1' in 'What Went Wrong' section, got: %q", text) - } - - // Verify skipped nodes section appears - if !strings.Contains(text, "The below nodes were skipped due to branching conditions") { - t.Fatalf("expected skipped nodes section in text, got: %q", text) - } - - // Verify skipped nodes are listed - if !strings.Contains(text, "- contractRead1") { - t.Fatalf("expected skipped node 'contractRead1' in text, got: %q", text) - } - if !strings.Contains(text, "- approve1") { - t.Fatalf("expected skipped node 'approve1' in text, got: %q", text) - } - if !strings.Contains(text, "- contractWrite1") { - t.Fatalf("expected skipped node 'contractWrite1' in text, got: %q", text) - } - - // Verify "No on-chain transactions executed" message - if !strings.Contains(text, "No on-chain transactions executed") { - t.Fatalf("expected 'No on-chain transactions executed' message, got: %q", text) - } -} - -// TestBuildStepsOverview_ApproveWithLowercaseKey tests the fix for extracting spender -// from approve outputData when it uses lowercase "approve" key instead of "Approval" event key. -// This test uses the real response structure from the terminal output. -func TestBuildStepsOverview_ApproveWithLowercaseKey(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "uniswapv3_pool": map[string]interface{}{ - "tokens": map[string]interface{}{ - "input": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", // USDC - }, - "token1": map[string]interface{}{ - "symbol": "USDC", - }, - }, - "uniswapv3_contracts": map[string]interface{}{ - "swapRouter02": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", - }, - } - vm.mu.Unlock() - - // Create approve outputData with lowercase "approve" key (matching real response structure) - // This is the actual structure from the terminal output: - // "outputData": { - // "approve": { - // "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - // "spender": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", - // "value": "20990000" - // } - // } - approveOutputData, err := structpb.NewValue(map[string]interface{}{ - "approve": map[string]interface{}{ - "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "spender": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", - "value": "20990000", // 20.99 USDC (6 decimals) - }, - }) - require.NoError(t, err) - - configValue, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", // USDC token address - "isSimulated": false, - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "approve", - }, - }, - }) - require.NoError(t, err) - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "approve1", - Name: "approve1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Config: configValue, - OutputData: &avsproto.Execution_Step_ContractWrite{ - ContractWrite: &avsproto.ContractWriteNode_Output{ - Data: approveOutputData, - }, - }, - }, - } - - result := buildStepsOverview(vm) - - // Verify that spender is correctly extracted and included in the description - // Before the fix, this would show "Approved token to " (missing spender) - // After the fix, it should show the spender address (shortened format) - - // Verify that the description is complete (not truncated) - // Should NOT end with "Approved token to " (missing spender) - if strings.HasSuffix(strings.TrimSpace(result), "Approved token to") { - t.Fatalf("description should include spender address, got incomplete description: %q", result) - } - - // Verify that the spender address appears in shortened format - // The shortHexAddr function formats addresses as "0x...last4chars" - // Check for either the shortened format or the contract name + shortened format - if !strings.Contains(result, "0x3bFA") && !strings.Contains(result, "0x3bfa") { - // If not found, check if it's using the contract name instead - if !strings.Contains(result, "Uniswap V3 router") { - t.Fatalf("expected spender address (shortened) or contract name in description, got: %q", result) - } - } - - // Verify that the description contains "to" followed by something (the spender) - // This ensures the spender is present - if !strings.Contains(result, " to ") { - t.Fatalf("expected ' to ' in description (indicating spender is present), got: %q", result) - } - - // Verify that value is included (20.99 USDC) - // The value "20990000" with 6 decimals should format to "20.99" - if !strings.Contains(result, "20.99") && !strings.Contains(result, "20.9900") { - t.Fatalf("expected formatted amount (20.99) in description, got: %q", result) - } - - // Verify that token symbol is included if available - if strings.Contains(result, "USDC") { - // If USDC is present, verify the full description format - if !strings.Contains(result, "Approved") { - t.Fatalf("expected 'Approved' in description, got: %q", result) - } - } - - // Verify checkmark prefix - if !strings.Contains(result, "✓") { - t.Fatalf("expected checkmark prefix, got: %q", result) - } - - // Verify the description is not empty or just whitespace - if strings.TrimSpace(result) == "" { - t.Fatalf("expected non-empty description, got empty string") - } - - // Log the result for debugging - t.Logf("Generated description: %q", result) - - // Verify the exact format matches expected output - // Should be: "✓ Approved 20.9900 USDC to Uniswap V3 router 0x3bFA...e48E" - expectedParts := []string{ - "✓ Approved", - "20.9900", - "USDC", - "to", - "Uniswap V3 router", - "0x3bFA", - } - for _, part := range expectedParts { - if !strings.Contains(result, part) { - t.Fatalf("expected description to contain %q, got: %q", part, result) - } - } -} - -// TestBuildStepsOverview_ApproveWithBothKeys verifies that "Approval" event takes priority -// over "approve" output data when both are present. -func TestBuildStepsOverview_ApproveWithBothKeys(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "uniswapv3_pool": map[string]interface{}{ - "token1": map[string]interface{}{ - "symbol": "USDT", - }, - }, - "uniswapv3_contracts": map[string]interface{}{ - "swapRouter02": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", - }, - } - vm.mu.Unlock() - - // Create outputData with BOTH "Approval" event and "approve" output data - // The "Approval" event should take priority - approveOutputData, err := structpb.NewValue(map[string]interface{}{ - "Approval": map[string]interface{}{ - "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "spender": "0x3bfa4769fb09eefc5a80d6e87c3b9c650f7ae48e", // From Approval event (should be used) - "value": "1000000", // 1 USDT - }, - "approve": map[string]interface{}{ - "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "spender": "0x0000000000000000000000000000000000000000", // Different spender (should be ignored) - "value": "999999", // Different value (should be ignored) - }, - }) - require.NoError(t, err) - - configValue, err := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", - "isSimulated": false, - "methodCalls": []interface{}{ - map[string]interface{}{ - "methodName": "approve", - }, - }, - }) - require.NoError(t, err) - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "approve1", - Name: "approve1", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Config: configValue, - OutputData: &avsproto.Execution_Step_ContractWrite{ - ContractWrite: &avsproto.ContractWriteNode_Output{ - Data: approveOutputData, - }, - }, - }, - } - - result := buildStepsOverview(vm) - - // Verify that the "Approval" event data is used (not the "approve" output data) - // Should contain "1.0" or "1.0000" (from Approval event value: 1000000) - if !strings.Contains(result, "1.0") && !strings.Contains(result, "1.0000") { - t.Fatalf("expected value from Approval event (1.0), got: %q", result) - } - - // Should NOT contain the value from "approve" output data (999999) - if strings.Contains(result, "999999") { - t.Fatalf("should use Approval event value, not approve output value, got: %q", result) - } - - // Should contain the spender from Approval event (Uniswap V3 router) - if !strings.Contains(result, "Uniswap V3 router") { - t.Fatalf("expected spender from Approval event, got: %q", result) - } - - // Should NOT contain the zero address from "approve" output data - if strings.Contains(result, "0x0000") || strings.Contains(result, "0x0") { - t.Fatalf("should use Approval event spender, not approve output spender, got: %q", result) - } - - t.Logf("Generated description (both keys present, Approval takes priority): %q", result) -} diff --git a/core/taskengine/summarizer_context_memory.go b/core/taskengine/summarizer_context_memory.go index c8e932ce..5ab1e1f1 100644 --- a/core/taskengine/summarizer_context_memory.go +++ b/core/taskengine/summarizer_context_memory.go @@ -1,11 +1,8 @@ package taskengine import ( - "bytes" - "context" "encoding/json" "fmt" - "io" "net/http" "strings" "time" @@ -15,8 +12,9 @@ import ( "github.com/ethereum/go-ethereum/common" ) -// ContextMemorySummarizer implements Summarizer by posting to the workflow-summary -// endpoint (Studio's /api/summarize; the standalone context-memory service is deprecated). +// ContextMemorySummarizer builds the raw execution-data payload the gateway forwards to +// Studio's /api/notify (Path B). It no longer posts to /api/summarize itself — Studio +// summarizes and distributes; the gateway is a pass-through. type ContextMemorySummarizer struct { baseURL string authToken string @@ -28,7 +26,7 @@ type ContextMemorySummarizer struct { // client appends the path itself; including it would double the path. No default is // applied: the origin must be supplied explicitly (production wiring fails fast at // startup when it is missing, see NewContextMemorySummarizerFromAggregatorConfig). -func NewContextMemorySummarizer(baseURL, authToken string) Summarizer { +func NewContextMemorySummarizer(baseURL, authToken string) *ContextMemorySummarizer { return &ContextMemorySummarizer{ baseURL: baseURL, authToken: authToken, @@ -84,291 +82,6 @@ type contextMemoryEdgeDef struct { Target string `json:"target"` } -// contextMemoryTransferInfo matches the API response structure for transfers -type contextMemoryTransferInfo struct { - StepName string `json:"stepName"` - Type string `json:"type"` - From string `json:"from"` - To string `json:"to"` - RawAmount string `json:"rawAmount"` - Amount string `json:"amount"` - Symbol string `json:"symbol"` - Decimals int `json:"decimals"` - TxHash string `json:"txHash"` - IsSimulated bool `json:"isSimulated"` -} - -// contextMemoryBalanceInfo matches the API response structure for balances -type contextMemoryBalanceInfo struct { - StepName string `json:"stepName"` - TokenAddress string `json:"tokenAddress"` - Symbol string `json:"symbol"` - Name string `json:"name"` - Balance string `json:"balance"` - BalanceFormatted string `json:"balanceFormatted"` - Decimals int `json:"decimals"` -} - -// contextMemoryWorkflowInfo matches the API response structure for workflow metadata -type contextMemoryWorkflowInfo struct { - Name string `json:"name"` - Chain string `json:"chain"` - ChainID int64 `json:"chainId"` - IsSimulation bool `json:"isSimulation"` - RunNumber *int64 `json:"runNumber"` -} - -// contextMemoryExecutionEntry matches the API response structure for execution entries. -// Each entry has a description and an optional txHash for on-chain transactions. -type contextMemoryExecutionEntry struct { - Description string `json:"description"` - TxHash string `json:"txHash,omitempty"` -} - -// contextMemorySummarizeBody contains the structured workflow execution summary -// The aggregator is responsible for rendering this into email HTML or Telegram format -type contextMemorySummarizeBody struct { - Summary string `json:"summary"` // One-line execution summary (no skip-note suffix) - Status string `json:"status"` // "success" | "failed" | "error" - Network string `json:"network"` // Chain name (e.g., "Sepolia", "Ethereum") - Trigger string `json:"trigger"` // What triggered the workflow (text description) - TriggeredAt string `json:"triggeredAt"` // ISO 8601 timestamp (from trigger output) - Executions []contextMemoryExecutionEntry `json:"executions"` // On-chain operations only (no BRANCH entries) - Errors []string `json:"errors"` // Failed steps and skipped node descriptions - SkippedNote string `json:"skippedNote,omitempty"` // "1 node was skipped by Branch condition." — present only when status=success && skippedSteps>0 - ExecutedSteps int `json:"executedSteps"` // Number of steps that actually executed - TotalSteps int `json:"totalSteps"` // Executed + skipped-by-branch - SkippedSteps int `json:"skippedSteps"` // 0 when nothing was skipped - - // Enhanced structured data for rich notifications (kept for potential future use) - Transfers []contextMemoryTransferInfo `json:"transfers,omitempty"` // Transfer details - Balances []contextMemoryBalanceInfo `json:"balances,omitempty"` // Balance snapshots - Workflow *contextMemoryWorkflowInfo `json:"workflow,omitempty"` // Workflow metadata -} - -// SummarizeResponse matches the TypeScript SummarizeResponse -type contextMemorySummarizeResponse struct { - Subject string `json:"subject"` - Body contextMemorySummarizeBody `json:"body"` - PromptVersion string `json:"promptVersion"` - Cached bool `json:"cached,omitempty"` -} - -func (c *ContextMemorySummarizer) Summarize(ctx context.Context, vm *VM, currentStepName string) (Summary, error) { - if c == nil || c.httpClient == nil { - return Summary{}, fmt.Errorf("summarizer not initialized") - } - - // Compute execution verdict BEFORE buildRequest acquires vm.mu — AnalyzeExecutionResult - // takes the same lock and sync.Mutex is not reentrant. - // - // Empty ExecutionLogs is the single-node RunNodeImmediately case: the only - // step is the notification node currently running and therefore not in - // ExecutionLogs yet. AnalyzeExecutionResult treats that as "no execution - // steps found" / ExecutionFailed, which would emit a bogus status=failed - // to context-memory. Mirror the deterministic path and treat empty logs as - // success — nothing has failed yet. - var status, executionError string - if len(vm.ExecutionLogs) == 0 { - status = "success" - } else { - var resultStatus ExecutionResultStatus - executionError, _, resultStatus = vm.AnalyzeExecutionResult() - status = mapExecutionStatusToAPIString(resultStatus) - } - - // Build request from VM - req, err := c.buildRequest(vm, currentStepName, status, executionError) - if err != nil { - // Include the specific validation error to help with debugging - return Summary{}, fmt.Errorf("failed to build request (validation error): %w", err) - } - - // Marshal request - reqBody, err := json.Marshal(req) - if err != nil { - return Summary{}, fmt.Errorf("failed to marshal request: %w", err) - } - - // Create HTTP request - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.baseURL+"/api/summarize", bytes.NewBuffer(reqBody)) - if err != nil { - return Summary{}, fmt.Errorf("failed to create request: %w", err) - } - - httpReq.Header.Set("Content-Type", "application/json") - if c.authToken != "" { - httpReq.Header.Set("Authorization", "Bearer "+c.authToken) - } - - // Log request details - if vm != nil && vm.logger != nil { - vm.logger.Info("Context-memory API: sending request", "url", c.baseURL+"/api/summarize", "request_size", len(reqBody)) - } - - // Send request - resp, err := c.httpClient.Do(httpReq) - if err != nil { - // Log DEBUG level for fallback operations (reduces log clutter in production) - if vm != nil && vm.logger != nil { - vm.logger.Debug("Context-memory API not available: HTTP request failed", "error", err, "url", c.baseURL+"/api/summarize") - } - return Summary{}, fmt.Errorf("HTTP request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - // Log DEBUG level for fallback operations (reduces log clutter in production) - if vm != nil && vm.logger != nil { - vm.logger.Debug("Context-memory API not available: non-2xx response", "status_code", resp.StatusCode, "response_body", string(body), "url", c.baseURL+"/api/summarize") - } - return Summary{}, fmt.Errorf("non-2xx response (%d): %s", resp.StatusCode, string(body)) - } - - // Parse response - var apiResp contextMemorySummarizeResponse - if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { - if vm != nil && vm.logger != nil { - vm.logger.Info("Context-memory API: failed to decode response", "error", err) - } - return Summary{}, fmt.Errorf("failed to decode response: %w", err) - } - - // Log successful response - if vm != nil && vm.logger != nil { - vm.logger.Info("Context-memory API: received successful response", "subject", apiResp.Subject, "status", apiResp.Body.Status) - } - - // Convert API response transfers to Summary transfers - var transfers []TransferInfo - if len(apiResp.Body.Transfers) > 0 { - transfers = make([]TransferInfo, len(apiResp.Body.Transfers)) - for i, t := range apiResp.Body.Transfers { - transfers[i] = TransferInfo{ - StepName: t.StepName, - Type: t.Type, - From: t.From, - To: t.To, - RawAmount: t.RawAmount, - Amount: t.Amount, - Symbol: t.Symbol, - Decimals: t.Decimals, - TxHash: t.TxHash, - IsSimulated: t.IsSimulated, - } - } - } - - // Convert API response balances to Summary balances - var balances []BalanceInfo - if len(apiResp.Body.Balances) > 0 { - balances = make([]BalanceInfo, len(apiResp.Body.Balances)) - for i, b := range apiResp.Body.Balances { - balances[i] = BalanceInfo{ - StepName: b.StepName, - TokenAddress: b.TokenAddress, - Symbol: b.Symbol, - Name: b.Name, - Balance: b.Balance, - BalanceFormatted: b.BalanceFormatted, - Decimals: b.Decimals, - } - } - } - - // Workflow metadata: the aggregator owns IsSimulation since it ran the - // workflow — the API response's value (if any) is informational only. - // Always populate so renderers can rely on the flag without nil-checking - // against API response shape drift. - workflow := &WorkflowInfo{IsSimulation: vm.IsSimulation} - if apiResp.Body.Workflow != nil { - workflow.Name = apiResp.Body.Workflow.Name - workflow.Chain = apiResp.Body.Workflow.Chain - workflow.ChainID = apiResp.Body.Workflow.ChainID - workflow.RunNumber = apiResp.Body.Workflow.RunNumber - } - - // Convert API execution entries to Summary execution entries - // Validate txHash at ingestion: only keep hashes that look like real - // Ethereum tx hashes (0x + 64 hex chars). This filters out Tenderly - // simulation IDs and other non-hash values the API may return. - var executions []ExecutionEntry - for _, e := range apiResp.Body.Executions { - entry := ExecutionEntry{Description: e.Description} - if isValidTxHash(e.TxHash) { - entry.TxHash = e.TxHash - } - executions = append(executions, entry) - } - - // Runner / Fees are aggregator-local (not from the API response). Both helpers - // read VM state directly so notifications surface the same fee numbers as - // EstimateFees() and the persisted Execution.Fee. See PRD: - // docs/changes/20260501-summary-runner-and-fees-sections.md. - return Summary{ - Subject: apiResp.Subject, - Body: composePlainTextBodyFromAPI(apiResp.Body), - SummaryLine: apiResp.Body.Summary, - Status: apiResp.Body.Status, - Network: apiResp.Body.Network, - Trigger: apiResp.Body.Trigger, - TriggeredAt: apiResp.Body.TriggeredAt, - Executions: executions, - Errors: apiResp.Body.Errors, - SkippedNote: apiResp.Body.SkippedNote, - ExecutedSteps: apiResp.Body.ExecutedSteps, - TotalSteps: apiResp.Body.TotalSteps, - SkippedSteps: apiResp.Body.SkippedSteps, - SmartWallet: req.SmartWallet, - Transfers: transfers, - Balances: balances, - Workflow: workflow, - Runner: buildRunnerFromVM(vm), - Fees: buildFeesFromVM(vm), - }, nil -} - -// composePlainTextBodyFromAPI creates a plain text body from the structured API response -// This is used for backward compatibility with channels that expect plain text -// NOTE: Does NOT include summary line - that's in the separate SummaryLine field -func composePlainTextBodyFromAPI(body contextMemorySummarizeBody) string { - var sb strings.Builder - - // Trigger (don't include summary - it's in SummaryLine field) - if body.Trigger != "" { - sb.WriteString("Trigger: ") - sb.WriteString(body.Trigger) - sb.WriteString("\n\n") - } - - // Executions - if len(body.Executions) > 0 { - sb.WriteString("Executed:\n") - for _, exec := range body.Executions { - sb.WriteString("- ") - sb.WriteString(exec.Description) - sb.WriteString("\n") - } - if len(body.Errors) > 0 { - sb.WriteString("\n") - } - } - - // Errors - if len(body.Errors) > 0 { - sb.WriteString("Issues:\n") - for _, err := range body.Errors { - sb.WriteString("- ") - sb.WriteString(err) - sb.WriteString("\n") - } - } - - return strings.TrimSpace(sb.String()) -} - // BuildNotifyPayload builds the raw execution-data payload (the same SummarizeRequest the // summarizer would POST to /api/summarize) as a generic map, for the gateway to merge into a // Studio /api/notify call. Path B: Studio summarizes AND distributes, so the gateway forwards @@ -463,11 +176,10 @@ func (c *ContextMemorySummarizer) buildRequest(vm *VM, currentStepName, status, // settings.chain_id is the last fallback (e.g. RunNodeImmediately where // vm.task is nil). vm.mu is already held — do not call chainIDFromVM, // which re-locks. + // A task carries no chain (G5); derive from the VM default config, then the + // settings.chain_id fallback below. var workflowChainID uint64 - if vm.task != nil && vm.task.Task != nil && vm.task.Task.ChainId > 0 { - workflowChainID = uint64(vm.task.Task.ChainId) - } - if workflowChainID == 0 && vm.smartWalletConfig != nil && vm.smartWalletConfig.ChainID > 0 { + if vm.smartWalletConfig != nil && vm.smartWalletConfig.ChainID > 0 { workflowChainID = uint64(vm.smartWalletConfig.ChainID) } if workflowChainID == 0 { diff --git a/core/taskengine/summarizer_context_memory_integration_test.go b/core/taskengine/summarizer_context_memory_integration_test.go deleted file mode 100644 index 2e466e00..00000000 --- a/core/taskengine/summarizer_context_memory_integration_test.go +++ /dev/null @@ -1,886 +0,0 @@ -//go:build integration -// +build integration - -package taskengine - -import ( - "encoding/json" - "fmt" - "os" - "strings" - "testing" - "time" - - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "github.com/go-resty/resty/v2" -) - -// ExecutionSummaryResponse matches the TypeScript ExecutionSummaryResponse interface -type ExecutionSummaryResponse struct { - Status string `json:"status"` // "success" | "failed" | "error" - BranchSummary string `json:"branchSummary"` // Plain text summary - SkippedNodes []string `json:"skippedNodes"` // Array of skipped node names - TotalSteps int `json:"totalSteps"` // Total steps in workflow - ExecutedSteps int `json:"executedSteps"` // Number of steps executed - SkippedSteps int `json:"skippedSteps"` // Number of skipped steps -} - -// SummarizeRequest matches the TypeScript SummarizeRequest interface -type SummarizeRequest struct { - OwnerEOA string `json:"ownerEOA"` - Name string `json:"name"` - SmartWallet string `json:"smartWallet"` - Steps []StepDigest `json:"steps"` - Status string `json:"status"` // "success" | "failed" | "error" - ExecutionError string `json:"executionError"` // Empty string on success - ChainName string `json:"chainName,omitempty"` - Nodes []NodeDefinition `json:"nodes,omitempty"` - Edges []EdgeDefinition `json:"edges,omitempty"` - Settings map[string]interface{} `json:"settings,omitempty"` - CurrentNodeName string `json:"currentNodeName,omitempty"` - TokenMetadata map[string]TokenMeta `json:"tokenMetadata,omitempty"` // All tokens involved, keyed by address (lowercase) -} - -type TokenMeta struct { - Symbol string `json:"symbol"` - Decimals int `json:"decimals"` - Name string `json:"name,omitempty"` -} - -type StepDigest struct { - Name string `json:"name"` - ID string `json:"id"` - Type string `json:"type"` - Success bool `json:"success"` - Error string `json:"error,omitempty"` - ContractAddress string `json:"contractAddress,omitempty"` - MethodName string `json:"methodName,omitempty"` - MethodParams map[string]interface{} `json:"methodParams,omitempty"` - OutputData interface{} `json:"outputData,omitempty"` - Metadata interface{} `json:"metadata,omitempty"` - StepDescription string `json:"stepDescription,omitempty"` - ExecutionContext map[string]interface{} `json:"executionContext,omitempty"` // Actual execution mode (is_simulated, provider, chain_id) -} - -type NodeDefinition struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type EdgeDefinition struct { - ID string `json:"id"` - Source string `json:"source"` - Target string `json:"target"` -} - -// getContextMemoryURL returns the base URL for the summarizer API in tests. -// Uses CONTEXT_MEMORY_URL env var if set, otherwise the production origin above. -func getContextMemoryURL() string { - if url := os.Getenv("CONTEXT_MEMORY_URL"); url != "" { - return url - } - return defaultSummarizerURL -} - -// baseURL shared across tests to avoid redeclaration issues -var baseURL string - -func TestContextMemoryExecutionSummary_SuccessfulWorkflow(t *testing.T) { - baseURL = getContextMemoryURL() - authToken := getAuthTokenOrSkip(t, baseURL) - t.Logf("Testing against: %s", baseURL) - - client := resty.New() - client.SetTimeout(30 * time.Second) - - // Build request from VM-like structure - request := SummarizeRequest{ - OwnerEOA: "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - Name: "Test Workflow", - SmartWallet: "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - Status: "success", - ExecutionError: "", - ChainName: "Sepolia", - Steps: []StepDigest{ - { - Name: "balance1", - ID: "step1", - Type: "balance", - Success: true, - }, - { - Name: "approve_token1", - ID: "step2", - Type: "contractWrite", - Success: true, - ContractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", - MethodName: "approve", - StepDescription: "Approved 1000 USDC to Uniswap V3 router", - }, - { - Name: "get_quote", - ID: "step3", - Type: "contractRead", - Success: true, - }, - }, - Nodes: []NodeDefinition{ - {ID: "node1", Name: "balance1"}, - {ID: "node2", Name: "approve_token1"}, - {ID: "node3", Name: "get_quote"}, - {ID: "node4", Name: "run_swap"}, - }, - Edges: []EdgeDefinition{ - {ID: "edge1", Source: "node1", Target: "node2"}, - {ID: "edge2", Source: "node2", Target: "node3"}, - {ID: "edge3", Source: "node3", Target: "node4"}, - }, - } - - var response ExecutionSummaryResponse - url := baseURL + "/api/execution-summary" - - // Log request details for debugging - t.Logf("Making POST request to: %s", url) - t.Logf("Request body (first 500 chars): %s", truncateString(mustMarshalJSON(request), 500)) - - resp, err := client.R(). - SetHeader("Authorization", "Bearer "+authToken). - SetHeader("Content-Type", "application/json"). - SetBody(request). - SetResult(&response). - Post(url) - - if err != nil { - t.Fatalf("HTTP request failed: %v", err) - } - - if resp.StatusCode() != 200 { - t.Logf("Response status: %d", resp.StatusCode()) - t.Logf("Response headers: %v", resp.Header()) - t.Logf("Full response body: %s", string(resp.Body())) - t.Fatalf("Expected status 200, got %d. Response body: %s", resp.StatusCode(), string(resp.Body())) - } - - t.Logf("Success! Response: status=%s, executedSteps=%d, totalSteps=%d", - response.Status, response.ExecutedSteps, response.TotalSteps) - - // Validate response structure - if response.Status == "" { - t.Error("Response status should not be empty") - } - if response.Status != "success" && response.Status != "failed" && response.Status != "error" { - t.Errorf("Invalid status value: %s (expected 'success', 'failed', or 'error')", response.Status) - } - - // branchSummary should always be present (even if empty string) - if response.BranchSummary == "" { - // Empty string is valid when there are no skipped nodes - t.Log("branchSummary is empty (expected for successful workflow)") - } - - // skippedNodes should always be present (even if empty array) - if response.SkippedNodes == nil { - t.Error("skippedNodes should not be nil") - } - - // Validate metrics - if response.ExecutedSteps != 3 { - t.Errorf("Expected executedSteps=3, got %d", response.ExecutedSteps) - } - if response.TotalSteps < response.ExecutedSteps { - t.Errorf("totalSteps (%d) should be >= executedSteps (%d)", response.TotalSteps, response.ExecutedSteps) - } - if response.SkippedSteps < 0 { - t.Errorf("skippedSteps should be >= 0, got %d", response.SkippedSteps) - } - - // For this test, we expect 1 skipped node (run_swap) - if response.SkippedSteps != 1 { - t.Errorf("Expected skippedSteps=1 (run_swap was skipped), got %d", response.SkippedSteps) - } - if len(response.SkippedNodes) != 1 { - t.Errorf("Expected 1 skipped node, got %d: %v", len(response.SkippedNodes), response.SkippedNodes) - } - if len(response.SkippedNodes) > 0 && response.SkippedNodes[0] != "run_swap" { - t.Errorf("Expected skipped node 'run_swap', got %s", response.SkippedNodes[0]) - } - - t.Logf("Response: status=%s, executedSteps=%d, totalSteps=%d, skippedSteps=%d, skippedNodes=%v", - response.Status, response.ExecutedSteps, response.TotalSteps, response.SkippedSteps, response.SkippedNodes) -} - -func TestContextMemoryExecutionSummary_FailedWorkflow(t *testing.T) { - baseURL = getContextMemoryURL() - authToken := getAuthTokenOrSkip(t, baseURL) - - client := resty.New() - client.SetTimeout(30 * time.Second) - - request := SummarizeRequest{ - OwnerEOA: "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - Name: "Failed Workflow", - SmartWallet: "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - Status: "failed", - ExecutionError: "1 of 2 steps failed: swap_tokens", - ChainName: "Sepolia", - Steps: []StepDigest{ - { - Name: "approve_token", - ID: "step1", - Type: "contractWrite", - Success: true, - }, - { - Name: "swap_tokens", - ID: "step2", - Type: "contractWrite", - Success: false, - Error: "Insufficient gas", - }, - }, - Nodes: []NodeDefinition{ - {ID: "node1", Name: "approve_token"}, - {ID: "node2", Name: "swap_tokens"}, - }, - } - - var response ExecutionSummaryResponse - url := baseURL + "/api/execution-summary" - - // Log request details for debugging - t.Logf("Making POST request to: %s", url) - t.Logf("Request body (first 500 chars): %s", truncateString(mustMarshalJSON(request), 500)) - - resp, err := client.R(). - SetHeader("Authorization", "Bearer "+authToken). - SetHeader("Content-Type", "application/json"). - SetBody(request). - SetResult(&response). - Post(url) - - if err != nil { - t.Fatalf("HTTP request failed: %v", err) - } - - if resp.StatusCode() != 200 { - t.Logf("Response status: %d", resp.StatusCode()) - t.Logf("Response headers: %v", resp.Header()) - t.Logf("Full response body: %s", string(resp.Body())) - t.Fatalf("Expected status 200, got %d. Response body: %s", resp.StatusCode(), string(resp.Body())) - } - - t.Logf("Success! Response: status=%s, executedSteps=%d, totalSteps=%d", - response.Status, response.ExecutedSteps, response.TotalSteps) - - // Validate failed status - if response.Status != "failed" { - t.Errorf("Expected status='failed', got %s", response.Status) - } - - // Validate metrics - if response.ExecutedSteps != 2 { - t.Errorf("Expected executedSteps=2, got %d", response.ExecutedSteps) - } - - t.Logf("Failed workflow response: status=%s, executedSteps=%d", response.Status, response.ExecutedSteps) -} - -func TestContextMemoryExecutionSummary_CompleteWorkflow(t *testing.T) { - baseURL = getContextMemoryURL() - authToken := getAuthTokenOrSkip(t, baseURL) - - client := resty.New() - client.SetTimeout(30 * time.Second) - - request := SummarizeRequest{ - OwnerEOA: "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - Name: "Complete Workflow", - SmartWallet: "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - Status: "success", - ExecutionError: "", - ChainName: "Sepolia", - Steps: []StepDigest{ - { - Name: "balance1", - ID: "step1", - Type: "balance", - Success: true, - }, - { - Name: "approve_token1", - ID: "step2", - Type: "contractWrite", - Success: true, - }, - { - Name: "get_quote", - ID: "step3", - Type: "contractRead", - Success: true, - }, - { - Name: "run_swap", - ID: "step4", - Type: "contractWrite", - Success: true, - }, - }, - Nodes: []NodeDefinition{ - {ID: "node1", Name: "balance1"}, - {ID: "node2", Name: "approve_token1"}, - {ID: "node3", Name: "get_quote"}, - {ID: "node4", Name: "run_swap"}, - }, - } - - var response ExecutionSummaryResponse - url := baseURL + "/api/execution-summary" - - // Log request details for debugging - t.Logf("Making POST request to: %s", url) - t.Logf("Request body (first 500 chars): %s", truncateString(mustMarshalJSON(request), 500)) - - resp, err := client.R(). - SetHeader("Authorization", "Bearer "+authToken). - SetHeader("Content-Type", "application/json"). - SetBody(request). - SetResult(&response). - Post(url) - - if err != nil { - t.Fatalf("HTTP request failed: %v", err) - } - - if resp.StatusCode() != 200 { - t.Logf("Response status: %d", resp.StatusCode()) - t.Logf("Response headers: %v", resp.Header()) - t.Logf("Full response body: %s", string(resp.Body())) - t.Fatalf("Expected status 200, got %d. Response body: %s", resp.StatusCode(), string(resp.Body())) - } - - t.Logf("Success! Response: status=%s, executedSteps=%d, totalSteps=%d", - response.Status, response.ExecutedSteps, response.TotalSteps) - - // Validate success status - if response.Status != "success" { - t.Errorf("Expected status='success', got %s", response.Status) - } - - // All nodes executed, no skipped nodes - if response.SkippedSteps != 0 { - t.Errorf("Expected skippedSteps=0, got %d", response.SkippedSteps) - } - if len(response.SkippedNodes) != 0 { - t.Errorf("Expected no skipped nodes, got %v", response.SkippedNodes) - } - if response.ExecutedSteps != 4 { - t.Errorf("Expected executedSteps=4, got %d", response.ExecutedSteps) - } - if response.TotalSteps != 4 { - t.Errorf("Expected totalSteps=4, got %d", response.TotalSteps) - } - - t.Logf("Complete workflow response: status=%s, executedSteps=%d, totalSteps=%d", - response.Status, response.ExecutedSteps, response.TotalSteps) -} - -func TestContextMemoryExecutionSummary_FromVM(t *testing.T) { - baseURL = getContextMemoryURL() - authToken := getAuthTokenOrSkip(t, baseURL) - // Create a VM similar to TestComposeSummarySmart_WithRealWorkflowState - vm := NewVM() - vm.TaskID = "01K6H8R583M8WFXM2Z4APP7JTN" - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - {Id: "trigger", Name: "eventTrigger", Type: "eventTrigger", Success: true}, - {Id: "step1", Name: "balance1", Type: "balance", Success: true}, - {Id: "step2", Name: "branch1", Type: "branch", Success: true}, - {Id: "step3", Name: "email_report", Type: "restApi", Success: true}, - } - - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "node1": {Id: "node1", Name: "balance1"}, - "node2": {Id: "node2", Name: "branch1"}, - "node3": {Id: "node3", Name: "approve_token1"}, - "node4": {Id: "node4", Name: "get_quote"}, - "node5": {Id: "node5", Name: "run_swap"}, - "node6": {Id: "node6", Name: "email_report"}, - } - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "Test template", - "chain": "Sepolia", - "runner": "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - }, - } - vm.mu.Unlock() - - // Convert VM to API request - // Note: buildSummarizeRequestFromVM locks vm.mu, so we call it before creating the client - request := buildSummarizeRequestFromVM(vm) - - baseURL = getContextMemoryURL() - t.Logf("Testing against: %s", baseURL) - - client := resty.New() - client.SetTimeout(30 * time.Second) - - var response ExecutionSummaryResponse - url := baseURL + "/api/execution-summary" - - // Log request details for debugging - t.Logf("Making POST request to: %s", url) - t.Logf("Request body (first 500 chars): %s", truncateString(mustMarshalJSON(request), 500)) - - resp, err := client.R(). - SetHeader("Authorization", "Bearer "+authToken). - SetHeader("Content-Type", "application/json"). - SetBody(request). - SetResult(&response). - Post(url) - - if err != nil { - t.Fatalf("HTTP request failed: %v", err) - } - - if resp.StatusCode() != 200 { - t.Logf("Response status: %d", resp.StatusCode()) - t.Logf("Response headers: %v", resp.Header()) - t.Logf("Full response body: %s", string(resp.Body())) - t.Fatalf("Expected status 200, got %d. Response body: %s", resp.StatusCode(), string(resp.Body())) - } - - t.Logf("Success! Response: status=%s, executedSteps=%d, totalSteps=%d", - response.Status, response.ExecutedSteps, response.TotalSteps) - - // Validate response - if response.Status == "" { - t.Error("Response status should not be empty") - } - - // Should have 4 executed steps (trigger + 3 nodes) - if response.ExecutedSteps != 4 { - t.Errorf("Expected executedSteps=4, got %d", response.ExecutedSteps) - } - - // Should have 3 skipped nodes (approve_token1, get_quote, run_swap) - if response.SkippedSteps != 3 { - t.Errorf("Expected skippedSteps=3, got %d", response.SkippedSteps) - } - if response.TotalSteps != 7 { - t.Errorf("Expected totalSteps=7 (4 executed + 3 skipped), got %d", response.TotalSteps) - } - - // Validate skipped nodes - expectedSkipped := []string{"approve_token1", "get_quote", "run_swap"} - if len(response.SkippedNodes) != len(expectedSkipped) { - t.Errorf("Expected %d skipped nodes, got %d: %v", len(expectedSkipped), len(response.SkippedNodes), response.SkippedNodes) - } - - // branchSummary should contain text about skipped nodes - if response.BranchSummary == "" { - t.Error("branchSummary should not be empty when there are skipped nodes") - } - if !strings.Contains(response.BranchSummary, "skipped") { - t.Errorf("branchSummary should mention skipped nodes, got: %s", response.BranchSummary) - } - - t.Logf("VM-based test response: status=%s, executedSteps=%d, totalSteps=%d, skippedSteps=%d, skippedNodes=%v", - response.Status, response.ExecutedSteps, response.TotalSteps, response.SkippedSteps, response.SkippedNodes) - t.Logf("branchSummary: %s", response.BranchSummary) -} - -// buildSummarizeRequestFromVM converts a VM to a SummarizeRequest -// This mimics what the aggregator will do when integrating with context-memory -func buildSummarizeRequestFromVM(vm *VM) SummarizeRequest { - // AnalyzeExecutionResult locks vm.mu, so compute the verdict before we take the lock. - executionError, _, resultStatus := vm.AnalyzeExecutionResult() - status := mapExecutionStatusToAPIString(resultStatus) - - vm.mu.Lock() - defer vm.mu.Unlock() - - // Extract from settings - var ownerEOA, smartWallet, workflowName, chainName string - if settings, ok := vm.vars["settings"].(map[string]interface{}); ok { - if name, ok := settings["name"].(string); ok { - workflowName = name - } - if chain, ok := settings["chain"].(string); ok { - chainName = chain - } - if runner, ok := settings["runner"].(string); ok { - smartWallet = runner - } - } - - // Convert execution logs to steps - steps := make([]StepDigest, 0, len(vm.ExecutionLogs)) - for _, log := range vm.ExecutionLogs { - step := StepDigest{ - Name: log.GetName(), - ID: log.GetId(), - Type: log.GetType(), - Success: log.GetSuccess(), - } - if log.GetError() != "" { - step.Error = log.GetError() - } - steps = append(steps, step) - } - - // Convert TaskNodes to nodes - nodes := make([]NodeDefinition, 0, len(vm.TaskNodes)) - for nodeID, node := range vm.TaskNodes { - if node == nil { - continue - } - // Skip branch condition pseudo-nodes (IDs starting with '.') - if len(nodeID) > 0 && nodeID[0] == '.' { - continue - } - nodes = append(nodes, NodeDefinition{ - ID: nodeID, - Name: node.GetName(), - }) - } - - // Convert edges (if available) - // Note: vm.task is a *model.Workflow which contains the protobuf Task - edges := make([]EdgeDefinition, 0) - if vm.task != nil && vm.task.Task != nil && vm.task.Task.Edges != nil { - for _, edge := range vm.task.Task.Edges { - edges = append(edges, EdgeDefinition{ - ID: edge.GetId(), - Source: edge.GetSource(), - Target: edge.GetTarget(), - }) - } - } - - // Extract token metadata from settings if available - tokenMetadata := make(map[string]TokenMeta) - if settings, ok := vm.vars["settings"].(map[string]interface{}); ok { - if pool, ok := settings["uniswapv3_pool"].(map[string]interface{}); ok { - if tokens, ok := pool["tokens"].(map[string]interface{}); ok { - // For test purposes, we'll add placeholder metadata - // In production, this would come from TokenEnrichmentService - for _, tokenAddr := range tokens { - if addr, ok := tokenAddr.(string); ok && len(addr) > 0 { - addrLower := strings.ToLower(addr) - // Add placeholder metadata (tests can override if needed) - if _, exists := tokenMetadata[addrLower]; !exists { - tokenMetadata[addrLower] = TokenMeta{ - Symbol: "TOKEN", - Decimals: 18, - Name: "Test Token", - } - } - } - } - } - } - } - - return SummarizeRequest{ - OwnerEOA: ownerEOA, - Name: workflowName, - SmartWallet: smartWallet, - Steps: steps, - Status: status, - ExecutionError: executionError, - ChainName: chainName, - Nodes: nodes, - Edges: edges, - TokenMetadata: tokenMetadata, - } -} - -// getAuthTokenOrSkip returns the auth token for context-api requests -// Accepts SERVICE_AUTH_TOKEN env var override, or uses default local token for localhost URLs -func getAuthTokenOrSkip(t *testing.T, baseURL string) string { - t.Helper() - // Check for SERVICE_AUTH_TOKEN override first (works for any URL) - if authToken := os.Getenv("SERVICE_AUTH_TOKEN"); authToken != "" && strings.TrimSpace(authToken) != "" { - return authToken - } - // For localhost URLs, use default local token if SERVICE_AUTH_TOKEN not set - if strings.Contains(baseURL, "localhost") { - t.Logf("SERVICE_AUTH_TOKEN not set, using default local token") - return ContextMemoryAuthToken - } - // For production URLs, require SERVICE_AUTH_TOKEN to be set - t.Skip("SERVICE_AUTH_TOKEN not set, skipping integration test") - return "" -} - -// Helper functions for debugging -func mustMarshalJSON(v interface{}) string { - data, err := json.Marshal(v) - if err != nil { - return fmt.Sprintf("", err) - } - return string(data) -} - -func truncateString(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "..." -} - -// SummarizeResponse matches the TypeScript SummarizeResponse interface for /api/summarize -type SummarizeResponse struct { - Subject string `json:"subject"` - Summary string `json:"summary"` - AnalysisHtml string `json:"analysisHtml"` - Body string `json:"body"` - StatusHtml string `json:"statusHtml"` - Status string `json:"status"` - PromptVersion string `json:"promptVersion"` - Cached bool `json:"cached,omitempty"` -} - -// TestContextMemorySummarize_SimulatedPrefixBehavior verifies that the /api/summarize endpoint -// correctly adds "(simulated)" prefix to steps with ExecutionContext.is_simulated = true -// and does NOT add the prefix to steps with is_simulated = false (real on-chain transactions) -func TestContextMemorySummarize_SimulatedPrefixBehavior(t *testing.T) { - baseURL = getContextMemoryURL() - authToken := getAuthTokenOrSkip(t, baseURL) - t.Logf("Testing against: %s", baseURL) - - client := resty.New() - client.SetTimeout(30 * time.Second) - - // Build request with mixed simulated/real steps - // This simulates a workflow where: - // - approve1: is_simulated=false (real on-chain transaction via bundler) - // - contractWrite1: is_simulated=true (simulated via Tenderly) - request := SummarizeRequest{ - OwnerEOA: "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - Name: "Test Stoploss", - SmartWallet: "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - Status: "success", - ExecutionError: "", - ChainName: "Sepolia", - Steps: []StepDigest{ - { - Name: "eventTrigger", - ID: "trigger1", - Type: "eventTrigger", - Success: true, - ExecutionContext: map[string]interface{}{ - "is_simulated": false, - "provider": "chain-rpc", - "chain_id": 11155111, - }, - }, - { - Name: "approve1", - ID: "step1", - Type: "contractWrite", - Success: true, - ContractAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - MethodName: "approve", - OutputData: map[string]interface{}{ - "approve": map[string]interface{}{ - "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "spender": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", - "value": "20990000", - }, - }, - // REAL transaction - should NOT have (simulated) prefix - ExecutionContext: map[string]interface{}{ - "is_simulated": false, - "provider": "bundler", - "chain_id": 11155111, - }, - }, - { - Name: "contractWrite1", - ID: "step2", - Type: "contractWrite", - Success: true, - ContractAddress: "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", - MethodName: "exactInputSingle", - OutputData: map[string]interface{}{ - "exactInputSingle": map[string]interface{}{ - "amountOut": "2235380089399511", - }, - }, - // SIMULATED transaction - SHOULD have (simulated) prefix - ExecutionContext: map[string]interface{}{ - "is_simulated": true, - "provider": "tenderly", - "chain_id": 11155111, - }, - }, - }, - Nodes: []NodeDefinition{ - {ID: "node0", Name: "eventTrigger"}, - {ID: "node1", Name: "approve1"}, - {ID: "node2", Name: "contractWrite1"}, - }, - Edges: []EdgeDefinition{ - {ID: "edge1", Source: "node0", Target: "node1"}, - {ID: "edge2", Source: "node1", Target: "node2"}, - }, - Settings: map[string]interface{}{ - "name": "Test Stoploss", - "chain_id": 11155111, - "uniswapv3_pool": map[string]interface{}{ - "tokens": map[string]interface{}{ - "input": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - "output": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", - }, - }, - }, - CurrentNodeName: "email1", - TokenMetadata: map[string]TokenMeta{ - "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": { - Symbol: "USDC", - Decimals: 6, - Name: "USD Coin", - }, - "0xfff9976782d46cc05630d1f6ebab18b2324d6b14": { - Symbol: "WETH", - Decimals: 18, - Name: "Wrapped Ether", - }, - }, - } - - var response SummarizeResponse - url := baseURL + "/api/summarize" - - // Log request details for debugging - requestJSON := mustMarshalJSON(request) - t.Logf("Making POST request to: %s", url) - t.Logf("Full request body:\n%s", requestJSON) - - resp, err := client.R(). - SetHeader("Authorization", "Bearer "+authToken). - SetHeader("Content-Type", "application/json"). - SetBody(request). - SetResult(&response). - Post(url) - - if err != nil { - t.Fatalf("HTTP request failed: %v", err) - } - - // Always log the raw response body for debugging - t.Logf("Raw response body: %s", string(resp.Body())) - - if resp.StatusCode() != 200 { - t.Logf("Response status: %d", resp.StatusCode()) - t.Logf("Response headers: %v", resp.Header()) - t.Logf("Full response body: %s", string(resp.Body())) - t.Fatalf("Expected status 200, got %d. Response body: %s", resp.StatusCode(), string(resp.Body())) - } - - t.Logf("Response received:") - t.Logf(" Subject: %s", response.Subject) - t.Logf(" Body: %s", response.Body) - t.Logf(" AnalysisHtml: %s", response.AnalysisHtml) - t.Logf(" Status: %s", response.Status) - - // Validate that the body/analysisHtml contains proper (simulated) markers - // The swap (contractWrite1) should have "(simulated)" but approve1 should NOT - - // Check that approve1 does NOT have (simulated) prefix since is_simulated=false - if strings.Contains(response.Body, "Approved") && strings.Contains(response.Body, "(simulated)") { - // Need to check if the (simulated) is associated with Approved or Swapped - // This is a simplified check - ideally we'd parse the structure - t.Logf("WARNING: Body contains '(simulated)' - checking if it's correctly applied") - } - - // Check that swap/exactInputSingle DOES have (Simulated) prefix since is_simulated=true - bodyLowerCheck := strings.ToLower(response.Body) - analysisLowerCheck := strings.ToLower(response.AnalysisHtml) - if strings.Contains(bodyLowerCheck, "swap") { - if !strings.Contains(bodyLowerCheck, "(simulated)") && !strings.Contains(analysisLowerCheck, "(simulated)") { - t.Errorf("Expected '(Simulated)' prefix for swap step (is_simulated=true), but not found in body or analysisHtml") - t.Logf("Body: %s", response.Body) - t.Logf("AnalysisHtml: %s", response.AnalysisHtml) - } - } - - // More detailed check: The body should distinguish between real and simulated - // Real: "Approved 20.99 USDC to 0x3bFA...e48E for trading" (no prefix, formatted with symbol) - // Simulated: "(Simulated) Swapped for ~2.2354 WETH via Uniswap V3" (with prefix and symbol) - t.Logf("=== SIMULATED PREFIX BEHAVIOR VERIFICATION ===") - t.Logf("Expected behavior:") - t.Logf(" - approve1 (is_simulated=false): NO (simulated) prefix") - t.Logf(" - contractWrite1 (is_simulated=true): SHOULD have (simulated) prefix") - t.Logf("Actual body: %s", response.Body) - t.Logf("PromptVersion: %s", response.PromptVersion) - - // If using fallback, the test can't verify the simulated prefix behavior - // Log a warning but don't fail - the real verification happens when AI is enabled - if response.PromptVersion == "fallback" { - t.Logf("WARNING: Context-memory returned fallback response (no AI summary)") - t.Logf("This test requires the full AI summary to verify (simulated) prefix behavior") - t.Logf("Ensure the context-memory service has AI summarization enabled") - // Don't fail the test - just skip the verification - t.Skip("Skipping simulated prefix verification - context-memory returned fallback") - } - - // When AI summary is enabled, verify the (simulated) prefix behavior - // The body should contain "(Simulated)" only for steps with is_simulated=true - if response.Body != "" { - // The swap step (contractWrite1) has is_simulated=true, so it should have (Simulated) prefix - // Check if body mentions swap/exactInputSingle with (Simulated) - bodyLower := strings.ToLower(response.Body) - hasSwapMention := strings.Contains(bodyLower, "swap") || strings.Contains(bodyLower, "exactinputsingle") - hasSimulatedPrefix := strings.Contains(bodyLower, "(simulated)") // case-insensitive check - - if hasSwapMention && !hasSimulatedPrefix { - t.Errorf("FAIL: Swap step (is_simulated=true) should have '(Simulated)' prefix but it's missing") - t.Logf("Body: %s", response.Body) - } else if hasSwapMention && hasSimulatedPrefix { - t.Logf("PASS: Swap step correctly has '(Simulated)' prefix") - } - - // The approve step (approve1) has is_simulated=false, so it should NOT have (Simulated) prefix - // Check that "(Simulated)" doesn't appear on the same line as "Approved" - // Also verify that approve amount is formatted with token symbol (e.g., "20.99 USDC" not "20,990,000") - lines := strings.Split(response.Body, "\n") - for _, line := range lines { - lineLower := strings.ToLower(line) - if strings.Contains(lineLower, "approved") { - if strings.Contains(lineLower, "(simulated)") { - t.Errorf("FAIL: Approve step (is_simulated=false) should NOT have '(Simulated)' prefix") - t.Logf("Line: %s", line) - } else { - t.Logf("PASS: Approve line correctly has NO (Simulated) prefix") - } - // Verify formatted amount with symbol (should contain "USDC" and a decimal number like "20.99") - if !strings.Contains(lineLower, "usdc") { - t.Errorf("FAIL: Approve step should include token symbol 'USDC' in formatted amount") - t.Logf("Line: %s", line) - } - // Should not contain raw amount like "20,990,000" or "20990000" - if strings.Contains(line, "20,990,000") || strings.Contains(line, "20990000") { - t.Errorf("FAIL: Approve step should show formatted amount (e.g., '20.99 USDC'), not raw amount") - t.Logf("Line: %s", line) - } - } - if strings.Contains(lineLower, "swap") { - if !strings.Contains(lineLower, "(simulated)") { - t.Errorf("FAIL: Swap line missing (Simulated) prefix: %s", line) - } else { - t.Logf("PASS: Swap line correctly has (Simulated) prefix") - } - // Verify swap includes output token symbol (should contain "weth" - case-insensitive) - if !strings.Contains(lineLower, "weth") { - t.Errorf("FAIL: Swap step should include output token symbol 'WETH'") - t.Logf("Line: %s", line) - } - } - } - } -} diff --git a/core/taskengine/summarizer_deterministic.go b/core/taskengine/summarizer_deterministic.go deleted file mode 100644 index 099e4312..00000000 --- a/core/taskengine/summarizer_deterministic.go +++ /dev/null @@ -1,3055 +0,0 @@ -package taskengine - -import ( - "fmt" - "html" - "math/big" - "net/url" - "sort" - "strconv" - "strings" - "time" - - "github.com/ethereum/go-ethereum/common" - - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" -) - -// TransferInfo contains structured data for a token transfer -type TransferInfo struct { - StepName string `json:"stepName"` // Step name that produced this transfer - Type string `json:"type"` // Transfer type: "eth_transfer", "erc20_transfer", "contract_write" - From string `json:"from"` // Sender address - To string `json:"to"` // Recipient address - RawAmount string `json:"rawAmount"` // Raw amount (wei/smallest unit) - Amount string `json:"amount"` // Formatted amount (human-readable) - Symbol string `json:"symbol"` // Token symbol (e.g., "ETH", "USDC") - Decimals int `json:"decimals"` // Token decimals - TxHash string `json:"txHash"` // Transaction hash (empty if simulated) - IsSimulated bool `json:"isSimulated"` // Was this simulated? -} - -// BalanceInfo contains structured data for a balance snapshot -type BalanceInfo struct { - StepName string `json:"stepName"` // Step name that produced this balance - TokenAddress string `json:"tokenAddress"` // Token contract address - Symbol string `json:"symbol"` // Token symbol - Name string `json:"name"` // Token name - Balance string `json:"balance"` // Raw balance - BalanceFormatted string `json:"balanceFormatted"` // Formatted balance - Decimals int `json:"decimals"` // Token decimals -} - -// WorkflowInfo contains metadata about the workflow execution -type WorkflowInfo struct { - Name string `json:"name"` // Workflow name - Chain string `json:"chain"` // Chain name (e.g., "Sepolia") - ChainID int64 `json:"chainId"` // Chain ID - IsSimulation bool `json:"isSimulation"` // Was this a simulation? - RunNumber *int64 `json:"runNumber"` // Run number (nil for simulations) -} - -// ExecutionEntry represents a single on-chain execution step with an optional transaction hash. -// When TxHash is non-empty, formatters render it as a clickable explorer link. -type ExecutionEntry struct { - Description string // Human-readable execution description - TxHash string // On-chain transaction hash (empty for non-tx steps) -} - -// Summary represents composed notification content -type Summary struct { - Subject string - Body string - SummaryLine string // One-liner summary (e.g., "Your workflow 'Test Stoploss' executed 7 out of 7 total steps") - Status string // Execution status: "success" | "failed" | "error" - - // Structured fields for rendering notifications - Trigger string // What triggered the workflow (text description) - TriggeredAt string // ISO 8601 timestamp (from trigger output) - Executions []ExecutionEntry // On-chain operation descriptions (no BRANCH entries) - Errors []string // Failed steps and skipped node descriptions - SmartWallet string // Smart wallet address that executed the workflow - Network string // Chain name (e.g., "Sepolia", "Ethereum") - from body.network - Annotation string // Optional italic footnote (e.g., run-node disclaimer) - - // Step counts and skip reporting (present on every summary; populated from context-memory body) - SkippedNote string // "1 node was skipped by Branch condition." — present only when Status=="success" && SkippedSteps>0 - ExecutedSteps int // Number of steps that actually executed - TotalSteps int // Executed + skipped-by-branch - SkippedSteps int // 0 when nothing was skipped - - // Enhanced structured data for rich notifications (kept for potential future use) - Transfers []TransferInfo // Transfer details from ETH_TRANSFER and CONTRACT_WRITE steps - Balances []BalanceInfo // Balance snapshots from BALANCE steps - Workflow *WorkflowInfo // Workflow metadata - - // Runner / Fees come from the context-memory API response (PRD: docs/changes - // 20260501-summary-runner-and-fees-sections.md). Renderers display a Runner - // block above the per-step list and a Cost/Estimated cost block below. - Runner *RunnerInfo - Fees *FeesInfo -} - -// RunnerInfo identifies who ran the workflow. -type RunnerInfo struct { - SmartWallet string // 0x… AA wallet (the actual sender) - OwnerEOA string // 0x… EOA that owns the smart wallet -} - -// FeeAmount is a self-describing numeric value (mirror of avs.proto Fee). -type FeeAmount struct { - Amount string // decimal string; precision-safe for uint256 - Unit string // "USD" | "WEI" | "PERCENTAGE" -} - -// NodeCOGS is one per-node operational cost entry. -type NodeCOGS struct { - NodeID string - StepName string // joined from steps[]; empty for synthetic entries (e.g. _wallet_creation) - CostType string // canonical REST values: "gas" | "externalApi" | "walletCreation" — see fee_enums.go and OpenAPI enum NodeCOGSCostType - Fee *FeeAmount - GasUnits string // present when CostType == "gas" - TxHash string // joined from steps[].outputData; deployed-gas only -} - -// ValueFee is the workflow-level percentage of tx value. -type ValueFee struct { - Fee *FeeAmount - Tier string // proto enum string: "EXECUTION_TIER_1" / "_2" / "_3" / "_UNSPECIFIED" - Reason string // human-readable classification reason -} - -// FeesInfo is the per-execution fee breakdown computed from VM state. -type FeesInfo struct { - ExecutionFee *FeeAmount // flat platform fee, USD - Cogs []*NodeCOGS // per-node WEI cost - ValueFee *ValueFee // workflow-level percentage; nil when no on-chain nodes - - // Total is the per-token fee breakdown surfaced in notifications. First - // entry is always the chain's native token (gas + executionFee converted to - // ETH); subsequent entries are per-token value-fee legs grouped by transfer - // token symbol. Empty when no on-chain steps ran. See PRD: - // docs/changes/20260501-summary-runner-and-fees-sections.md. - Total []*TokenTotal -} - -// TokenTotal is a single token's contribution to the cost line. Self-describing: -// Amount is in the token's native units (e.g. "0.000003" ETH, "1.2" USDC), -// USD is the dollar equivalent ("0.01"); empty USD signals "price unknown" and -// renderers print a "$?" placeholder. -type TokenTotal struct { - Amount string // raw token amount, decimal string - Unit string // token symbol ("ETH", "USDC", etc.) - USD string // dollar equivalent; empty when unpriceable - // IsGas marks the native-token entry that represents actual on-chain gas - // spent by the UserOp. The other entries in Fees.Total — per-token value - // fees (tier_percentage × tx_value) and the USD platform fee — are NOT - // gas and notification renderers should not include them under the "⛽" - // (gas) emoji. API consumers that want the full fee breakdown read - // Fees.Total directly and don't depend on this flag. - IsGas bool -} - -// BuildBranchAndSkippedSummary builds a deterministic summary (text and HTML) -// describing which nodes were skipped due to branching and which branch -// conditions were selected along with the configured condition expressions. -// currentNodeName: optional name of the currently executing node (not yet in ExecutionLogs) -func BuildBranchAndSkippedSummary(vm *VM, currentNodeName ...string) (string, string) { - if vm == nil { - return "", "" - } - - // Collect executed step names - executed := make(map[string]struct{}) - for _, st := range vm.ExecutionLogs { - name := st.GetName() - if name == "" { - name = st.GetId() - } - if name != "" { - executed[name] = struct{}{} - } - } - // Include the current node if provided (it's executing but not yet in ExecutionLogs) - if len(currentNodeName) > 0 && currentNodeName[0] != "" { - executed[currentNodeName[0]] = struct{}{} - } - - // Compute skipped nodes (by name) - // Skip branch condition pseudo-nodes (e.g., "branch1.0", "branch1.1") which are routing points, not actual nodes - vm.mu.Lock() - skipped := make([]string, 0, len(vm.TaskNodes)) - for nodeID, n := range vm.TaskNodes { - if n == nil { - continue - } - // Skip branch condition nodes (they have IDs like "nodeId.conditionId") - // These are routing/edge nodes, not actual executable nodes - if strings.Contains(nodeID, ".") { - continue - } - if _, ok := executed[n.Name]; !ok { - skipped = append(skipped, n.Name) - } - } - vm.mu.Unlock() - - // Collect branch selections with evaluation details - type branchInfo struct { - Step string - Path string - ConditionID string - TargetNodeName string // Name of the node this branch leads to - Conditions []struct{ ID, Type, Expr string } - ConditionEvaluations []map[string]interface{} // Detailed evaluation results from metadata - } - branches := make([]branchInfo, 0, 2) - // On-chain action accounting - var successfulRealWrites, failedWrites, successfulSimulatedWrites int - simulatedWriteNames := make([]string, 0, 4) - for _, st := range vm.ExecutionLogs { - t := strings.ToUpper(st.GetType()) - if strings.Contains(t, "BRANCH") { - bi := branchInfo{} - name := st.GetName() - if name == "" { - name = st.GetId() - } - bi.Step = name - if br := st.GetBranch(); br != nil && br.GetData() != nil { - if m, ok := br.GetData().AsInterface().(map[string]interface{}); ok { - if cid, ok := m["conditionId"].(string); ok { - bi.ConditionID = cid - if idx := strings.LastIndex(cid, "."); idx >= 0 && idx+1 < len(cid) { - switch cid[idx+1:] { - case "0": - bi.Path = "If" - case "1": - bi.Path = "Else" - default: - bi.Path = cid[idx+1:] - } - } - - // Find target node name from edges - vm.mu.Lock() - if vm.task != nil && vm.task.Edges != nil { - for _, edge := range vm.task.Edges { - if edge.Source == cid { - // Found the edge from this condition - if targetNode, exists := vm.TaskNodes[edge.Target]; exists && targetNode != nil { - bi.TargetNodeName = targetNode.Name - } - break - } - } - } - vm.mu.Unlock() - } - } - } - // Get condition evaluations from metadata (new structured format) - if st.GetMetadata() != nil { - if metaMap, ok := st.GetMetadata().AsInterface().(map[string]interface{}); ok { - if evals, ok := metaMap["conditionEvaluations"].([]interface{}); ok { - for _, e := range evals { - if em, ok := e.(map[string]interface{}); ok { - bi.ConditionEvaluations = append(bi.ConditionEvaluations, em) - } - } - } - } - } - - // Fallback to config for backward compatibility - if len(bi.ConditionEvaluations) == 0 && st.GetConfig() != nil { - if cfgMap, ok := st.GetConfig().AsInterface().(map[string]interface{}); ok { - if conds, ok := cfgMap["conditions"].([]interface{}); ok { - for _, c := range conds { - if cm, ok := c.(map[string]interface{}); ok { - bi.Conditions = append(bi.Conditions, struct{ ID, Type, Expr string }{ - ID: asString(cm["id"]), - Type: asString(cm["type"]), - Expr: asString(cm["expression"]), - }) - } - } - } - } - } - branches = append(branches, bi) - } - // Count contract writes deterministically - if strings.Contains(t, "CONTRACT_WRITE") { - // Determine simulation mode strictly from node config (source of truth from client request) - // No fallbacks. If missing, treat as real execution (isSimulated=false). - isSimulated := false - if st.GetConfig() != nil { - if cfgMap, ok := st.GetConfig().AsInterface().(map[string]interface{}); ok { - if sim, ok := cfgMap["isSimulated"]; ok { - switch v := sim.(type) { - case bool: - isSimulated = v - case string: - isSimulated = strings.EqualFold(strings.TrimSpace(v), "true") - } - } - } - } - if st.GetSuccess() { - if isSimulated { - successfulSimulatedWrites++ - name := st.GetName() - if name == "" { - name = st.GetId() - } - if name != "" { - simulatedWriteNames = append(simulatedWriteNames, name) - } - } else { - successfulRealWrites++ - } - } else { - failedWrites++ - } - } - } - - // Build "What Executed Successfully" section with checkmarks for successful contract writes - successfulStepsOverview := buildStepsOverview(vm) - - // Build "What Went Wrong" section for failed steps - failedStepsOverview := buildFailedStepsOverview(vm) - - // Count failed nodes and check if there are any failures - failedNodeCount := 0 - hasFailures := false - for _, st := range vm.ExecutionLogs { - if !st.GetSuccess() { - hasFailures = true - failedNodeCount++ - } - } - - // Calculate total workflow steps consistently with vm_runner_rest.go - // Use executedSteps + skippedCount instead of getTotalWorkflowSteps(vm) - // because vm.TaskNodes includes nodes from ALL branch paths, not just the executed path - executedSteps := len(vm.ExecutionLogs) - skippedCount := len(skipped) - totalWorkflowSteps := executedSteps - if skippedCount > 0 { - totalWorkflowSteps = executedSteps + skippedCount - } - - // If there are no branches, no successful steps, and no failures, return empty - if len(branches) == 0 && strings.TrimSpace(successfulStepsOverview) == "" && !hasFailures { - return "", "" - } - - // Compose plain text - var tb []string - // Add Summary heading and one-line narrative - tb = append(tb, "Summary") - summaryLine := "" - if hasFailures { - summaryLine = fmt.Sprintf("%d out of %d nodes failed during execution.", failedNodeCount, totalWorkflowSteps) - } else if len(skipped) > 0 { - summaryLine = "The workflow did not fully execute. Some nodes were skipped due to branching conditions." - } else { - summaryLine = "All nodes were executed successfully." - } - tb = append(tb, summaryLine) - - // On-chain clause on separate line - onchainLine := "" - if successfulRealWrites > 0 { - if successfulSimulatedWrites > 0 { - // Both real and simulated transactions - onchainLine = fmt.Sprintf("Executed %d on-chain transaction(s) and %d simulated transaction(s).", successfulRealWrites, successfulSimulatedWrites) - } else { - // Only real transactions - onchainLine = fmt.Sprintf("Executed %d on-chain transaction(s).", successfulRealWrites) - } - } else if successfulSimulatedWrites > 0 { - // Include simulated node names with proper grammar - if len(simulatedWriteNames) > 0 { - nodeList := FormatStringListWithAnd(simulatedWriteNames) - onchainLine = fmt.Sprintf("No on-chain transactions were sent, as %s ran in simulation mode.", nodeList) - } else { - onchainLine = "No on-chain transactions were sent, as all contract write nodes ran in simulation mode." - } - } else if failedWrites > 0 { - onchainLine = "No on-chain transactions executed." - } else if successfulRealWrites == 0 && successfulSimulatedWrites == 0 { - onchainLine = "No on-chain transactions executed." - } - if onchainLine != "" { - tb = append(tb, onchainLine) - } - - // Add "What Went Wrong" section if there are failures - if strings.TrimSpace(failedStepsOverview) != "" { - tb = append(tb, "What Went Wrong") - // Split by newlines to add each step as a separate line - steps := strings.Split(failedStepsOverview, "\n") - for _, step := range steps { - if strings.TrimSpace(step) != "" { - tb = append(tb, step) - } - } - } - - // Add "What Executed Successfully" section if there are successful contract writes - if strings.TrimSpace(successfulStepsOverview) != "" { - tb = append(tb, "What Executed Successfully") - // Split by newlines to add each step as a separate line - steps := strings.Split(successfulStepsOverview, "\n") - for _, step := range steps { - if strings.TrimSpace(step) != "" { - tb = append(tb, step) - } - } - } - - if len(skipped) > 0 { - tb = append(tb, "") // blank line before Skipped nodes - tb = append(tb, "The below nodes were skipped due to branching conditions") - for _, n := range skipped { - tb = append(tb, "- "+n) - } - } - // Only show branch details if there are skipped nodes (branch-condition skip) - if len(skipped) > 0 { - for _, b := range branches { - path := normalizeBranchType(b.Path) - header := fmt.Sprintf("Branch '%s': selected %s condition", b.Step, path) - tb = append(tb, header) - - // Use new structured evaluation data if available - if len(b.ConditionEvaluations) > 0 { - for _, eval := range b.ConditionEvaluations { - label := asString(eval["label"]) - expr := asString(eval["expression"]) - result, _ := eval["result"].(bool) - taken, _ := eval["taken"].(bool) - - if taken { - tb = append(tb, fmt.Sprintf("- %s (selected)", label)) - } else if result { - tb = append(tb, fmt.Sprintf("- %s: true", label)) - } else { - if strings.TrimSpace(expr) == "" { - tb = append(tb, fmt.Sprintf("- %s: false", label)) - } else { - tb = append(tb, fmt.Sprintf("- %s: false -> %s", label, expr)) - } - } - } - } else if len(b.Conditions) > 0 { - // Fallback to old format for backward compatibility - tb = append(tb, "Conditions:") - for _, c := range b.Conditions { - label := normalizeBranchType(c.Type) - if strings.TrimSpace(c.Expr) == "" { - tb = append(tb, fmt.Sprintf("- %s", label)) - } else { - tb = append(tb, fmt.Sprintf("- %s -> %s", label, c.Expr)) - } - } - } - } - } - text := strings.Join(tb, "\n") - - // Compose minimal HTML (improved spacing for readability) - var hb []string - // Summary heading and sentence - hb = append(hb, "
Summary
") - hb = append(hb, "

"+html.EscapeString(summaryLine)+"

") - // On-chain line on separate paragraph - if onchainLine != "" { - hb = append(hb, "

"+html.EscapeString(onchainLine)+"

") - } - - // Add "What Went Wrong" section if there are failures - if strings.TrimSpace(failedStepsOverview) != "" { - hb = append(hb, "
What Went Wrong
") - // Split by newlines to add each step as a separate paragraph - steps := strings.Split(failedStepsOverview, "\n") - for _, step := range steps { - if strings.TrimSpace(step) != "" { - // Escape HTML but preserve the X mark character - escapedStep := html.EscapeString(step) - hb = append(hb, "

"+escapedStep+"

") - } - } - } - - // Add "What Executed Successfully" section if there are successful contract writes - if strings.TrimSpace(successfulStepsOverview) != "" { - hb = append(hb, "
What Executed Successfully
") - // Split by newlines to add each step as a separate paragraph - steps := strings.Split(successfulStepsOverview, "\n") - for _, step := range steps { - if strings.TrimSpace(step) != "" { - // Escape HTML but preserve the checkmark character - escapedStep := html.EscapeString(step) - hb = append(hb, "

"+escapedStep+"

") - } - } - } - - // Spacer before Skipped nodes (only if there are skipped nodes or branches) - if len(skipped) > 0 || len(branches) > 0 { - hb = append(hb, "
") - } - if len(skipped) > 0 { - hb = append(hb, "
The below nodes were skipped due to branching conditions
") - hb = append(hb, "
    ") - for _, n := range skipped { - hb = append(hb, "
  • "+html.EscapeString(n)+"
  • ") - } - hb = append(hb, "
") - } - // Only show branch details if there are skipped nodes - if len(skipped) > 0 { - for _, b := range branches { - path := normalizeBranchType(b.Path) - // Include target node in header, matching the execution log format - headerText := fmt.Sprintf("Branch '%s': selected %s condition", b.Step, path) - if b.TargetNodeName != "" { - headerText += fmt.Sprintf(" -> led to node '%s'", b.TargetNodeName) - } else { - headerText += " -> no next node" - } - hb = append(hb, fmt.Sprintf("
%s
", html.EscapeString(headerText))) - - // Use new structured evaluation data if available - if len(b.ConditionEvaluations) > 0 { - // Use execution log format (not bullet list) - for _, eval := range b.ConditionEvaluations { - label := asString(eval["label"]) - expr := asString(eval["expression"]) - taken, _ := eval["taken"].(bool) - - if taken { - // Condition that was selected - show comparison operands if available (like false conditions) - if strings.TrimSpace(expr) != "" { - hb = append(hb, fmt.Sprintf("

%s condition (selected)

", html.EscapeString(label))) - - // Check if we have structured comparison operand data (same as false condition logic) - operandData, hasOperandData := eval["variableValues"].(map[string]interface{}) - if hasOperandData { - leftExpr, hasLeft := operandData["leftExpr"].(string) - rightExpr, hasRight := operandData["rightExpr"].(string) - operator, hasOp := operandData["operator"].(string) - if hasLeft && hasRight && hasOp && leftExpr != "" && rightExpr != "" && operator != "" { - // This is comparison operand data - format it using shared formatter - comparisonHTML := formatComparisonForHTML(operandData) - if comparisonHTML != "" { - hb = append(hb, comparisonHTML) - } else { - // Fallback to showing expression - hb = append(hb, fmt.Sprintf("

Expression: %s

", html.EscapeString(expr))) - } - } else { - // Not comparison data - show expression - hb = append(hb, fmt.Sprintf("

Expression: %s

", html.EscapeString(expr))) - } - } else { - // No operand data - just show expression - hb = append(hb, fmt.Sprintf("

Expression: %s

", html.EscapeString(expr))) - } - } - } else { - // False condition - show comparison operands if available, otherwise fall back to expression - if strings.TrimSpace(expr) == "" { - hb = append(hb, fmt.Sprintf("

%s condition resolved to false (empty)

", html.EscapeString(label))) - } else { - hb = append(hb, fmt.Sprintf("

%s condition resolved to false

", html.EscapeString(label))) - - // Check if we have structured comparison operand data (from consolidated parser) - // Comparison operand data has leftExpr, rightExpr, operator keys - operandData, hasOperandData := eval["variableValues"].(map[string]interface{}) - if hasOperandData { - leftExpr, hasLeft := operandData["leftExpr"].(string) - rightExpr, hasRight := operandData["rightExpr"].(string) - operator, hasOp := operandData["operator"].(string) - if hasLeft && hasRight && hasOp && leftExpr != "" && rightExpr != "" && operator != "" { - // This is comparison operand data - format it using shared formatter - comparisonHTML := formatComparisonForHTML(operandData) - if comparisonHTML != "" { - hb = append(hb, comparisonHTML) - } else { - // Fallback to showing expression - hb = append(hb, fmt.Sprintf("

Expression: %s

", html.EscapeString(expr))) - } - } else { - // Not comparison data - show expression - hb = append(hb, fmt.Sprintf("

Expression: %s

", html.EscapeString(expr))) - } - } else { - // No operand data - just show expression - hb = append(hb, fmt.Sprintf("

Expression: %s

", html.EscapeString(expr))) - } - } - } - } - // Add spacing after branch details - hb = append(hb, "
") - } else if len(b.Conditions) > 0 { - // Fallback to old format for backward compatibility - hb = append(hb, "
    ") - for _, c := range b.Conditions { - expr := html.EscapeString(c.Expr) - label := html.EscapeString(normalizeBranchType(c.Type)) - if strings.TrimSpace(expr) == "" { - hb = append(hb, fmt.Sprintf("
  • %s
  • ", label)) - } else { - hb = append(hb, fmt.Sprintf("
  • %s → %s
  • ", label, expr)) - } - } - hb = append(hb, "
") - } - } - } - htmlOut := strings.Join(hb, "\n") - return text, htmlOut -} - -// asString converts interface{} to string safely. -func asString(v interface{}) string { - if v == nil { - return "" - } - if s, ok := v.(string); ok { - return s - } - return fmt.Sprintf("%v", v) -} - -// normalizeBranchType maps various case/styles to If/ElseIf/Else titles. -func normalizeBranchType(t string) string { - x := strings.TrimSpace(strings.ToLower(t)) - switch x { - case "if": - return "If" - case "elseif", "else if", "else_if": - return "ElseIf" - case "else": - return "Else" - default: - if x == "0" { - return "If" - } - if x == "1" { - return "Else" - } - return strings.Title(strings.TrimSpace(t)) - } -} - -// computeExecutionStatus determines the workflow execution status for the -// deterministic summarizer. Returns "success" or "failed" — "error" is -// reserved for VM-level failures that prevent a Summary from being built at -// all, so this function never emits it. The yellow "warn" badge is derived -// separately by the renderer from (Status=="success" && SkippedSteps>0). -func computeExecutionStatus(vm *VM) string { - if vm == nil { - return "failed" - } - - for _, st := range vm.ExecutionLogs { - if !st.GetSuccess() { - return "failed" - } - } - - return "success" -} - -// extractTriggerInfo extracts the trigger description and timestamp from ExecutionLogs -// Returns (triggerDescription, triggeredAtISO8601) -func extractTriggerInfo(vm *VM) (triggerDesc, triggeredAt string) { - if vm == nil { - return "", "" - } - - // Find the trigger step (first step with TRIGGER in type) - for _, st := range vm.ExecutionLogs { - stepType := strings.ToUpper(st.GetType()) - if !strings.Contains(stepType, "TRIGGER") { - continue - } - - // Extract timestamp from trigger output - if st.GetStartAt() > 0 { - triggeredAt = time.UnixMilli(st.GetStartAt()).UTC().Format(time.RFC3339) - } - - // Build trigger description based on trigger type - triggerDesc = buildTriggerDescription(st, vm) - return triggerDesc, triggeredAt - } - - return "", "" -} - -// buildTriggerDescription builds a human-readable trigger description -func buildTriggerDescription(st *avsproto.Execution_Step, vm *VM) string { - stepType := strings.ToUpper(st.GetType()) - isSimulated := vm != nil && vm.IsSimulation - prefix := "" - if isSimulated { - prefix = "(Simulated) " - } - - chainName := "" - if vm != nil { - chainName = resolveChainName(vm) - } - chainSuffix := "" - if chainName != "" { - chainSuffix = " on " + chainName - } - - // Event trigger (Transfer, conditional thresholds, etc.) - if strings.Contains(stepType, "EVENT") { - var data map[string]interface{} - if eventOutput := st.GetEventTrigger(); eventOutput != nil && eventOutput.Data != nil { - if d, ok := eventOutput.Data.AsInterface().(map[string]interface{}); ok { - data = d - if transfer, ok := data["Transfer"].(map[string]interface{}); ok { - to := shortHexAddr(fmt.Sprintf("%v", transfer["to"])) - value := fmt.Sprintf("%v", transfer["value"]) - return fmt.Sprintf("%sTransfer event detected: sent %s to %s%s", prefix, value, to, chainSuffix) - } - } - } - if desc := describeEventCondition(taskEventTrigger(vm), data); desc != "" { - return prefix + desc + chainSuffix - } - return prefix + "Event trigger activated" + chainSuffix - } - - // Block trigger - if strings.Contains(stepType, "BLOCK") { - if blockTrigger := st.GetBlockTrigger(); blockTrigger != nil && blockTrigger.Data != nil { - if data, ok := blockTrigger.Data.AsInterface().(map[string]interface{}); ok { - if blockNum, ok := data["blockNumber"].(float64); ok && blockNum > 0 { - return fmt.Sprintf("%sBlock %d was mined%s", prefix, int64(blockNum), chainSuffix) - } - } - } - return prefix + "Block trigger activated" + chainSuffix - } - - // Cron trigger - if strings.Contains(stepType, "CRON") { - return prefix + "Scheduled task ran" + chainSuffix - } - - // Fixed time trigger - if strings.Contains(stepType, "FIXED") { - return prefix + "Scheduled time was reached" + chainSuffix - } - - // Manual trigger - if strings.Contains(stepType, "MANUAL") { - return prefix + "Workflow was manually triggered" + chainSuffix - } - - return prefix + "Workflow triggered" + chainSuffix -} - -// taskEventTrigger returns the configured EventTrigger from the task definition, -// or nil if the VM has no task or the trigger isn't an event trigger. -func taskEventTrigger(vm *VM) *avsproto.EventTrigger { - if vm == nil || vm.task == nil || vm.task.Task == nil { - return nil - } - return vm.task.Task.Trigger.GetEvent() -} - -// describeEventCondition renders the first user-defined threshold condition as -// human-readable text (e.g. "AnswerUpdated current dropped below 50000 (actual: 47231)"). -// Returns "" when no usable condition exists; callers should fall back to a generic phrase. -func describeEventCondition(eventTrigger *avsproto.EventTrigger, data map[string]interface{}) string { - if eventTrigger == nil { - return "" - } - cfg := eventTrigger.GetConfig() - if cfg == nil { - return "" - } - for _, q := range cfg.GetQueries() { - for _, c := range q.GetConditions() { - fieldName := c.GetFieldName() - operator := c.GetOperator() - threshold := c.GetValue() - if fieldName == "" || operator == "" || threshold == "" { - continue - } - readable := strings.ReplaceAll(fieldName, ".", " ") - opText := formatConditionOperator(operator) - actual := extractEventFieldValue(fieldName, data) - if actual != "" { - return fmt.Sprintf("%s %s %s (actual: %s)", readable, opText, threshold, actual) - } - return fmt.Sprintf("%s %s %s", readable, opText, threshold) - } - } - return "" -} - -// extractEventFieldValue walks a dotted field path (e.g. "AnswerUpdated.current") -// through the decoded event data and returns the leaf value as a string. Returns -// "" when any segment is missing. -func extractEventFieldValue(fieldName string, data map[string]interface{}) string { - if data == nil || fieldName == "" { - return "" - } - var cur interface{} = data - for _, part := range strings.Split(fieldName, ".") { - m, ok := cur.(map[string]interface{}) - if !ok { - return "" - } - cur = m[part] - } - if cur == nil { - return "" - } - return fmt.Sprintf("%v", cur) -} - -func formatConditionOperator(op string) string { - switch strings.ToLower(op) { - case "gt", "greater_than": - return "rose above" - case "gte": - return "reached or exceeded" - case "lt", "less_than": - return "dropped below" - case "lte": - return "dropped to or below" - case "eq": - return "equaled" - case "ne": - return "differed from" - } - return op -} - -// buildExecutionsArray builds an array of on-chain execution entries from successful CONTRACT_WRITE steps -func buildExecutionsArray(vm *VM) []ExecutionEntry { - if vm == nil { - return nil - } - - var executions []ExecutionEntry - - // Get token context for formatting - vm.mu.Lock() - var settings map[string]interface{} - if m, ok := vm.vars["settings"].(map[string]interface{}); ok { - settings = m - } - vm.mu.Unlock() - - token0Sym, token0Dec := "", 18 - token1Sym, token1Dec := "", 18 - if settings != nil { - if pool, ok := settings["uniswapv3_pool"].(map[string]interface{}); ok { - if t0, ok := pool["token0"].(map[string]interface{}); ok { - if s, ok := t0["symbol"].(string); ok { - token0Sym = s - token0Dec = defaultDecimalsForSymbol(s) - } - } - if t1, ok := pool["token1"].(map[string]interface{}); ok { - if s, ok := t1["symbol"].(string); ok { - token1Sym = s - token1Dec = defaultDecimalsForSymbol(s) - } - } - } - } - - isSimulated := vm.IsSimulation - prefix := "" - if isSimulated { - prefix = "(Simulated) " - } - - for _, st := range vm.ExecutionLogs { - if !st.GetSuccess() { - continue - } - stepType := strings.ToUpper(st.GetType()) - if !strings.Contains(stepType, "CONTRACT_WRITE") { - continue - } - - // Extract method name from config - methodName := "" - if st.GetConfig() != nil { - if cfg, ok := st.GetConfig().AsInterface().(map[string]interface{}); ok { - if mcs, ok := cfg["methodCalls"].([]interface{}); ok && len(mcs) > 0 { - if call, ok := mcs[0].(map[string]interface{}); ok { - if mn, ok := call["methodName"].(string); ok { - methodName = strings.ToLower(mn) - } - } - } - } - } - - // Build execution description based on method - desc := buildContractWriteDescription(st, methodName, token0Sym, token0Dec, token1Sym, token1Dec, prefix) - if desc != "" { - entry := ExecutionEntry{Description: desc} - // Only attach txHash for real (non-simulated) steps with valid tx hashes. - // Simulated steps (Tenderly forks) produce raw BigInt values as "transactionHash" - // which aren't real tx hashes and would generate broken explorer links. - if !isStepSimulated(st) && st.GetMetadata() != nil { - if meta, ok := st.GetMetadata().AsInterface().(map[string]interface{}); ok { - if txHash, ok := meta["transactionHash"].(string); ok && isValidTxHash(txHash) { - entry.TxHash = txHash - } - } else if metaArr, ok := st.GetMetadata().AsInterface().([]interface{}); ok && len(metaArr) > 0 { - if first, ok := metaArr[0].(map[string]interface{}); ok { - if txHash, ok := first["transactionHash"].(string); ok && isValidTxHash(txHash) { - entry.TxHash = txHash - } - } - } - } - executions = append(executions, entry) - } - } - - return executions -} - -// buildContractWriteDescription builds a human-readable description for a CONTRACT_WRITE step -func buildContractWriteDescription(st *avsproto.Execution_Step, methodName string, token0Sym string, token0Dec int, token1Sym string, token1Dec int, prefix string) string { - // Extract spender for approve - if methodName == "approve" { - spender := "" - value := "" - if st.GetContractWrite() != nil && st.GetContractWrite().Data != nil { - if m, ok := st.GetContractWrite().Data.AsInterface().(map[string]interface{}); ok { - if ev, ok := m["Approval"].(map[string]interface{}); ok { - if v, ok := ev["value"].(string); ok { - value = v - } - if sp, ok := ev["spender"].(string); ok { - spender = sp - } - } - } - } - if value != "" && token1Sym != "" { - return fmt.Sprintf("%sApproved %s %s to %s for trading", prefix, formatAmount(value, token1Dec), token1Sym, shortHexAddr(spender)) - } - return fmt.Sprintf("%sApproved tokens to %s", prefix, shortHexAddr(spender)) - } - - // Swap operations - if methodName == "exactinputsingle" || methodName == "exactoutputsingle" { - amountOut := "" - if st.Metadata != nil { - if meta := st.Metadata.AsInterface(); meta != nil { - if arr, ok := meta.([]interface{}); ok && len(arr) > 0 { - if first, ok := arr[0].(map[string]interface{}); ok { - if val, ok := first["value"].(map[string]interface{}); ok { - if amt, ok := val["amountOut"].(string); ok { - amountOut = amt - } - } else if amtStr, ok := first["value"].(string); ok { - amountOut = amtStr - } - } - } - } - } - if amountOut != "" && token0Sym != "" { - return fmt.Sprintf("%sSwapped for ~%s %s via Uniswap V3", prefix, formatAmount(amountOut, token0Dec), token0Sym) - } - return prefix + "Swapped tokens via Uniswap V3" - } - - // Generic contract write - stepName := st.GetName() - if stepName == "" { - stepName = st.GetId() - } - if methodName != "" { - return fmt.Sprintf("%sExecuted %s on %s", prefix, methodName, stepName) - } - return fmt.Sprintf("%sExecuted contract write: %s", prefix, stepName) -} - -// buildErrorsArray builds an array of error descriptions from failed steps and skipped nodes -func buildErrorsArray(vm *VM, currentStepName string) []string { - if vm == nil { - return nil - } - - var errors []string - - // Add failed steps - for _, st := range vm.ExecutionLogs { - if st.GetSuccess() { - continue - } - name := st.GetName() - if name == "" { - name = st.GetId() - } - errorMsg := st.GetError() - if errorMsg == "" { - errorMsg = "unknown error" - } - errors = append(errors, fmt.Sprintf("%s: %s", safeName(name), firstLine(errorMsg))) - } - - // Add skipped nodes - vm.mu.Lock() - executed := make(map[string]struct{}) - for _, st := range vm.ExecutionLogs { - name := st.GetName() - if name == "" { - name = st.GetId() - } - if name != "" { - executed[name] = struct{}{} - } - } - // Include current step if provided - if currentStepName != "" { - executed[currentStepName] = struct{}{} - } - - for nodeID, n := range vm.TaskNodes { - if n == nil { - continue - } - // Skip branch condition nodes - if strings.Contains(nodeID, ".") { - continue - } - // Exclude notification nodes from skipped count - if isNotificationNode(n) { - continue - } - if _, ok := executed[n.Name]; !ok { - errors = append(errors, fmt.Sprintf("%s - skipped due to branch condition", safeName(n.Name))) - } - } - vm.mu.Unlock() - - return errors -} - -// composePlainTextBodyFromStructured composes a plain text body from the structured fields -func composePlainTextBodyFromStructured(trigger string, executions []ExecutionEntry, errors []string, status string) string { - var sb strings.Builder - - // Trigger - if trigger != "" { - sb.WriteString("Trigger: ") - sb.WriteString(trigger) - sb.WriteString("\n\n") - } - - // Executions - if len(executions) > 0 { - sb.WriteString("What Executed On-Chain:\n") - for _, exec := range executions { - sb.WriteString("✓ ") - sb.WriteString(exec.Description) - sb.WriteString("\n") - } - } - - // Errors (only for failed status) - if status == "failed" && len(errors) > 0 { - if len(executions) > 0 { - sb.WriteString("\n") - } - sb.WriteString("What Went Wrong:\n") - for _, err := range errors { - sb.WriteString("✗ ") - sb.WriteString(err) - sb.WriteString("\n") - } - } - - return strings.TrimSpace(sb.String()) -} - -// ComposeSummary generates a conservative, deterministic summary based on the VM's -// currently available execution context. It never panics and always returns a fallback. -// -// Semantics (MVP): -// - If any prior step failed, subject: "{workflowName}: failed at {stepName}", -// body: "The workflow failed at step \"{stepName}\". Reason: {first line of error}" (truncated internally) -// - Otherwise, subject: "{workflowName}: succeeded", body: "Finished {lastStepName}." -// - workflowName: settings.name || "Workflow" -// - step selection: earliest failure; else last successful step if any; else currentStepName fallback -func ComposeSummary(vm *VM, currentStepName string) Summary { - workflowName := resolveWorkflowName(vm) - failed, failedName, failedReason := findEarliestFailure(vm) - - // Get total workflow steps (trigger + all nodes) and executed steps count - totalWorkflowSteps := getTotalWorkflowSteps(vm) - - // Check for skipped nodes (for partial execution detection) - // Exclude notification nodes (email/telegram) from skipped count, similar to getTotalWorkflowSteps - vm.mu.Lock() - skippedCount := 0 - executed := make(map[string]struct{}) - for _, st := range vm.ExecutionLogs { - name := st.GetName() - if name == "" { - name = st.GetId() - } - if name != "" { - executed[name] = struct{}{} - } - } - - // Build a set of notification node names so we can exclude them from - // both the skipped count and the executed count (matching getTotalWorkflowSteps). - notificationNames := make(map[string]struct{}) - for nodeID, n := range vm.TaskNodes { - if n == nil { - continue - } - // Skip branch condition nodes - if strings.Contains(nodeID, ".") { - continue - } - if isNotificationNode(n) { - notificationNames[n.Name] = struct{}{} - continue - } - if _, ok := executed[n.Name]; !ok { - skippedCount++ - } - } - vm.mu.Unlock() - - // Count executed steps, excluding notification nodes so the count is - // consistent with getTotalWorkflowSteps (which also excludes them). - executedSteps := 0 - for _, st := range vm.ExecutionLogs { - name := st.GetName() - if name == "" { - name = st.GetId() - } - if _, isNotif := notificationNames[name]; isNotif { - continue - } - executedSteps++ - } - - // Build subject up-front to support richer failure bodies - singleNode := isSingleNodeImmediate(vm) - - var subject string - var summaryLine string - - // singleNode (run_node_immediately) is checked first because it also sets IsSimulation=true, - // but should always use the "Run Node:" prefix to distinguish from multi-node simulate_task. - if singleNode { - // The single node is actively executing when ComposeSummary is called, - // so count it as executed (ExecutionLogs is empty because the node - // hasn't finished yet — it's the notification node itself). - executedSteps = 1 - - // run_node_immediately entry - single-node format (regardless of simulation mode) - if failed { - subject = fmt.Sprintf("Run Node: %s failed at %s", workflowName, safeName(failedName)) - } else { - subject = fmt.Sprintf("Run Node: %s succeeded", workflowName) - } - // Summary line for single-node workflows - summaryLine = fmt.Sprintf("Your workflow '%s' executed %d out of %d total steps", workflowName, executedSteps, totalWorkflowSteps) - } else if vm.IsSimulation { - // simulate_task entry - multi-node simulation format - if failed { - subject = fmt.Sprintf("Simulation: %s failed to execute", workflowName) - } else if skippedCount > 0 { - subject = fmt.Sprintf("Simulation: %s partially executed", workflowName) - } else { - subject = fmt.Sprintf("Simulation: %s successfully completed", workflowName) - } - // Summary line format: "Your workflow 'Test Stoploss' executed 7 out of 7 total steps" - summaryLine = fmt.Sprintf("Your workflow '%s' executed %d out of %d total steps", workflowName, executedSteps, totalWorkflowSteps) - } else { - // For deployed workflows, use the original format - subject = fmt.Sprintf("%s: succeeded (%d out of %d steps)", workflowName, executedSteps, totalWorkflowSteps) - if failed { - subject = fmt.Sprintf("%s: failed at %s (%d out of %d steps)", workflowName, failedName, executedSteps, totalWorkflowSteps) - } - // Summary line for deployed workflows - summaryLine = fmt.Sprintf("Your workflow '%s' executed %d out of %d total steps", workflowName, executedSteps, totalWorkflowSteps) - } - - annotation := "" - if singleNode { - annotation = ExampleExecutionAnnotation - } - - // Extract runner smart wallet and owner EOA from vm.task directly. - smartWallet := "" - ownerEOA := "" - if vm.task != nil && vm.task.Task != nil { - smartWallet = vm.task.SmartWalletAddress - ownerEOA = vm.task.Owner - } - // Fallback for single-node executions where vm.task may be nil - vm.mu.Lock() - if smartWallet == "" { - if aaSender, ok := vm.vars["aa_sender"].(string); ok && aaSender != "" { - smartWallet = aaSender - } - } - if smartWallet == "" { - if settings, ok := vm.vars["settings"].(map[string]interface{}); ok { - if runner, ok := settings["runner"].(string); ok && strings.TrimSpace(runner) != "" { - smartWallet = runner - } - } - } - if ownerEOA == "" && vm.TaskOwner != (common.Address{}) { - ownerEOA = vm.TaskOwner.Hex() - } - vm.mu.Unlock() - - // Collect concise on-chain action lines from executed steps - var actionLines []string - for _, st := range vm.ExecutionLogs { - if !st.GetSuccess() { - continue - } - t := strings.ToUpper(st.GetType()) - isWrite := strings.Contains(t, "CONTRACT_WRITE") - isRead := strings.Contains(t, "CONTRACT_READ") - if !isWrite && !isRead { - continue - } - - contractAddr := "" - methodName := "" - if st.GetConfig() != nil { - if cfg, ok := st.GetConfig().AsInterface().(map[string]interface{}); ok { - if addr, ok := cfg["contractAddress"].(string); ok { - contractAddr = addr - } - if mcs, ok := cfg["methodCalls"].([]interface{}); ok && len(mcs) > 0 { - if call, ok := mcs[0].(map[string]interface{}); ok { - if mn, ok := call["methodName"].(string); ok { - methodName = mn - } - } - } - } - } - - // Include key output data when available (amountOut for swaps, approve value) - outDetail := "" - if isWrite && st.GetContractWrite() != nil && st.GetContractWrite().Data != nil { - if m, ok := st.GetContractWrite().Data.AsInterface().(map[string]interface{}); ok { - // Try common event shapes first (Approval, Swap) - if ev, ok := m["Approval"].(map[string]interface{}); ok { - if val, ok := ev["value"].(string); ok && val != "" { - outDetail = fmt.Sprintf("value=%s", val) - } - } - if ev, ok := m["Swap"].(map[string]interface{}); ok && outDetail == "" { - // Prefer amountOut from event amounts if present - if amt, ok := ev["amount1"].(string); ok && amt != "" { // token1 out in USDC/WETH pool often amount1 - outDetail = fmt.Sprintf("amountOut=%s", amt) - } else if amt0, ok := ev["amount0"].(string); ok && amt0 != "" { - outDetail = fmt.Sprintf("amountOut=%s", amt0) - } - } - } - // Fallback to metadata.value.amountOut for writes returning a value (e.g., exactInputSingle) - if outDetail == "" && st.Metadata != nil { - if meta := st.Metadata.AsInterface(); meta != nil { - if arr, ok := meta.([]interface{}); ok && len(arr) > 0 { - if first, ok := arr[0].(map[string]interface{}); ok { - if val, ok := first["value"].(map[string]interface{}); ok { - if amt, ok := val["amountOut"].(string); ok && amt != "" { - outDetail = fmt.Sprintf("amountOut=%s", amt) - } - } else if amtStr, ok := first["value"].(string); ok && amtStr != "" && strings.ToLower(methodName) == "exactinputsingle" { - outDetail = fmt.Sprintf("amountOut=%s", amtStr) - } - } - } - } - } - } - if isRead && st.GetContractRead() != nil && st.GetContractRead().Data != nil { - if m, ok := st.GetContractRead().Data.AsInterface().(map[string]interface{}); ok { - if v, ok := m[methodName].(map[string]interface{}); ok { - if amt, ok := v["amountOut"].(string); ok && amt != "" { - outDetail = fmt.Sprintf("amountOut=%s", amt) - } - } - } - } - - line := fmt.Sprintf("- %s: %s on %s", st.GetName(), methodName, contractAddr) - if outDetail != "" { - line += fmt.Sprintf(" (%s)", outDetail) - } - actionLines = append(actionLines, line) - } - - // Single-node execution enhancement: if there are no recorded steps, try to - // synthesize concise action lines from inputVariables (carried into the - // runNodeImmediately call) and settings. This helps users understand what - // happened even when the workflow isn't deployed end-to-end yet. - if totalWorkflowSteps == 1 && len(actionLines) == 0 && isSingleNodeImmediate(vm) { - vm.mu.Lock() - var settings map[string]interface{} - if m, ok := vm.vars["settings"].(map[string]interface{}); ok { - settings = m - } - // Read inputs directly from top-level vars (RunNodeWithInputs adds them as top-level) - approveVar, hasApprove := vm.vars["approve_token1"].(map[string]interface{}) - quoteVar, hasQuote := vm.vars["get_quote"].(map[string]interface{}) - swapVar, hasSwap := vm.vars["run_swap"].(map[string]interface{}) - vm.mu.Unlock() - - if hasApprove || hasQuote || hasSwap { - // Helpful addresses from settings - var token1Addr, quoterV2Addr, swapRouter02Addr string - if pool, ok := settings["uniswapv3_pool"].(map[string]interface{}); ok { - if t1, ok := pool["token1"].(map[string]interface{}); ok { - if id, ok := t1["id"].(string); ok { - token1Addr = id - } - } - } - if contracts, ok := settings["uniswapv3_contracts"].(map[string]interface{}); ok { - if a, ok := contracts["quoterV2"].(string); ok { - quoterV2Addr = a - } - if a, ok := contracts["swapRouter02"].(string); ok { - swapRouter02Addr = a - } - } - - // approve_token1 - if hasApprove { - if data, ok := approveVar["data"].(map[string]interface{}); ok { - if approve, ok := data["approve"].(map[string]interface{}); ok { - val := "" - if s, ok := approve["value"].(string); ok && s != "" { - val = s - } - line := fmt.Sprintf("- approve_token1: approve on %s", token1Addr) - if val != "" { - line += fmt.Sprintf(" (value=%s)", val) - } - actionLines = append(actionLines, line) - } - } - } - - // get_quote - if hasQuote { - if data, ok := quoteVar["data"].(map[string]interface{}); ok { - if q, ok := data["quoteExactInputSingle"].(map[string]interface{}); ok { - amt := "" - if s, ok := q["amountOut"].(string); ok && s != "" { - amt = s - } - line := fmt.Sprintf("- get_quote: quoteExactInputSingle on %s", quoterV2Addr) - if amt != "" { - line += fmt.Sprintf(" (amountOut=%s)", amt) - } - actionLines = append(actionLines, line) - } - } - } - - // run_swap - if hasSwap { - if data, ok := swapVar["data"].(map[string]interface{}); ok { - if swap, ok := data["exactInputSingle"].(map[string]interface{}); ok { - amt := "" - if s, ok := swap["amountOut"].(string); ok && s != "" { - amt = s - } - line := fmt.Sprintf("- run_swap: exactInputSingle on %s", swapRouter02Addr) - if amt != "" { - line += fmt.Sprintf(" (amountOut=%s)", amt) - } - actionLines = append(actionLines, line) - } - } - } - } - } - - var body string - if failed { - // Failure body: start with summary line to match context-memory API format - // Then include what executed successfully (if any) and what didn't run - successfulSteps := buildStepsOverview(vm) - - if strings.TrimSpace(successfulSteps) != "" { - // Use "What Executed On-Chain" for simulations, "What Executed Successfully" for deployed workflows - sectionHeading := "What Executed Successfully" - if vm.IsSimulation { - sectionHeading = "What Executed On-Chain" - } - // Include steps that succeeded before failure - // Body is plain text (no HTML tags) to match context-memory API format - body = fmt.Sprintf( - "%s\n\nSmart wallet %s (owner %s) started workflow execution but encountered a failure.\n\n%s\n%s\n\nWhat Didn't Run\nFailed at step '%s': %s", - summaryLine, smartWallet, ownerEOA, sectionHeading, successfulSteps, safeName(failedName), firstLine(failedReason), - ) - } else { - // No successful steps before failure - // Body is plain text (no HTML tags) to match context-memory API format - body = fmt.Sprintf( - "%s\n\nSmart wallet %s (owner %s) started workflow execution but encountered a failure.\n\nNo on-chain contract writes were completed before the failure.\n\nWhat Didn't Run\nFailed at step '%s': %s", - summaryLine, smartWallet, ownerEOA, safeName(failedName), firstLine(failedReason), - ) - } - } else { - // Success body with What Executed On-Chain format (matching AI summarizer output) - chainName := resolveChainName(vm) - actionCount := countActionsInLogs(vm) - - // Build What Executed On-Chain section with checkmarks for each successful contract write - successfulSteps := buildStepsOverview(vm) - - // Use "What Executed On-Chain" for simulations, "What Executed Successfully" for deployed workflows - sectionHeading := "What Executed Successfully" - if vm.IsSimulation { - sectionHeading = "What Executed On-Chain" - } - - if singleNode { - body = composeSingleNodeSuccessBody(vm, smartWallet, ownerEOA, chainName, successfulSteps, actionLines, actionCount, currentStepName) - } else { - if strings.TrimSpace(successfulSteps) != "" { - // Body format: plain text (no HTML tags) to match context-memory API format - // AnalysisHtml is now generated by buildAnalysisHtmlFromStructured() in SendGridDynamicData() - body = fmt.Sprintf( - "%s\n%s\n\nAll steps completed on %s.", - sectionHeading, successfulSteps, chainName, - ) - } else { - // Fallback when no contract writes are found - // Body should start with summary line to match context-memory API format - // This ensures consistency when context API is unreachable - body = fmt.Sprintf( - "%s\n\nNo on-chain contract writes were recorded. This may have been a read-only workflow or all steps were simulated.\n\nAll steps completed on %s.", - summaryLine, chainName, - ) - } - } - } - - if !failed { - if narrative := strings.TrimSpace(buildNarrativeFromLogs(vm)); narrative != "" { - if strings.TrimSpace(body) != "" { - body += "\n\n" + narrative - } else { - body = narrative - } - } - } - - // Compute structured fields for consistent output with context-memory API - status := computeExecutionStatus(vm) - trigger, triggeredAt := extractTriggerInfo(vm) - executions := buildExecutionsArray(vm) - errors := buildErrorsArray(vm, currentStepName) - - // If we have structured data, compose body from it for consistency - if trigger != "" || len(executions) > 0 || len(errors) > 0 { - structuredBody := composePlainTextBodyFromStructured(trigger, executions, errors, status) - if structuredBody != "" { - body = structuredBody - } - } - - // Fallback: when no on-chain executions were recorded and the run succeeded, - // add a generic execution entry so the structured formatters always have data. - // This is added AFTER the body composition so it doesn't override detailed bodies - // (e.g., single-node REST API summaries), but the Executions field is still - // populated for the channel formatters (Telegram/Discord/plain text). - // Only show example if there are no errors to avoid confusion. - if len(executions) == 0 && status == "success" && len(errors) == 0 { - prefix := "" - if vm.IsSimulation || isSingleNodeImmediate(vm) { - prefix = "(Simulated) " - } - executions = []ExecutionEntry{{Description: prefix + ExampleExecutionMessage}} - } - - // For single-node runs with no execution logs (the notification node itself is - // executing), recompose the body from structured data so the client response - // matches the formatted channel output. When ExecutionLogs exist, the body - // already has detailed info (REST API summary, action lines, etc.) that - // should be preserved. - if singleNode && len(vm.ExecutionLogs) == 0 { - if structuredBody := composePlainTextBodyFromStructured(trigger, executions, errors, status); structuredBody != "" { - body = structuredBody - } - } - - // Reuse the already-computed counts from earlier in ComposeSummary so the - // step counts on Summary match SummaryLine/subject (which exclude - // notification nodes via getTotalWorkflowSteps). - skippedNote := "" - if status == "success" && skippedCount > 0 { - skippedNote = buildSkippedNote(skippedCount) - } - - return Summary{ - Subject: subject, - Body: body, - SummaryLine: summaryLine, - Status: status, - Trigger: trigger, - TriggeredAt: triggeredAt, - Executions: executions, - Errors: errors, - SmartWallet: smartWallet, - Network: resolveChainName(vm), - Annotation: annotation, - SkippedNote: skippedNote, - ExecutedSteps: executedSteps, - TotalSteps: totalWorkflowSteps, - SkippedSteps: skippedCount, - Runner: buildRunnerFromVM(vm), - Fees: buildFeesFromVM(vm), - Workflow: &WorkflowInfo{IsSimulation: vm != nil && vm.IsSimulation}, - } -} - -// buildRunnerFromVM extracts the smart wallet and owner EOA from the VM's task -// state. Used by both ComposeSummary (deterministic) and ContextMemorySummarizer -// so notifications and SendGrid template variables share one source of truth. -// Falls back to settings.runner / vm.TaskOwner when task fields are absent. -func buildRunnerFromVM(vm *VM) *RunnerInfo { - if vm == nil { - return nil - } - smartWallet := "" - ownerEOA := "" - if vm.task != nil && vm.task.Task != nil { - smartWallet = vm.task.SmartWalletAddress - ownerEOA = vm.task.Owner - } - vm.mu.Lock() - if smartWallet == "" { - if aaSender, ok := vm.vars["aa_sender"].(string); ok && aaSender != "" { - smartWallet = aaSender - } - } - if smartWallet == "" { - if settings, ok := vm.vars["settings"].(map[string]interface{}); ok { - if runner, ok := settings["runner"].(string); ok && strings.TrimSpace(runner) != "" { - smartWallet = runner - } - } - } - vm.mu.Unlock() - if ownerEOA == "" && vm.TaskOwner != (common.Address{}) { - ownerEOA = vm.TaskOwner.Hex() - } - if smartWallet == "" && ownerEOA == "" { - return nil - } - return &RunnerInfo{SmartWallet: smartWallet, OwnerEOA: ownerEOA} -} - -// buildFeesFromVM computes the per-execution fee breakdown from VM state using -// the same helpers (buildExecutionFee / buildCOGSFromSteps / buildValueFee) -// that populate the persisted Execution.Fee — so notification fees match the -// stored execution exactly. Reads globalFeeRates (set at engine startup); -// nil falls back to GetDefaultFeeRatesConfig() defaults. -func buildFeesFromVM(vm *VM) *FeesInfo { - if vm == nil { - return nil - } - var taskNodes []*avsproto.TaskNode - if vm.task != nil && vm.task.Task != nil { - taskNodes = vm.task.Task.Nodes - } - - out := &FeesInfo{ - ExecutionFee: protoFeeToFeeAmount(buildExecutionFee(globalFeeRates)), - ValueFee: protoValueFeeToValueFee(buildValueFee(taskNodes, globalFeeRates)), - } - for _, c := range buildCOGSFromSteps(vm.ExecutionLogs) { - out.Cogs = append(out.Cogs, &NodeCOGS{ - NodeID: c.GetNodeId(), - CostType: c.GetCostType(), - Fee: protoFeeToFeeAmount(c.GetFee()), - GasUnits: c.GetGasUnits(), - }) - } - out.Total = buildTotalsFromVM(vm, out) - return out -} - -// buildTotalsFromVM computes the per-token fee total surfaced in notifications. -// Native token entry first (gas + executionFee + native value-fee), then -// per-token entries for any ERC20 value-fee legs. -// -// USD is populated when a price source is available (chain price service for -// native; Stablecoins map or PriceService.GetERC20PriceUSD for ERC20s); -// otherwise left empty so renderers print "$?". Zero-rounded entries are -// omitted. -func buildTotalsFromVM(vm *VM, fees *FeesInfo) []*TokenTotal { - if fees == nil { - return nil - } - - chainID := chainIDFromVM(vm) - nativePriceUSD := nativeTokenPriceUSD(chainID) - nativeSymbol := nativeTokenSymbol(chainID) - - // 1. Native-token bucket: gas cogs (WEI) + executionFee (USD → WEI). - gasWei := new(big.Int) - for _, c := range fees.Cogs { - if c == nil || c.Fee == nil { - continue - } - if w, ok := new(big.Int).SetString(c.Fee.Amount, 10); ok { - gasWei.Add(gasWei, w) - } - } - weiPerEth := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)) - nativeEth := new(big.Float).Quo(new(big.Float).SetInt(gasWei), weiPerEth) - // Platform (execution) fee is rendered as its own USD entry below — NOT - // folded into the native-ETH amount. Keeping it separate ensures (a) the - // fee is shown even when no price service is configured, (b) read-only - // runs that paid only the platform fee don't display a misleading - // "0.00000X ETH" gas-equivalent line, and (c) attribution stays clear - // (this much was gas, this much was the platform charge). - - // 2. Value-fee per-token aggregation. tier_percentage × tx_value in each - // transferred token's units, summed across loop iterations. - valueFeePct := valueFeePercentage(fees) - transfers := extractOutgoingTransfers(vm) - erc20Buckets := make(map[string]*tokenBucket) // key = lowercased contract addr - for _, t := range transfers { - feeRaw := percentOfRaw(t.rawAmount, valueFeePct) - if feeRaw == nil || feeRaw.Sign() == 0 { - continue - } - if t.contractAddress == "" { - // Native ETH transfer's value-fee adds to the native bucket. - feeEth := new(big.Float).Quo(new(big.Float).SetInt(feeRaw), weiPerEth) - nativeEth.Add(nativeEth, feeEth) - continue - } - key := strings.ToLower(t.contractAddress) - bucket, ok := erc20Buckets[key] - if !ok { - bucket = &tokenBucket{contract: key, decimals: t.decimals, symbol: t.symbol, raw: new(big.Int)} - erc20Buckets[key] = bucket - } - bucket.raw.Add(bucket.raw, feeRaw) - } - - out := make([]*TokenTotal, 0, 1+len(erc20Buckets)) - - // Native-token entry (always first, when non-zero). Marked IsGas so - // notification renderers can isolate the actual gas charge from the - // other Fees.Total entries (value fees, platform fee). - if nativeEth.Sign() > 0 { - entry := &TokenTotal{ - Amount: trimTrailingZeros(nativeEth.Text('f', 9)), - Unit: nativeSymbol, - IsGas: true, - } - if nativePriceUSD != nil { - entry.USD = trimTrailingZeros(new(big.Float).Mul(nativeEth, nativePriceUSD).Text('f', 2)) - } - if entry.Amount != "" && entry.Amount != "0" { - out = append(out, entry) - } - } - - // ERC20 entries (sorted by symbol for deterministic order). - keys := make([]string, 0, len(erc20Buckets)) - for k := range erc20Buckets { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { - return erc20Buckets[keys[i]].symbol < erc20Buckets[keys[j]].symbol - }) - for _, k := range keys { - entry := erc20Buckets[k].toTokenTotal(uint64(chainID)) - if entry == nil { - continue - } - out = append(out, entry) - } - - // Platform (execution) fee — appended last as a USD-denominated entry. - // Renderer special-cases Unit=="USD" to emit "$X platform fee" instead of - // the standard token-amount format. Always shown when non-zero so the - // user sees what they paid even when no price service is configured. - if fees.ExecutionFee != nil { - amount := trimTrailingZeros(strings.TrimSpace(fees.ExecutionFee.Amount)) - if amount != "" && amount != "0" { - out = append(out, &TokenTotal{Amount: amount, Unit: "USD", USD: amount}) - } - } - - return out -} - -// tokenBucket accumulates raw token amounts during transfer aggregation. -type tokenBucket struct { - contract string // lowercase - symbol string - decimals uint32 - raw *big.Int -} - -func (b *tokenBucket) toTokenTotal(chainID uint64) *TokenTotal { - if b == nil || b.raw == nil || b.raw.Sign() == 0 { - return nil - } - denom := new(big.Float).SetInt(new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(b.decimals)), nil)) - amount := new(big.Float).Quo(new(big.Float).SetInt(b.raw), denom) - formatted := trimTrailingZeros(amount.Text('f', int(b.decimals))) - if formatted == "" || formatted == "0" { - // Below the token's display precision — omit per V2 directive. - return nil - } - entry := &TokenTotal{Amount: formatted, Unit: b.symbol} - // Stablecoin shortcut — $1.00 without a network call. - if _, ok := LookupStablecoin(chainID, b.contract); ok { - entry.USD = trimTrailingZeros(amount.Text('f', 2)) - return entry - } - // Otherwise ask the price service. If unavailable, leave USD empty - // (renderer prints "$?"). - if globalPriceService != nil { - if price, err := globalPriceService.GetERC20PriceUSD(int64(chainID), b.contract); err == nil && price != nil { - usd := new(big.Float).Mul(amount, price) - entry.USD = trimTrailingZeros(usd.Text('f', 2)) - } - } - return entry -} - -// outgoingTransfer is one transfer OUT of the smart wallet — input to the -// value-fee leg calculation. -type outgoingTransfer struct { - contractAddress string // lowercase; empty for native ETH - symbol string // "ETH" / "USDC" / etc. - decimals uint32 // 18 for ETH, ERC20-specific otherwise - rawAmount *big.Int -} - -// extractOutgoingTransfers walks ExecutionLogs and returns transfers where -// `from == smartWallet`. Handles eth_transfer steps, single contractWrite -// transfer outputs, and loop iterations whose children produced transfers. -func extractOutgoingTransfers(vm *VM) []outgoingTransfer { - if vm == nil { - return nil - } - smartWallet := strings.ToLower(smartWalletAddressFromVM(vm)) - if smartWallet == "" { - return nil - } - chainID := uint64(chainIDFromVM(vm)) - - out := make([]outgoingTransfer, 0) - for _, step := range vm.ExecutionLogs { - if eth := step.GetEthTransfer(); eth != nil && eth.Data != nil { - if m, ok := eth.Data.AsInterface().(map[string]interface{}); ok { - if t := readTransferRecord(m, smartWallet, "", chainID); t != nil { - out = append(out, *t) - } - } - } - if cw := step.GetContractWrite(); cw != nil && cw.Data != nil { - if m, ok := cw.Data.AsInterface().(map[string]interface{}); ok { - if t := readTransferRecord(m, smartWallet, "", chainID); t != nil { - out = append(out, *t) - } - } - } - if loop := step.GetLoop(); loop != nil && loop.Data != nil { - if arr, ok := loop.Data.AsInterface().([]interface{}); ok { - for _, iter := range arr { - if m, ok := iter.(map[string]interface{}); ok { - if t := readTransferRecord(m, smartWallet, "", chainID); t != nil { - out = append(out, *t) - } - } - } - } - } - } - return out -} - -// readTransferRecord pulls a single outgoing-transfer record from an output -// payload. Expects shape: {transfer: {from, to, value}, metadata?: {contractAddress}}. -// Returns nil when the transfer is not from the smart wallet, or when the -// shape doesn't match. -func readTransferRecord(m map[string]interface{}, smartWallet, _ string, chainID uint64) *outgoingTransfer { - transfer, ok := m["transfer"].(map[string]interface{}) - if !ok { - return nil - } - from, _ := transfer["from"].(string) - if strings.ToLower(from) != smartWallet { - return nil - } - rawValue, _ := transfer["value"].(string) - rawInt, ok := new(big.Int).SetString(rawValue, 10) - if !ok || rawInt.Sign() == 0 { - return nil - } - // Native ETH transfer: no metadata.contractAddress; or contractAddress is empty. - contractAddr := "" - if meta, ok := m["metadata"].(map[string]interface{}); ok { - if c, ok := meta["contractAddress"].(string); ok { - contractAddr = strings.ToLower(c) - } - } - if contractAddr == "" { - return &outgoingTransfer{contractAddress: "", symbol: "ETH", decimals: 18, rawAmount: rawInt} - } - symbol, decimals := resolveTokenInfo(chainID, contractAddr) - return &outgoingTransfer{contractAddress: contractAddr, symbol: symbol, decimals: decimals, rawAmount: rawInt} -} - -// resolveTokenInfo returns (symbol, decimals) for an ERC20 contract using: -// 1. The Stablecoins fast-path map (no network call). -// 2. TokenEnrichmentService's metadata cache / RPC lookup — same source the -// /api/summarize request uses, so decimals here match what context-memory -// sees. -// -// Falls back to ("?", 18) when the address resolves to nothing — caller renders -// "$?" for USD; the 18-decimal default at least keeps the amount in the right -// order of magnitude for most tokens. -func resolveTokenInfo(chainID uint64, contractAddress string) (string, uint32) { - if info, ok := LookupStablecoin(chainID, contractAddress); ok { - return info.Symbol, info.Decimals - } - if svc := GetTokenEnrichmentService(); svc != nil { - if meta, err := svc.GetTokenMetadata(contractAddress); err == nil && meta != nil { - symbol := meta.Symbol - if symbol == "" { - symbol = "?" - } - decimals := meta.Decimals - if decimals == 0 { - decimals = 18 - } - return symbol, decimals - } - } - return "?", 18 -} - -// valueFeePercentage returns the tier percentage as a big.Float fraction -// (e.g., 0.03 → 0.0003), or nil when no value fee applies. -func valueFeePercentage(fees *FeesInfo) *big.Float { - if fees == nil || fees.ValueFee == nil || fees.ValueFee.Fee == nil { - return nil - } - pct, ok := new(big.Float).SetString(fees.ValueFee.Fee.Amount) - if !ok || pct.Sign() <= 0 { - return nil - } - return new(big.Float).Quo(pct, big.NewFloat(100)) -} - -// percentOfRaw returns floor(raw × pct) where pct is a fraction (0.0003 = 0.03%). -// Returns nil when inputs are zero or invalid. -func percentOfRaw(raw *big.Int, pct *big.Float) *big.Int { - if raw == nil || raw.Sign() == 0 || pct == nil || pct.Sign() <= 0 { - return nil - } - rawF := new(big.Float).SetInt(raw) - feeF := new(big.Float).Mul(rawF, pct) - feeI, _ := feeF.Int(nil) - return feeI -} - -// smartWalletAddressFromVM extracts the smart wallet address using the same -// fallback chain as buildRunnerFromVM but without constructing a RunnerInfo. -func smartWalletAddressFromVM(vm *VM) string { - if vm == nil { - return "" - } - if vm.task != nil && vm.task.Task != nil && vm.task.SmartWalletAddress != "" { - return vm.task.SmartWalletAddress - } - vm.mu.Lock() - defer vm.mu.Unlock() - if aaSender, ok := vm.vars["aa_sender"].(string); ok && aaSender != "" { - return aaSender - } - if settings, ok := vm.vars["settings"].(map[string]interface{}); ok { - if runner, ok := settings["runner"].(string); ok && strings.TrimSpace(runner) != "" { - return runner - } - } - return "" -} - -// chainIDFromVM resolves the chain ID from VM state. Returns 0 when unknown, -// in which case price-service lookups fall back to the unknown-chain default. -func chainIDFromVM(vm *VM) int64 { - if vm == nil { - return 0 - } - if vm.smartWalletConfig != nil && vm.smartWalletConfig.ChainID != 0 { - return vm.smartWalletConfig.ChainID - } - vm.mu.Lock() - defer vm.mu.Unlock() - if settings, ok := vm.vars["settings"].(map[string]interface{}); ok { - if id, ok := settings["chain_id"].(int64); ok { - return id - } - if id, ok := settings["chain_id"].(float64); ok { - return int64(id) - } - } - return 0 -} - -// nativeTokenPriceUSD returns the chain's native token price in USD (big.Float), -// or nil when the price service is unavailable. -func nativeTokenPriceUSD(chainID int64) *big.Float { - if globalPriceService == nil || chainID == 0 { - return nil - } - price, err := globalPriceService.GetNativeTokenPriceUSD(chainID) - if err != nil || price == nil { - return nil - } - return price -} - -// nativeTokenSymbol returns the chain's native token symbol (e.g., "ETH"), -// falling back to "ETH" when the price service is unavailable. -func nativeTokenSymbol(chainID int64) string { - if globalPriceService != nil && chainID != 0 { - if sym := globalPriceService.GetNativeTokenSymbol(chainID); sym != "" { - return sym - } - } - return "ETH" -} - -// trimTrailingZeros strips trailing zeros after the decimal point. "0.020000" → "0.02". -// Pure utility, kept here so the totals helper has a localized formatter. -func trimTrailingZeros(s string) string { - if !strings.Contains(s, ".") { - return s - } - s = strings.TrimRight(s, "0") - s = strings.TrimRight(s, ".") - if s == "" { - return "0" - } - return s -} - -func protoFeeToFeeAmount(f *avsproto.Fee) *FeeAmount { - if f == nil { - return nil - } - return &FeeAmount{Amount: f.GetAmount(), Unit: f.GetUnit()} -} - -func protoValueFeeToValueFee(v *avsproto.ValueFee) *ValueFee { - if v == nil { - return nil - } - return &ValueFee{ - Fee: protoFeeToFeeAmount(v.GetFee()), - Tier: v.GetTier().String(), - Reason: v.GetReason(), - } -} - -func composeSingleNodeSuccessBody(vm *VM, smartWallet, ownerEOA, chainName string, successfulSteps string, actionLines []string, actionCount int, fallbackStepName string) string { - trimmedSteps := strings.TrimSpace(successfulSteps) - if trimmedSteps != "" { - // Use "What Executed On-Chain" for simulations, "What Executed Successfully" for deployed workflows - sectionHeading := "What Executed Successfully" - if vm != nil && vm.IsSimulation { - sectionHeading = "What Executed On-Chain" - } - // Body is plain text (no HTML tags) to match context-memory API format - return fmt.Sprintf( - "Smart wallet %s (owner %s) executed %d on-chain action(s).\n\n%s\n%s\n\nAll steps completed on %s.", - displayOrUnknown(smartWallet), displayOrUnknown(ownerEOA), actionCount, sectionHeading, trimmedSteps, chainName, - ) - } - - if summary := buildSingleNodeRestSummary(vm, smartWallet, ownerEOA, chainName, fallbackStepName); summary != "" { - return summary - } - - if len(actionLines) > 0 { - return fmt.Sprintf( - "Smart wallet %s (owner %s) executed the node with the following activity:\n\n%s\n\nAll steps completed on %s.", - displayOrUnknown(smartWallet), - displayOrUnknown(ownerEOA), - strings.Join(actionLines, "\n"), - chainName, - ) - } - - description := "node execution" - if strings.TrimSpace(fallbackStepName) != "" { - description = fmt.Sprintf("the '%s' node", safeName(fallbackStepName)) - } - - return fmt.Sprintf( - "Smart wallet %s (owner %s) completed %s.\n\nAll steps completed on %s.", - displayOrUnknown(smartWallet), - displayOrUnknown(ownerEOA), - description, - chainName, - ) -} - -func buildSingleNodeRestSummary(vm *VM, smartWallet, ownerEOA, chainName string, fallbackStepName string) string { - step := findLastSuccessStep(vm) - if step == nil { - return "" - } - - stepType := strings.ToUpper(step.GetType()) - if !strings.Contains(stepType, "REST_API") { - return "" - } - - status, statusText, requestURL := extractRestStepInfo(step) - - statusSummary := "" - if status != 0 { - statusSummary = fmt.Sprintf("HTTP %d", status) - if strings.TrimSpace(statusText) != "" { - statusSummary += " " + strings.TrimSpace(statusText) - } - } else if strings.TrimSpace(statusText) != "" { - statusSummary = strings.TrimSpace(statusText) - } else { - statusSummary = "Notification provider responded" - } - - if strings.TrimSpace(requestURL) != "" { - provider := providerDisplayName(detectNotificationProvider(requestURL)) - if provider == "" { - provider = shortURLHost(requestURL) - } - if provider != "" { - statusSummary += fmt.Sprintf(" from %s", provider) - } - } - - stepName := step.GetName() - if strings.TrimSpace(stepName) == "" { - stepName = fallbackStepName - } - stepName = safeName(stepName) - return fmt.Sprintf( - "Smart wallet %s (owner %s) executed the '%s' REST API node.\n\n%s.\n\nAll steps completed on %s.", - displayOrUnknown(smartWallet), - displayOrUnknown(ownerEOA), - stepName, - statusSummary, - chainName, - ) -} - -// isSingleNodeImmediate returns true if the VM looks like a temp VM used for a -// one-off RunNodeImmediately execution (no TaskId, exactly one TaskNode). -func isSingleNodeImmediate(vm *VM) bool { - if vm == nil { - return false - } - // No lock needed for GetTaskId; lock to count TaskNodes safely - vm.mu.Lock() - count := len(vm.TaskNodes) - vm.mu.Unlock() - return vm.GetTaskId() == "" && count == 1 -} - -func extractRestStepInfo(step *avsproto.Execution_Step) (int, string, string) { - if step == nil { - return 0, "", "" - } - rest := step.GetRestApi() - if rest == nil || rest.Data == nil { - return 0, "", "" - } - - if dataMap, ok := rest.Data.AsInterface().(map[string]interface{}); ok { - status := convertInterfaceToInt(dataMap["status"]) - statusText := asString(dataMap["statusText"]) - requestURL := asString(dataMap["url"]) - return status, statusText, requestURL - } - - return 0, "", "" -} - -func convertInterfaceToInt(val interface{}) int { - switch v := val.(type) { - case int: - return v - case int32: - return int(v) - case int64: - return int(v) - case float32: - return int(v) - case float64: - return int(v) - case string: - if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { - return i - } - } - return 0 -} - -func providerDisplayName(provider string) string { - switch strings.ToLower(provider) { - case "studio-notify": - return "Ava Protocol" - case "telegram": - return "Telegram" - default: - return "" - } -} - -func shortURLHost(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - parsed, err := url.Parse(raw) - if err != nil { - return raw - } - host := parsed.Host - if host == "" { - return raw - } - path := strings.Trim(parsed.Path, "/") - if path != "" { - host = host + "/" + path - } - if len(host) > 64 { - return host[:61] + "..." - } - return host -} - -func displayOrUnknown(value string) string { - if strings.TrimSpace(value) == "" { - return "unknown" - } - return value -} - -// getTotalWorkflowSteps returns the total number of steps in a workflow, -// including the trigger and all nodes. This represents the full workflow size, -// not just the executed steps so far. -// If currentNodeName is provided, notification nodes (email/telegram) are excluded -// since they are the source of the summary, not part of the workflow logic. -func getTotalWorkflowSteps(vm *VM, currentNodeName ...string) int { - if vm == nil { - return 0 - } - - // For single-node immediate execution (testing), return 1 - if isSingleNodeImmediate(vm) { - return 1 - } - - vm.mu.Lock() - defer vm.mu.Unlock() - - // Count all nodes, excluding notification nodes (email/telegram REST API) - nodeCount := 0 - for _, node := range vm.TaskNodes { - if node == nil { - continue - } - // Check if this is a notification node (REST API with sendgrid/telegram URL) - if isNotificationNode(node) { - continue - } - nodeCount++ - } - - // Count: 1 trigger + non-notification nodes - totalSteps := 1 + nodeCount - - return totalSteps -} - -// isNotificationNode checks if a task node is a notification endpoint (email/telegram) -func isNotificationNode(node *avsproto.TaskNode) bool { - if node == nil { - return false - } - // Check if it's a REST API node - restAPI := node.GetRestApi() - if restAPI == nil || restAPI.Config == nil { - return false - } - // Check URL for notification providers - url := strings.ToLower(restAPI.Config.Url) - method := strings.ToUpper(restAPI.Config.Method) - - // Only consider POST requests as notifications (GET requests are typically data queries) - // This helps avoid incorrectly classifying legitimate API nodes that query these services - if method != "POST" && method != "" { - return false - } - - // Studio /api/notify (Path B distribution endpoint) - if strings.Contains(url, "/api/notify") { - return true - } - // Telegram - if strings.Contains(url, "telegram") || strings.Contains(url, "api.telegram.org") { - return true - } - return false -} - -// ------- Helpers for concise narrative formatting ------- - -func resolveChainName(vm *VM) string { - // Prefer settings.chain then chain_id mapping, else try smartWalletConfig.ChainID - vm.mu.Lock() - defer vm.mu.Unlock() - if settings, ok := vm.vars["settings"].(map[string]interface{}); ok { - if n, ok := settings["chain"].(string); ok && strings.TrimSpace(n) != "" { - return n - } - if id, ok := settings["chain_id"].(int); ok && id != 0 { - if name := mapChainIDToName(int64(id)); name != "" { - return name - } - } - } - if vm.smartWalletConfig != nil && vm.smartWalletConfig.ChainID != 0 { - if name := mapChainIDToName(vm.smartWalletConfig.ChainID); name != "" { - return name - } - } - return "unknown network" -} - -func mapChainIDToName(id int64) string { - switch id { - case 1: - return "Ethereum" - case 5, 11155111: - return "Sepolia" - case 8453: - return "Base" - case 84531: - return "Base Goerli" - default: - return "chain-" + fmt.Sprint(id) - } -} - -func countActionsInLogs(vm *VM) int { - count := 0 - for _, st := range vm.ExecutionLogs { - if !st.GetSuccess() { - continue - } - t := strings.ToUpper(st.GetType()) - if strings.Contains(t, "CONTRACT_WRITE") || strings.Contains(t, "CONTRACT_READ") { - count++ - } - } - if count == 0 { - return 1 - } - return count -} - -// buildStepsOverview generates a checkmark-prefixed list of successful contract write steps -func buildStepsOverview(vm *VM) string { - if vm == nil { - return "" - } - - // Extract token context from settings (pool tokens and known contracts) - vm.mu.Lock() - var settings map[string]interface{} - if m, ok := vm.vars["settings"].(map[string]interface{}); ok { - settings = m - } - vm.mu.Unlock() - - token0Sym, token0Dec := "", 18 - token1Sym, token1Dec := "", 18 - var routerAddr string - if settings != nil { - if pool, ok := settings["uniswapv3_pool"].(map[string]interface{}); ok { - if t0, ok := pool["token0"].(map[string]interface{}); ok { - if s, ok := t0["symbol"].(string); ok { - token0Sym = s - token0Dec = defaultDecimalsForSymbol(s) - } - } - if t1, ok := pool["token1"].(map[string]interface{}); ok { - if s, ok := t1["symbol"].(string); ok { - token1Sym = s - token1Dec = defaultDecimalsForSymbol(s) - } - } - } - if contracts, ok := settings["uniswapv3_contracts"].(map[string]interface{}); ok { - if a, ok := contracts["swapRouter02"].(string); ok { - routerAddr = a - } - } - // Debug logging - if vm.logger != nil { - vm.logger.Debug("buildStepsOverview: token context", "token0Sym", token0Sym, "token1Sym", token1Sym, "routerAddr", routerAddr) - } - } - - // Helper function to detect contract name from address - detectContractName := func(addr string) string { - if addr == "" { - return "" - } - // Normalize addresses for comparison - addrLower := strings.ToLower(strings.TrimSpace(addr)) - if routerAddr != "" && strings.ToLower(strings.TrimSpace(routerAddr)) == addrLower { - return "Uniswap V3 router" - } - return "" - } - - var steps []string - for _, st := range vm.ExecutionLogs { - if !st.GetSuccess() { - continue - } - - t := strings.ToUpper(st.GetType()) - if !strings.Contains(t, "CONTRACT_WRITE") { - continue - } - - // Check if this is a simulated transaction - // Uses isStepSimulated() which handles both "is_simulated" (snake_case) and "isSimulated" (camelCase) - isSimulated := isStepSimulated(st) - - // Extract methodName and contractAddr from Config (still needed for description generation) - methodName := "" - contractAddr := "" - if st.GetConfig() != nil { - if cfg, ok := st.GetConfig().AsInterface().(map[string]interface{}); ok { - if addr, ok := cfg["contractAddress"].(string); ok { - contractAddr = addr - } - if mcs, ok := cfg["methodCalls"].([]interface{}); ok && len(mcs) > 0 { - if call, ok := mcs[0].(map[string]interface{}); ok { - if mn, ok := call["methodName"].(string); ok { - methodName = strings.ToLower(mn) - } - } - } - } - } - - // Generate description based on method type - description := "" - switch methodName { - case "approve": - value := "" - spender := "" - tokenAddr := "" - if st.GetContractWrite() != nil && st.GetContractWrite().Data != nil { - if m, ok := st.GetContractWrite().Data.AsInterface().(map[string]interface{}); ok { - // Check for "Approval" event (capital A) first - if ev, ok := m["Approval"].(map[string]interface{}); ok { - if v, ok := ev["value"].(string); ok { - value = v - } - if sp, ok := ev["spender"].(string); ok { - spender = sp - } - // Get token address from owner field in Approval event - if owner, ok := ev["owner"].(string); ok && owner != "" { - tokenAddr = owner - } - } else if ev, ok := m["approve"].(map[string]interface{}); ok { - // Fallback to "approve" output data (lowercase) if event not found - if v, ok := ev["value"].(string); ok { - value = v - } - if sp, ok := ev["spender"].(string); ok { - spender = sp - } - // Get token address from owner field in approve output - if owner, ok := ev["owner"].(string); ok && owner != "" { - tokenAddr = owner - } - } - } - } - // Use contractAddr as token address if not found in event, but skip if it's a template variable - if tokenAddr == "" && contractAddr != "" && !strings.HasPrefix(contractAddr, "{{") { - tokenAddr = contractAddr - } - // Fallback to routerAddr if spender not found in event - if spender == "" { - spender = routerAddr // Use router address as default spender - } - // Debug logging - if vm.logger != nil { - vm.logger.Debug("buildStepsOverview: approve step", "contractAddr", contractAddr, "spender", spender, "value", value, "token1Sym", token1Sym, "token1Dec", token1Dec) - } - // Detect contract name - contractName := detectContractName(spender) - spenderDisplay := contractName - if spenderDisplay == "" { - spenderDisplay = shortHexAddr(spender) - } else { - spenderDisplay = spenderDisplay + " " + shortHexAddr(spender) - } - // Format amount with token symbol - if value != "" && token1Sym != "" { - formattedAmount := formatAmount(value, token1Dec) - description = fmt.Sprintf("Approved %s %s to %s", formattedAmount, token1Sym, spenderDisplay) - } else if value != "" { - description = fmt.Sprintf("Approved %s to %s", value, spenderDisplay) - } else if token1Sym != "" { - description = fmt.Sprintf("Approved %s to %s", token1Sym, spenderDisplay) - } else { - description = fmt.Sprintf("Approved token to %s", spenderDisplay) - } - - case "exactinputsingle": - amountOut := "" - if st.Metadata != nil { - if meta := st.Metadata.AsInterface(); meta != nil { - if arr, ok := meta.([]interface{}); ok && len(arr) > 0 { - if first, ok := arr[0].(map[string]interface{}); ok { - if val, ok := first["value"].(map[string]interface{}); ok { - if amt, ok := val["amountOut"].(string); ok { - amountOut = amt - } - } else if amtStr, ok := first["value"].(string); ok { - amountOut = amtStr - } - } - } - } - } - // Also check return value from contract write output - if amountOut == "" && st.GetContractWrite() != nil && st.GetContractWrite().Data != nil { - if m, ok := st.GetContractWrite().Data.AsInterface().(map[string]interface{}); ok { - if exactInput, ok := m["exactInputSingle"].(map[string]interface{}); ok { - if amt, ok := exactInput["amountOut"].(string); ok { - amountOut = amt - } - } - } - } - // Format amountOut with token symbol and decimal formatting - if amountOut != "" { - formattedAmount := formatAmount(amountOut, token0Dec) - if vm.logger != nil { - vm.logger.Info("buildStepsOverview: formatting amountOut", "amountOut", amountOut, "token0Sym", token0Sym, "token0Dec", token0Dec, "formattedAmount", formattedAmount) - } - if token0Sym != "" { - description = fmt.Sprintf("Swapped for ~%s %s via Uniswap V3 (exactInputSingle)", formattedAmount, token0Sym) - } else { - // Fallback: try to format without symbol if we have decimals - description = fmt.Sprintf("Swapped for ~%s via Uniswap V3 (exactInputSingle)", formattedAmount) - } - } else { - description = "Swapped via Uniswap V3 (exactInputSingle)" - } - // Debug logging - if vm.logger != nil { - vm.logger.Debug("buildStepsOverview: exactInputSingle step", "amountOut", amountOut, "token0Sym", token0Sym, "token0Dec", token0Dec, "isSimulated", isSimulated) - } - - case "transfer": - if st.GetContractWrite() != nil && st.GetContractWrite().Data != nil { - if m, ok := st.GetContractWrite().Data.AsInterface().(map[string]interface{}); ok { - if ev, ok := m["Transfer"].(map[string]interface{}); ok { - value := "" - to := "" - if v, ok := ev["value"].(string); ok { - value = v - } - if t, ok := ev["to"].(string); ok { - to = t - } - if value != "" && to != "" { - description = fmt.Sprintf("Transferred %s to %s for settlement", value, shortHexAddr(to)) - } - } - } - } - if description == "" { - description = fmt.Sprintf("Transferred to %s", shortHexAddr(contractAddr)) - } - - default: - // Generic description for other methods - description = fmt.Sprintf("Called %s on %s", methodName, shortHexAddr(contractAddr)) - } - - // Add "(Simulated)" prefix if this is a simulated transaction - if isSimulated { - description = "(Simulated) " + description - if vm.logger != nil { - vm.logger.Info("buildStepsOverview: added Simulated prefix", "methodName", methodName, "description", description) - } - } - - // Add checkmark prefix - steps = append(steps, fmt.Sprintf("✓ %s", description)) - } - - return strings.Join(steps, "\n") -} - -// buildFailedStepsOverview generates an X-prefixed list of failed steps with error messages -func buildFailedStepsOverview(vm *VM) string { - if vm == nil { - return "" - } - - var steps []string - for _, st := range vm.ExecutionLogs { - if st.GetSuccess() { - continue // Skip successful steps - } - - name := st.GetName() - if name == "" { - name = st.GetId() - } - if name == "" { - continue - } - - errorMsg := st.GetError() - if errorMsg == "" { - errorMsg = "unknown error" - } - - // Format: "✗ StepName: error message" - description := fmt.Sprintf("✗ %s: %s", safeName(name), firstLine(errorMsg)) - steps = append(steps, description) - } - - return strings.Join(steps, "\n") -} - -// shortHexAddr formats an address as 0xABCD...WXYZ for compact display -func shortHexAddr(addr string) string { - addr = strings.TrimSpace(addr) - if addr == "" { - return "" - } - if strings.HasPrefix(addr, "0x") && len(addr) > 10 { - return addr[:6] + "..." + addr[len(addr)-4:] - } - return addr -} - -func buildNarrativeFromLogs(vm *VM) string { - // Extract token context from settings (pool tokens and known contracts) - vm.mu.Lock() - var settings map[string]interface{} - if m, ok := vm.vars["settings"].(map[string]interface{}); ok { - settings = m - } - vm.mu.Unlock() - - token0Sym, token0Dec := "", 18 - token1Sym, token1Dec := "", 18 - var routerAddr string - if settings != nil { - if pool, ok := settings["uniswapv3_pool"].(map[string]interface{}); ok { - if t0, ok := pool["token0"].(map[string]interface{}); ok { - if s, ok := t0["symbol"].(string); ok { - token0Sym = s - token0Dec = defaultDecimalsForSymbol(s) - } - } - if t1, ok := pool["token1"].(map[string]interface{}); ok { - if s, ok := t1["symbol"].(string); ok { - token1Sym = s - token1Dec = defaultDecimalsForSymbol(s) - } - } - } - if contracts, ok := settings["uniswapv3_contracts"].(map[string]interface{}); ok { - if a, ok := contracts["swapRouter02"].(string); ok { - routerAddr = a - } - } - } - - // Build sentences - var sentences []string - for _, st := range vm.ExecutionLogs { - if !st.GetSuccess() { - continue - } - t := strings.ToUpper(st.GetType()) - if !(strings.Contains(t, "CONTRACT_WRITE") || strings.Contains(t, "CONTRACT_READ")) { - continue - } - - methodName := "" - if st.GetConfig() != nil { - if cfg, ok := st.GetConfig().AsInterface().(map[string]interface{}); ok { - if mcs, ok := cfg["methodCalls"].([]interface{}); ok && len(mcs) > 0 { - if call, ok := mcs[0].(map[string]interface{}); ok { - if mn, ok := call["methodName"].(string); ok { - methodName = strings.ToLower(mn) - } - } - } - } - } - - switch methodName { - case "approve": - value := "" - spender := "" - if st.GetContractWrite() != nil && st.GetContractWrite().Data != nil { - if m, ok := st.GetContractWrite().Data.AsInterface().(map[string]interface{}); ok { - if ev, ok := m["Approval"].(map[string]interface{}); ok { - if v, ok := ev["value"].(string); ok { - value = v - } - if sp, ok := ev["spender"].(string); ok { - spender = sp - } - } - } - } - if spender == "" { - spender = routerAddr - } - if value != "" && token1Sym != "" { - sentences = append(sentences, fmt.Sprintf("Approved %s %s to Uniswap V3 router %s for trading.", formatAmount(value, token1Dec), token1Sym, spender)) - } - - case "quoteexactinputsingle": - amountOut := "" - if st.GetContractRead() != nil && st.GetContractRead().Data != nil { - if m, ok := st.GetContractRead().Data.AsInterface().(map[string]interface{}); ok { - if q, ok := m["quoteExactInputSingle"].(map[string]interface{}); ok { - if a, ok := q["amountOut"].(string); ok { - amountOut = a - } - } - } - } - if amountOut != "" && token0Sym != "" { - sentences = append(sentences, fmt.Sprintf("Quoted ~%s %s via Uniswap V3.", formatAmount(amountOut, token0Dec), token0Sym)) - } - - case "exactinputsingle": - // Prefer metadata.value.amountOut on writes - amountOut := "" - if st.Metadata != nil { - if meta := st.Metadata.AsInterface(); meta != nil { - if arr, ok := meta.([]interface{}); ok && len(arr) > 0 { - if first, ok := arr[0].(map[string]interface{}); ok { - if val, ok := first["value"].(map[string]interface{}); ok { - if amt, ok := val["amountOut"].(string); ok { - amountOut = amt - } - } else if amtStr, ok := first["value"].(string); ok { - amountOut = amtStr - } - } - } - } - } - if amountOut != "" && token1Sym != "" && token0Sym != "" { - // We cannot reliably know tokenIn amount without decoding params; use narrative focused on output - sentences = append(sentences, fmt.Sprintf("Swapped for ~%s %s via Uniswap V3 (exactInputSingle).", formatAmount(amountOut, token0Dec), token0Sym)) - } - } - } - // Join sentences with blank lines for readability - return strings.Join(sentences, "\n\n") -} - -// defaultDecimalsForSymbol returns the default decimal count for a token symbol. -// WARNING: This function assumes 18 decimals for all unknown tokens, which may be incorrect. -// Many tokens use different decimal counts (e.g., USDC/USDT use 6, WBTC uses 8). -// For accurate formatting, prefer querying the actual decimal count from the blockchain -// or expanding this switch statement to include more known tokens. -func defaultDecimalsForSymbol(sym string) int { - switch strings.ToUpper(sym) { - case "USDC", "USDT": - return 6 - default: - return 18 // Assumes 18 decimals for all unknown tokens - may be incorrect - } -} - -func formatAmount(raw string, decimals int) string { - if raw == "" { - return "0.0000" - } - bi, ok := new(big.Int).SetString(raw, 10) - if !ok { - return raw - } - if decimals <= 0 { - return bi.String() - } - // Convert to decimal with 4 fractional digits - denom := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) - // value = bi / denom - intPart := new(big.Int).Div(bi, denom) - rem := new(big.Int).Mod(bi, denom) - // Scale remainder to four decimals - scaleDigits := int64(4) - scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(scaleDigits), nil) - frac := new(big.Int).Mul(rem, scale) - frac.Div(frac, denom) - fracStr := frac.String() - // Left-pad with zeros to fixed width - for len(fracStr) < int(scaleDigits) { - fracStr = "0" + fracStr - } - return fmt.Sprintf("%s.%s", intPart.String(), fracStr) -} - -func resolveWorkflowName(vm *VM) string { - // Read directly from vm.task - if vm.task != nil && vm.task.Task != nil && strings.TrimSpace(vm.task.Name) != "" { - return vm.task.Name - } - // Fallback for single-node executions: try settings.name - vm.mu.Lock() - defer vm.mu.Unlock() - if settings, ok := vm.vars["settings"].(map[string]interface{}); ok { - if n, okn := settings["name"].(string); okn && strings.TrimSpace(n) != "" { - return n - } - } - return "Workflow" -} - -func findEarliestFailure(vm *VM) (bool, string, string) { - steps := vm.ExecutionLogs - for _, s := range steps { - if !s.GetSuccess() { - name := s.GetName() - if name == "" { - name = s.GetId() - } - return true, safeName(name), s.GetError() - } - } - return false, "", "" -} - -func findLastSuccessStep(vm *VM) *avsproto.Execution_Step { - if vm == nil { - return nil - } - steps := vm.ExecutionLogs - for i := len(steps) - 1; i >= 0; i-- { - if steps[i].GetSuccess() { - return steps[i] - } - } - return nil -} - -func firstLine(s string) string { - if s == "" { - return "unknown error" - } - if idx := strings.IndexAny(s, "\r\n"); idx >= 0 { - return s[:idx] - } - return s -} - -func safeName(s string) string { - if strings.TrimSpace(s) == "" { - return "step" - } - return s -} - -// formatComparisonForHTML formats comparison operands as HTML for email summaries. -// It uses the same parsing/formatting logic as execution logs but renders as HTML. -func formatComparisonForHTML(operandData map[string]interface{}) string { - if operandData == nil { - return "" - } - - leftExpr, _ := operandData["leftExpr"].(string) - rightExpr, _ := operandData["rightExpr"].(string) - operator, _ := operandData["operator"].(string) - left := operandData["left"] - right := operandData["right"] - - if leftExpr == "" || rightExpr == "" || operator == "" { - return "" - } - - // Format operand values using shared concise formatter - leftVal := formatValueConcise(left) - rightVal := formatValueConcise(right) - - return fmt.Sprintf( - "

Expression: %s %s %s

"+ - "

Evaluated: %s %s %s

", - html.EscapeString(leftExpr), - html.EscapeString(operator), - html.EscapeString(rightExpr), - html.EscapeString(leftVal), - html.EscapeString(operator), - html.EscapeString(rightVal), - ) -} - -// formatValueConcise formats a value concisely for display (shared by logs and emails) -func formatValueConcise(v interface{}) string { - if v == nil { - return "null" - } - - switch val := v.(type) { - case string: - if len(val) > 50 { - return fmt.Sprintf("\"%s...\"", val[:47]) - } - return fmt.Sprintf("\"%s\"", val) - case bool: - return fmt.Sprintf("%t", val) - case float64, int, int64: - return fmt.Sprintf("%v", val) - case map[string]interface{}: - return fmt.Sprintf("{object with %d keys}", len(val)) - case []interface{}: - return fmt.Sprintf("[array with %d items]", len(val)) - default: - s := fmt.Sprintf("%v", val) - if len(s) > 50 { - return s[:47] + "..." - } - return s - } -} - -// TransferEventData holds parsed data from an ERC20/ETH Transfer event trigger -type TransferEventData struct { - Direction string // "sent" or "received" - FromAddress string - ToAddress string - Value string // Already formatted (e.g., "1.5") - TokenSymbol string // e.g., "ETH", "USDC" - TokenName string // e.g., "Ether", "USD Coin" - BlockTimestamp int64 // Unix timestamp in milliseconds - ChainName string // e.g., "Sepolia" -} - -// transferMonitorVarName is the key used for transfer event data in vm.vars -// when passed via inputVariables (e.g., single-node Telegram notification runs) -const transferMonitorVarName = "transfer_monitor" - -// transferEventDataFromMap builds a TransferEventData from a map containing transfer fields. -// Returns nil if the map doesn't contain eventName == "Transfer". -func transferEventDataFromMap(data map[string]interface{}) *TransferEventData { - eventName, _ := data["eventName"].(string) - if eventName != "Transfer" { - return nil - } - - transfer := &TransferEventData{} - - if dir, ok := data["direction"].(string); ok { - transfer.Direction = dir - } - if from, ok := data["fromAddress"].(string); ok { - transfer.FromAddress = from - } - if to, ok := data["toAddress"].(string); ok { - transfer.ToAddress = to - } - if val, ok := data["value"].(string); ok { - transfer.Value = val - } - if sym, ok := data["tokenSymbol"].(string); ok { - transfer.TokenSymbol = sym - } - if name, ok := data["tokenName"].(string); ok { - transfer.TokenName = name - } - - // Block timestamp can be int64 or float64 - if ts, ok := data["blockTimestamp"].(float64); ok { - transfer.BlockTimestamp = int64(ts) - } else if ts, ok := data["blockTimestamp"].(int64); ok { - transfer.BlockTimestamp = ts - } - - return transfer -} - -// ExtractTransferEventData extracts transfer event data from the VM's execution logs -// or from vm.vars["transfer_monitor"] (for single-node runs with inputVariables). -// Returns nil if no transfer event is found. -// Thread-safe: acquires VM mutex before accessing ExecutionLogs and vars. -func ExtractTransferEventData(vm *VM) *TransferEventData { - if vm == nil { - return nil - } - - // Lock mutex while iterating over ExecutionLogs and vars to prevent race conditions - vm.mu.Lock() - var transfer *TransferEventData - - // First, look for an event trigger step with Transfer event data in ExecutionLogs - for _, st := range vm.ExecutionLogs { - stepType := strings.ToUpper(st.GetType()) - if !strings.Contains(stepType, "EVENT") && !strings.Contains(stepType, "TRIGGER") { - continue - } - - // Check the event trigger output - eventTrigger := st.GetEventTrigger() - if eventTrigger == nil || eventTrigger.Data == nil { - continue - } - - data, ok := eventTrigger.Data.AsInterface().(map[string]interface{}) - if !ok { - continue - } - - transfer = transferEventDataFromMap(data) - if transfer != nil { - break // Found transfer event, exit loop - } - } - - // Fallback: check vm.vars["transfer_monitor"] for single-node runs - // where transfer data is passed via inputVariables instead of ExecutionLogs - if transfer == nil { - if tmRaw, ok := vm.vars[transferMonitorVarName]; ok { - if tmMap, ok := tmRaw.(map[string]interface{}); ok { - if dataRaw, ok := tmMap["data"]; ok { - if data, ok := dataRaw.(map[string]interface{}); ok { - transfer = transferEventDataFromMap(data) - } - } - } - } - } - vm.mu.Unlock() - - if transfer == nil { - return nil - } - - // Get chain name from settings (resolveChainName acquires its own lock) - transfer.ChainName = resolveChainName(vm) - - return transfer -} - -// FormatTransferMessage formats a transfer event into an HTML notification message. -// Format: ⬆️ Sent 1.5 ETH to 0x00...02 on Sepolia (2026-01-20 13:03) -// All user-controlled content is HTML-escaped to prevent XSS attacks -func FormatTransferMessage(data *TransferEventData) string { - if data == nil { - return "" - } - - // Direction emoji and text - var directionPrefix string - var targetLabel string - var targetAddress string - - if data.Direction == "sent" { - directionPrefix = "⬆️ Sent" - targetLabel = "to" - targetAddress = data.ToAddress - } else { - directionPrefix = "⬇️ Received" - targetLabel = "from" - targetAddress = data.FromAddress - } - - // Format amount with token symbol (escaped) - amount := fmt.Sprintf("%s %s", html.EscapeString(data.Value), html.EscapeString(data.TokenSymbol)) - - // Format address (keep full address for code tag, it's monospace and scrollable, escaped) - addressDisplay := fmt.Sprintf("%s", html.EscapeString(targetAddress)) - - // Format chain (escaped) - chain := fmt.Sprintf("%s", html.EscapeString(data.ChainName)) - - // Format timestamp - timestamp := "" - if data.BlockTimestamp > 0 { - // Convert milliseconds to seconds if needed - ts := data.BlockTimestamp - if ts > 1e12 { - ts = ts / 1000 - } - t := time.Unix(ts, 0) - timestamp = fmt.Sprintf("(%s)", t.Format("2006-01-02 15:04")) - } - - // Compose message - msg := fmt.Sprintf("%s %s %s %s on %s", directionPrefix, amount, targetLabel, addressDisplay, chain) - if timestamp != "" { - msg += " " + timestamp - } - - return msg -} diff --git a/core/taskengine/summarizer_format_discord.go b/core/taskengine/summarizer_format_discord.go deleted file mode 100644 index b3f7cb00..00000000 --- a/core/taskengine/summarizer_format_discord.go +++ /dev/null @@ -1,101 +0,0 @@ -package taskengine - -import "strings" - -// formatDiscordFromStructured formats Summary into Discord markdown using AI-generated strings -// Uses the API response fields directly without composing additional text -// Format: Subject (bold) as header, Smart Wallet, then trigger, executions, errors -// Note: Discord uses markdown (not HTML), so XSS is not a concern -func formatDiscordFromStructured(s Summary) string { - var sb strings.Builder - - // Subject as header (bold for Discord markdown) - if s.Subject != "" { - sb.WriteString("**") - sb.WriteString(s.Subject) - sb.WriteString("**\n") - } - - // Smart Wallet line (right beneath title) - if s.SmartWallet != "" { - sb.WriteString("Smart Wallet: ") - sb.WriteString(s.SmartWallet) - sb.WriteString("\n") - } - - // Add blank line before trigger if we have subject or smart wallet - if s.Subject != "" || s.SmartWallet != "" { - sb.WriteString("\n") - } - - // Trigger (AI-generated text) - if s.Trigger != "" { - sb.WriteString(s.Trigger) - } - - // Executions (AI-generated descriptions) - if len(s.Executions) > 0 { - if s.Trigger != "" { - sb.WriteString("\n\n") - } - for _, exec := range s.Executions { - sb.WriteString("• ") - sb.WriteString(exec.Description) - sb.WriteString("\n") - if exec.TxHash != "" { - if explorerURL := buildTxExplorerURL(s, exec.TxHash); explorerURL != "" { - sb.WriteString(" Transaction: [") - sb.WriteString(truncateTxHash(exec.TxHash)) - sb.WriteString("](") - sb.WriteString(explorerURL) - sb.WriteString(")\n") - } - } - } - } - - // "What Went Wrong" section — consistent with email - if len(s.Errors) > 0 { - sb.WriteString("\n**What Went Wrong:**\n") - for _, err := range s.Errors { - sb.WriteString("• ") - sb.WriteString(err) - sb.WriteString("\n") - } - } - - if s.Annotation != "" { - sb.WriteString("\n*") - sb.WriteString(s.Annotation) - sb.WriteString("*") - } - - return strings.TrimSpace(sb.String()) -} - -func formatDiscordExampleMessage(workflowName, chainName string) string { - var sb strings.Builder - - // Status line with emoji and workflow name - sb.WriteString("✅ **") - sb.WriteString(workflowName) - sb.WriteString("** completed\n\n") - - // Network - sb.WriteString("**Network:** ") - sb.WriteString(chainName) - sb.WriteString("\n\n") - - // Executed section with example - sb.WriteString("**Executed:**\n") - sb.WriteString("• (Simulated) ") - sb.WriteString(ExampleExecutionMessage) - sb.WriteString("\n\n") - - // Example notice - sb.WriteString("*") - sb.WriteString(ExampleExecutionAnnotation) - sb.WriteString("*") - - return sb.String() -} diff --git a/core/taskengine/summarizer_format_email.go b/core/taskengine/summarizer_format_email.go deleted file mode 100644 index a0081833..00000000 --- a/core/taskengine/summarizer_format_email.go +++ /dev/null @@ -1,526 +0,0 @@ -package taskengine - -import ( - "fmt" - "html" - "strings" - "time" -) - -// buildSkippedNote returns the user-visible skip sentence rendered in the warn -// badge and as the `skipped_note` template variable. Kept in one place so the -// deterministic summarizer and the vm_runner_rest override can't drift. -func buildSkippedNote(count int) string { - noun, verb := "node", "was" - if count != 1 { - noun, verb = "nodes", "were" - } - return fmt.Sprintf("%d %s %s skipped by Branch condition.", count, noun, verb) -} - -// SendGridDynamicData returns a dynamic_template_data map for SendGrid Dynamic Templates. -// The template uses these variables to render the email: -// - subject: email subject line -// - preheader: short preview text (reuses subject) -// - summary: one-line execution summary (no skip-note suffix) -// - status: execution status ("success" | "failed" | "error") -// - status_color: color for status badge (green/yellow/red — yellow when success with skipped steps) -// - status_text: badge text ("All steps completed successfully" | SkippedNote | "Execution failed" | "System error") -// - skipped_note: "1 node was skipped by Branch condition." — present only when status=success && skippedSteps>0 -// - skipped_steps / executed_steps / total_steps: step counts -// - trigger: what triggered the workflow -// - triggered_at: formatted timestamp -// - executions: array of on-chain operation descriptions -// - has_executions: boolean for conditional rendering (false when context-memory sends empty executions[]) -// - errors: array of error descriptions -// - has_errors: boolean for conditional rendering -// - analysisHtml: (legacy) pre-formatted HTML for backward compatibility -func (s Summary) SendGridDynamicData() map[string]interface{} { - data := map[string]interface{}{ - "subject": s.Subject, - "preheader": s.Subject, - } - - // New structured format (from context-memory API) - if s.SummaryLine != "" { - data["summary"] = s.SummaryLine - } - - if s.Status != "" { - data["status"] = s.Status - data["status_color"] = getStatusColor(s) - data["status_text"] = getStatusDisplayText(s) - } - - if s.SkippedNote != "" { - data["skipped_note"] = s.SkippedNote - } - if s.TotalSteps > 0 { - data["executed_steps"] = s.ExecutedSteps - data["total_steps"] = s.TotalSteps - data["skipped_steps"] = s.SkippedSteps - } - - if s.Trigger != "" { - data["trigger"] = s.Trigger - } - - if s.TriggeredAt != "" { - data["triggered_at"] = formatTimestampHumanReadable(s.TriggeredAt) - } - - if len(s.Executions) > 0 { - // Convert ExecutionEntry to maps for SendGrid template compatibility - execMaps := make([]map[string]string, len(s.Executions)) - for i, e := range s.Executions { - m := map[string]string{"description": e.Description} - if e.TxHash != "" { - m["txHash"] = e.TxHash - m["txUrl"] = buildTxExplorerURL(s, e.TxHash) - } - execMaps[i] = m - } - data["executions"] = execMaps - data["has_executions"] = true - } - - if len(s.Errors) > 0 { - data["errors"] = s.Errors - data["has_errors"] = true - } - - if s.Annotation != "" { - data["annotation"] = s.Annotation - } - - // Build analysisHtml from structured data - var analysisHtml string - if len(s.Executions) > 0 || len(s.Errors) > 0 || s.Trigger != "" { - analysisHtml = buildAnalysisHtmlFromStructured(s) - } else { - // Fallback: build from plain text body - clean := filterAnalysisTextForTemplate(s.Body) - analysisHtml = buildBareHTMLFromText(clean) - } - data["analysisHtml"] = analysisHtml - - return data -} - -// getStatusColor returns the color for the email status badge. -// Per Studio, a `success` run with branch-skipped steps renders yellow ("warn"), -// not green — the skip is worth user attention even though nothing failed. -func getStatusColor(s Summary) string { - switch s.Status { - case "success": - if s.SkippedSteps > 0 { - return "yellow" - } - return "green" - case "failed", "error": - return "red" - default: - return "gray" - } -} - -// getStatusDisplayText returns the badge text for the email. -// For success-with-skipped, it surfaces the skip note (e.g. "1 node was skipped by -// Branch condition.") so the badge explains why it turned yellow. -func getStatusDisplayText(s Summary) string { - switch s.Status { - case "success": - if s.SkippedSteps > 0 && s.SkippedNote != "" { - return s.SkippedNote - } - return "All steps completed successfully" - case "failed": - return "Execution failed" - case "error": - return "System error" - default: - return "Completed" - } -} - -// --------------------------------------------------------------------------- -// Shared formatting helpers for all notification channels -// --------------------------------------------------------------------------- -// -// These helpers ensure consistent rendering across email, Telegram, Discord, -// and plain-text channels. When adding a new notification channel, follow -// the rules below for each field type. -// -// ## Context-memory API string conventions -// -// The context-memory API returns structured data that the aggregator formats -// per-channel. Two conventions apply to string fields: -// -// - Backtick-delimited code: expressions or variable names are wrapped in -// backticks (e.g., `code1.data.balance >= code1.data.totalNeeded`). -// Use formatBackticksForChannel to render them appropriately. -// -// - ISO 8601 timestamps: the triggeredAt field is in RFC 3339 format. -// Use formatTimestampHumanReadable to convert to display format. -// -// ## Per-channel rendering rules -// -// Field | Telegram (HTML) | Email (HTML) | Discord (Markdown) | Plaintext -// --------------|-------------------------|-------------------------|---------------------|---------- -// Subject | name | template variable | **bold** | as-is -// Network | Network: value | template variable | plain text | plain text -// Time | Time: formatted | template variable | — | — -// Trigger | Trigger: value | template variable | plain text | plain text -// Executions | Executed: • list | template variable | • list | - list -// Errors | What Went Wrong: | "What Went Wrong"

| **What Went Wrong:**| What Went Wrong: -// Backticks | → ... | → ... | native (pass-thru) | pass-thru -// Timestamps | formatTimestampHumanReadable | (same for all) -// Annotation | text | italic styled | *text* | plain text -// --------------------------------------------------------------------------- - -// formatTimestampHumanReadable formats an ISO 8601 timestamp into a human-readable string. -// Used by all notification channels (email, telegram, discord) for consistent display. -func formatTimestampHumanReadable(isoTimestamp string) string { - t, err := time.Parse(time.RFC3339, isoTimestamp) - if err != nil { - // Try parsing without timezone - t, err = time.Parse("2006-01-02T15:04:05", isoTimestamp) - if err != nil { - return isoTimestamp // Return as-is if parsing fails - } - } - return t.UTC().Format("Jan 2, 2006 at 3:04 PM UTC") -} - -// formatBackticksToHTML converts backtick-delimited segments in a string to tags. -// Used by Telegram and email formatters to render inline code from context-memory API errors. -// Non-backticked portions are HTML-escaped; backticked portions are wrapped in tags. -// -// Context-memory API convention: -// -// The context-memory API uses backticks to delimit code expressions in error messages. -// Example input: "loop1 - condition not met: `balance >= totalNeeded` evaluated to false" -// Example output: "loop1 - condition not met: balance >= totalNeeded evaluated to false" -// -// See formatBackticksForChannel for the channel-routing wrapper. -func formatBackticksToHTML(s string) string { - var sb strings.Builder - for { - start := strings.Index(s, "`") - if start < 0 { - sb.WriteString(html.EscapeString(s)) - break - } - end := strings.Index(s[start+1:], "`") - if end < 0 { - // No closing backtick — escape the rest as-is - sb.WriteString(html.EscapeString(s)) - break - } - end += start + 1 // absolute index of closing backtick - - sb.WriteString(html.EscapeString(s[:start])) - sb.WriteString("") - sb.WriteString(html.EscapeString(s[start+1 : end])) - sb.WriteString("") - s = s[end+1:] - } - return sb.String() -} - -// formatBackticksForChannel converts backtick-delimited code segments for the target channel. -// -// Channel formatting rules for context-memory API strings: -// -// - Telegram: backticks → ... (Telegram HTML parse mode) -// - Email: backticks → ... (HTML email) -// - Discord: pass through as-is (Discord natively renders backticks as inline code) -// - Plaintext: pass through as-is (backticks are readable in plain text) -// -// When adding a new notification channel, decide whether it supports inline code -// markup and add a case here. Default is pass-through. -func formatBackticksForChannel(s string, channel string) string { - switch channel { - case "telegram", "email": - return formatBackticksToHTML(s) - default: - // Discord, plaintext, and future channels that handle backticks natively - return s - } -} - -// buildAnalysisHtmlFromStructured builds HTML content from the structured Summary fields -// This provides backward compatibility for older email templates that expect analysisHtml -// NOTE: Does NOT include summary line - use the separate {{summary}} template variable for that -// Uses consistent section headers with margin-bottom formatting -func buildAnalysisHtmlFromStructured(s Summary) string { - var sb strings.Builder - - // Section: What Triggered This Workflow - if s.Trigger != "" { - sb.WriteString(`
`) - sb.WriteString(`

What Triggered This Workflow

`) - sb.WriteString("

✓ ") - sb.WriteString(html.EscapeString(s.Trigger)) - sb.WriteString("

") - // Add timestamp row if available - if s.TriggeredAt != "" { - if ts := formatTimestampHumanReadable(s.TriggeredAt); ts != "" { - sb.WriteString("

") - sb.WriteString(html.EscapeString(ts)) - sb.WriteString("

") - } - } - sb.WriteString("
") - } - - // Section 2: What Executed On-Chain - if len(s.Executions) > 0 { - sb.WriteString(`
`) - sb.WriteString(`

What Executed On-Chain

`) - for _, exec := range s.Executions { - sb.WriteString("

✓ ") - sb.WriteString(formatBackticksForChannel(exec.Description, "email")) - sb.WriteString("

") - if exec.TxHash != "" { - if explorerURL := buildTxExplorerURL(s, exec.TxHash); explorerURL != "" { - sb.WriteString("

Transaction: ") - sb.WriteString(html.EscapeString(truncateTxHash(exec.TxHash))) - sb.WriteString("

") - } - } - } - sb.WriteString("
") - } - - // Section 3: What Went Wrong (only show if there are errors) - if len(s.Errors) > 0 { - sb.WriteString(`
`) - sb.WriteString(`

What Went Wrong

`) - for _, err := range s.Errors { - sb.WriteString("

✗ ") - sb.WriteString(formatBackticksForChannel(err, "email")) - sb.WriteString("

") - } - sb.WriteString("
") - } - - // Section: Cost / Estimated cost — fee breakdown - if s.Fees != nil { - if costHTML := buildFeesSectionHTML(s); costHTML != "" { - sb.WriteString(costHTML) - } - } - - if s.Annotation != "" { - sb.WriteString(`
`) - sb.WriteString("

") - sb.WriteString(html.EscapeString(s.Annotation)) - sb.WriteString("

") - } - - return sb.String() -} - -// buildFeesSectionHTML renders the Gas section from Summary.Fees.Total — -// the actual on-chain gas the UserOp spent, in native token units. The -// platform fee and per-token value fees are tracked in Fees.Total for -// API consumers and billing but are NOT rendered here, because grouping -// them under "⛽" (gas) misleads users into reading them as gas. If a -// future product surface wants the full breakdown, render it under its -// own heading. -// -// Simulations render only the static placeholder. Returns "" when -// there's no gas to report (e.g. read-only workflows that only paid the -// platform fee). -func buildFeesSectionHTML(s Summary) string { - if s.Fees == nil { - return "" - } - - if s.Workflow != nil && s.Workflow.IsSimulation { - // Heading omitted — the placeholder line carries enough context on its own. - return `
` + - `

⛽ (see cost estimate before deploy)

` + - `
` - } - - if len(s.Fees.Total) == 0 { - return "" - } - var gasLine string - for _, t := range s.Fees.Total { - if t == nil || !t.IsGas || t.Amount == "" || t.Amount == "0" { - continue - } - usd := "$?" - if t.USD != "" { - usd = "$" + t.USD - } - gasLine = fmt.Sprintf("%s %s (%s)", html.EscapeString(t.Amount), html.EscapeString(t.Unit), usd) - break - } - if gasLine == "" { - return "" - } - var sb strings.Builder - sb.WriteString(`
`) - sb.WriteString(`

Gas

`) - sb.WriteString(`

⛽ `) - sb.WriteString(gasLine) - sb.WriteString("

") - return sb.String() -} - -// buildBareHTMLFromText converts plain text into minimal HTML paragraphs without global styles -// Preserves safe HTML tags (, ,
, etc.) while escaping potentially dangerous content -// -// SECURITY NOTE: This function uses a simple string replacement approach which has limitations. -// It does not validate HTML attributes, so tags like would pass through. -// For production use with untrusted input, consider using a proper HTML sanitizer library like -// github.com/microcosm-cc/bluemonday. This current implementation is acceptable for trusted -// AI-generated content from context-memory API, but should be reviewed if processing -// user-provided content. -func buildBareHTMLFromText(body string) string { - // Normalize newlines first - normalized := strings.ReplaceAll(body, "\r\n", "\n") - - // Check if body already contains HTML tags (from AI summaries) - // If it does, preserve safe HTML tags and only escape unsafe content - // Note: This check could be fooled by legitimate text containing comparison operators - hasHTML := strings.Contains(normalized, "<") && strings.Contains(normalized, ">") - - if hasHTML { - // Body already contains HTML - preserve safe tags and escape only unsafe content - // First, temporarily replace safe HTML tags with placeholders - safeTags := map[string]string{ - "": "___STRONG_OPEN___", - "": "___STRONG_CLOSE___", - "": "___EM_OPEN___", - "": "___EM_CLOSE___", - "
": "___BR___", - "
": "___BR___", - } - - // Replace safe tags with placeholders - withPlaceholders := normalized - for tag, placeholder := range safeTags { - withPlaceholders = strings.ReplaceAll(withPlaceholders, tag, placeholder) - } - - // Escape all remaining HTML (potentially dangerous) - safe := html.EscapeString(withPlaceholders) - - // Restore safe tags - for tag, placeholder := range safeTags { - safe = strings.ReplaceAll(safe, placeholder, tag) - } - - normalized = safe - } else { - // Plain text - escape everything - normalized = html.EscapeString(normalized) - } - - // Split by paragraphs (double newline) - parts := strings.Split(normalized, "\n\n") - var paragraphs []string - for _, p := range parts { - if strings.TrimSpace(p) == "" { - continue - } - // Convert single newlines within a paragraph to
(only if not already HTML) - if !hasHTML { - p = strings.ReplaceAll(p, "\n", "
") - } - paragraphs = append(paragraphs, "

"+p+"

") - } - return strings.Join(paragraphs, "\n") -} - -// filterAnalysisTextForTemplate removes lines that duplicate data provided via separate template variables -// such as runner/eoaAddress and high-level status lines. It preserves the core narrative/actions. -func filterAnalysisTextForTemplate(body string) string { - if strings.TrimSpace(body) == "" { - return body - } - b := strings.ReplaceAll(body, "\r\n", "\n") - lines := strings.Split(b, "\n") - var kept []string - for _, ln := range lines { - trim := strings.TrimSpace(ln) - lower := strings.ToLower(trim) - if trim == "" { - // keep paragraph breaks; we'll collapse later - kept = append(kept, trim) - continue - } - // Drop runner/owner and generic completion/status lines - if strings.HasPrefix(trim, "Smart wallet ") || - strings.HasPrefix(trim, "Runner smart wallet") || - strings.HasPrefix(trim, "All steps completed") || - strings.HasPrefix(trim, "Workflow '") || - strings.HasPrefix(trim, "The email was sent successfully via SendGrid") || - strings.HasPrefix(trim, "Single-node execution") || - strings.HasPrefix(lower, "smart wallet ") || - strings.HasPrefix(lower, "runner smart wallet") { - continue - } - kept = append(kept, ln) - } - // Collapse extra blank lines: ensure at most one blank line between paragraphs - var collapsed []string - prevBlank := false - for _, ln := range kept { - if strings.TrimSpace(ln) == "" { - if !prevBlank { - collapsed = append(collapsed, "") - } - prevBlank = true - continue - } - collapsed = append(collapsed, ln) - prevBlank = false - } - res := strings.Join(collapsed, "\n") - res = strings.TrimSpace(res) - if res == "" { - // Preserve original fallback message if present - if strings.Contains(b, "No specific on-chain actions were recorded") { - return "No specific on-chain actions were recorded; this may have been a simulation or a step encountered an error." - } - } - return res -} - -// buildStyledHTMLEmailForSummary wraps a plain-text body into a styled HTML layout -// suitable for email clients with light theme -func buildStyledHTMLEmailForSummary(subject, body string) string { - // Escape HTML to avoid injection - safe := html.EscapeString(body) - // Normalize newlines - safe = strings.ReplaceAll(safe, "\r\n", "\n") - // Split by paragraphs (double newline) - parts := strings.Split(safe, "\n\n") - var paragraphs []string - for _, p := range parts { - if strings.TrimSpace(p) == "" { - continue - } - // Convert single newlines within a paragraph to
- p = strings.ReplaceAll(p, "\n", "
") - paragraphs = append(paragraphs, "

"+p+"

") - } - - content := strings.Join(paragraphs, "\n") - // Minimal, responsive-friendly light theme - return "" + - "" + html.EscapeString(subject) + "" + - "" + - "
" + content + "
" -} diff --git a/core/taskengine/summarizer_format_integration_test.go b/core/taskengine/summarizer_format_integration_test.go deleted file mode 100644 index 203fe239..00000000 --- a/core/taskengine/summarizer_format_integration_test.go +++ /dev/null @@ -1,1096 +0,0 @@ -//go:build integration -// +build integration - -package taskengine - -import ( - "context" - "fmt" - "os" - "strings" - "testing" - "time" - - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "google.golang.org/protobuf/types/known/structpb" - "gopkg.in/yaml.v2" -) - -// sepoliaConfig holds the notifications.summary section from test.yaml -type sepoliaConfig struct { - Notifications struct { - Summary struct { - Enabled bool `yaml:"enabled"` - Provider string `yaml:"provider"` - APIEndpoint string `yaml:"api_endpoint"` - APIKey string `yaml:"api_key"` - } `yaml:"summary"` - } `yaml:"notifications"` -} - -// loadSepoliaConfig loads the context-memory API config from the local test.yaml -func loadSepoliaConfig(t *testing.T) (string, string) { - t.Helper() - - // Allow override via env vars - if url := os.Getenv("CONTEXT_MEMORY_URL"); url != "" { - token := os.Getenv("SERVICE_AUTH_TOKEN") - if token == "" { - if strings.Contains(url, "localhost") { - token = ContextMemoryAuthToken - } else { - t.Skip("SERVICE_AUTH_TOKEN not set for non-localhost URL") - } - } - return url, token - } - - // Try to load from the test fixture config (config/test.yaml) - configPaths := []string{ - "../../config/test.yaml", - "config/test.yaml", - } - - for _, path := range configPaths { - data, err := os.ReadFile(path) - if err != nil { - continue - } - var cfg sepoliaConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { - t.Logf("Failed to parse %s: %v", path, err) - continue - } - if !cfg.Notifications.Summary.Enabled { - t.Skip("notifications.summary.enabled is false in config") - } - apiURL := cfg.Notifications.Summary.APIEndpoint - apiKey := cfg.Notifications.Summary.APIKey - if apiURL == "" || apiKey == "" { - t.Skip("notifications.summary.api_endpoint or api_key not set in config") - } - t.Logf("Loaded config from %s: url=%s", path, apiURL) - return apiURL, apiKey - } - - t.Skip("Could not load test.yaml config") - return "", "" -} - -// discrepancy tracks a difference between AI and deterministic paths -type discrepancy struct { - Field string - Description string - AIValue string - DetValue string -} - -func (d discrepancy) String() string { - return fmt.Sprintf("[%s] %s\n AI: %s\n Deterministic: %s", d.Field, d.Description, d.AIValue, d.DetValue) -} - -// compareAndReport compares AI and deterministic summaries and reports discrepancies -func compareAndReport(t *testing.T, scenario string, ai, det Summary) []discrepancy { - t.Helper() - var diffs []discrepancy - - // Subject - if ai.Subject == "" { - diffs = append(diffs, discrepancy{"Subject", "AI subject is empty", ai.Subject, det.Subject}) - } else { - t.Logf("[%s] AI Subject: %s", scenario, ai.Subject) - t.Logf("[%s] Det Subject: %s", scenario, det.Subject) - } - - // SummaryLine - if ai.SummaryLine == "" && det.SummaryLine != "" { - diffs = append(diffs, discrepancy{"SummaryLine", "AI summary line is empty but deterministic has one", "", det.SummaryLine}) - } else { - t.Logf("[%s] AI SummaryLine: %s", scenario, ai.SummaryLine) - t.Logf("[%s] Det SummaryLine: %s", scenario, det.SummaryLine) - } - - // Status - if ai.Status != det.Status { - diffs = append(diffs, discrepancy{"Status", "Status values differ", ai.Status, det.Status}) - } - - // Annotation - if ai.Annotation != det.Annotation { - diffs = append(diffs, discrepancy{ - "Annotation", - "Annotation differs (AI path does not set Annotation)", - fmt.Sprintf("%q", ai.Annotation), - fmt.Sprintf("%q", det.Annotation), - }) - } - - // Network - if ai.Network == "" && det.Network != "" { - diffs = append(diffs, discrepancy{"Network", "AI network is empty but deterministic has one", "", det.Network}) - } - - // Trigger - if ai.Trigger == "" && det.Trigger != "" { - diffs = append(diffs, discrepancy{"Trigger", "AI trigger is empty but deterministic has one", "", det.Trigger}) - } else { - t.Logf("[%s] AI Trigger: %s", scenario, ai.Trigger) - t.Logf("[%s] Det Trigger: %s", scenario, det.Trigger) - } - - // Executions - t.Logf("[%s] AI Executions (%d): %v", scenario, len(ai.Executions), ai.Executions) - t.Logf("[%s] Det Executions (%d): %v", scenario, len(det.Executions), det.Executions) - if len(ai.Executions) == 0 && len(det.Executions) > 0 { - diffs = append(diffs, discrepancy{ - "Executions", - "AI has no executions but deterministic does", - "[]", - fmt.Sprintf("%v", det.Executions), - }) - } - - // Errors - if len(ai.Errors) != len(det.Errors) { - diffs = append(diffs, discrepancy{ - "Errors", - fmt.Sprintf("Error count differs: AI=%d, Det=%d", len(ai.Errors), len(det.Errors)), - fmt.Sprintf("%v", ai.Errors), - fmt.Sprintf("%v", det.Errors), - }) - } - - // Body (structural comparison - check headers) - aiBodyLower := strings.ToLower(ai.Body) - detBodyLower := strings.ToLower(det.Body) - if strings.Contains(aiBodyLower, "executed:") && strings.Contains(detBodyLower, "what executed on-chain") { - diffs = append(diffs, discrepancy{ - "Body.Headers", - "AI body uses 'Executed:' header while deterministic uses 'What Executed On-Chain'", - "Executed:", - "What Executed On-Chain", - }) - } - - // Transfers (structured data from AI) - if len(ai.Transfers) > 0 { - t.Logf("[%s] AI Transfers: %d entries", scenario, len(ai.Transfers)) - for i, tr := range ai.Transfers { - t.Logf("[%s] Transfer[%d]: %s %s %s -> %s (simulated=%v, tx=%s)", - scenario, i, tr.Amount, tr.Symbol, truncateAddress(tr.From), truncateAddress(tr.To), tr.IsSimulated, truncateTxHash(tr.TxHash)) - } - } - - // Workflow info - if ai.Workflow != nil { - t.Logf("[%s] AI Workflow: name=%s chain=%s chainID=%d isSimulation=%v", - scenario, ai.Workflow.Name, ai.Workflow.Chain, ai.Workflow.ChainID, ai.Workflow.IsSimulation) - } - - // SmartWallet - if ai.SmartWallet == "" && det.SmartWallet != "" { - diffs = append(diffs, discrepancy{"SmartWallet", "AI smart wallet is empty", "", det.SmartWallet}) - } - - return diffs -} - -// formatForAllChannels formats a summary for all channels and logs the output -func formatForAllChannels(t *testing.T, label string, s Summary, vm *VM) { - t.Helper() - for _, ch := range []string{"telegram", "discord", "plaintext"} { - msg := FormatForMessageChannels(s, ch, vm) - t.Logf("[%s] %s channel output:\n%s", label, ch, msg) - } - // Email (SendGrid data) - data := s.SendGridDynamicData() - t.Logf("[%s] Email SendGrid data keys: %v", label, mapKeys(data)) - if html, ok := data["analysisHtml"].(string); ok { - t.Logf("[%s] Email analysisHtml (first 500 chars): %s", label, truncateStr(html, 500)) - } - if ann, ok := data["annotation"].(string); ok { - t.Logf("[%s] Email annotation: %s", label, ann) - } -} - -func mapKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} - -func truncateStr(s string, maxLen int) string { - if len(s) <= maxLen { - return s - } - return s[:maxLen] + "..." -} - -// ============================================================================ -// Test 1: RunNodeImmediately (single-node with empty execution logs) -// ============================================================================ - -func TestAIIntegration_RunNodeImmediately(t *testing.T) { - apiURL, apiKey := loadSepoliaConfig(t) - t.Logf("Testing RunNodeImmediately against: %s", apiURL) - - // Build VM mimicking RunNodeImmediately: single notification node, no execution logs - // isSingleNodeImmediate checks: vm.GetTaskId() == "" && len(vm.TaskNodes) == 1 - // So we need: vm.task == nil (default from NewVM()) and exactly 1 TaskNode - vm := NewVM() - // Don't set TaskID — leave vm.task nil so GetTaskId() returns "" - vm.IsSimulation = true // RunNodeImmediately sets IsSimulation=true - - // No execution logs — the single node (notification) is currently executing - vm.ExecutionLogs = []*avsproto.Execution_Step{} - - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "telegram_send": { - Id: "telegram_send", - Name: "telegram_send", - TaskType: &avsproto.TaskNode_CustomCode{ - CustomCode: &avsproto.CustomCodeNode{}, - }, - }, - } - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "Test RunNode Workflow", - "chain": "Sepolia", - "runner": "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - }, - } - vm.mu.Unlock() - - // --- AI path --- - summarizer := NewContextMemorySummarizer(apiURL, apiKey) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - aiSummary, aiErr := summarizer.Summarize(ctx, vm, "telegram_send") - if aiErr != nil { - t.Logf("AI summarization failed (expected if API not running): %v", aiErr) - t.Skip("Context-memory API not available") - } - t.Logf("AI Summary received: subject=%q, status=%q, bodyLen=%d", aiSummary.Subject, aiSummary.Status, len(aiSummary.Body)) - - // --- Deterministic path --- - detSummary := ComposeSummary(vm, "telegram_send") - t.Logf("Det Summary: subject=%q, status=%q, bodyLen=%d", detSummary.Subject, detSummary.Status, len(detSummary.Body)) - - // --- Compare --- - diffs := compareAndReport(t, "RunNodeImmediately", aiSummary, detSummary) - - // --- Format for all channels --- - t.Log("\n=== AI Summary formatted for channels ===") - formatForAllChannels(t, "AI-RunNode", aiSummary, vm) - t.Log("\n=== Deterministic Summary formatted for channels ===") - formatForAllChannels(t, "Det-RunNode", detSummary, vm) - - // --- Report discrepancies --- - if len(diffs) > 0 { - t.Log("\n========================================") - t.Logf("DISCREPANCIES FOUND: %d", len(diffs)) - t.Log("========================================") - for i, d := range diffs { - t.Logf(" %d. %s", i+1, d.String()) - } - } else { - t.Log("\nNo discrepancies found between AI and deterministic paths.") - } - - // --- Key assertions --- - // The deterministic path should have Annotation set for singleNode - if detSummary.Annotation == "" { - t.Error("Deterministic summary should have Annotation for RunNodeImmediately") - } - // The AI path currently does NOT set Annotation — document this as known discrepancy - if aiSummary.Annotation == "" { - t.Log("KNOWN DISCREPANCY: AI path does not set Annotation field for RunNodeImmediately") - } - // Deterministic should show "1 out of 1" - if !strings.Contains(detSummary.SummaryLine, "1 out of 1") { - t.Errorf("Deterministic SummaryLine should contain '1 out of 1', got: %s", detSummary.SummaryLine) - } -} - -// ============================================================================ -// Test 2: Simulation (simulate_task with cron-triggered ETH transfer workflow) -// Mirrors the real client payload from simulateWorkflow with: -// - cronTrigger (timeTrigger) with schedule config -// - balance check node -// - ethTransfer node with destination/amount config and transfer output -// - telegram_send and email1 notification nodes -// ============================================================================ - -func TestAIIntegration_Simulation(t *testing.T) { - apiURL, apiKey := loadSepoliaConfig(t) - t.Logf("Testing Simulation against: %s", apiURL) - - vm := NewVM() - vm.IsSimulation = true - - // Execution contexts - simulatedCtxStruct, _ := structpb.NewStruct(map[string]interface{}{ - "is_simulated": true, - "provider": "tenderly", - "chain_id": float64(11155111), - }) - simulatedCtx := structpb.NewStructValue(simulatedCtxStruct) - chainRpcCtxStruct, _ := structpb.NewStruct(map[string]interface{}{ - "is_simulated": false, - "provider": "chain_rpc", - "chain_id": float64(11155111), - }) - chainRpcCtx := structpb.NewStructValue(chainRpcCtxStruct) - - // Trigger config and output (matching real cronTrigger) - triggerConfig, _ := structpb.NewValue(map[string]interface{}{ - "schedules": []interface{}{"0 23 */3 * *"}, - }) - triggerOutput, _ := structpb.NewValue(map[string]interface{}{ - "timestamp": float64(1769651702059), - "timestampIso": "2026-01-29T01:55:02.059Z", - }) - - // Balance node config and output - balanceConfig, _ := structpb.NewValue(map[string]interface{}{ - "address": "{{settings.runner}}", - "chain": "sepolia", - "tokenAddresses": []interface{}{"{{settings.token_amount.address}}"}, - "includeSpam": false, - }) - balanceOutput, _ := structpb.NewValue([]interface{}{ - map[string]interface{}{ - "balance": "62673652309441472", - "balanceFormatted": "0.062673652309441472", - "decimals": float64(18), - "name": "Ether", - "symbol": "ETH", - }, - }) - - // ETH transfer config and output - transferConfig, _ := structpb.NewValue(map[string]interface{}{ - "destination": "{{settings.recipient}}", - "amount": "{{settings.token_amount.amount}}", - }) - transferOutput, _ := structpb.NewValue(map[string]interface{}{ - "transfer": map[string]interface{}{ - "from": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "to": "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - "value": "10000000000000000", - }, - }) - transferMetadata, _ := structpb.NewValue(map[string]interface{}{ - "transactionHash": "0x0000000000000000000000000000000000000000000001769651703156844000", - }) - - startTime := int64(1769651702059) - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "01K2H450DVV4APEFCRK08BP5B2", Name: "timeTrigger", Type: avsproto.TriggerType_TRIGGER_TYPE_CRON.String(), Success: true, - StartAt: startTime, - EndAt: startTime + 1, - Config: triggerConfig, - ExecutionContext: simulatedCtx, - OutputData: &avsproto.Execution_Step_CronTrigger{ - CronTrigger: &avsproto.CronTrigger_Output{Data: triggerOutput}, - }, - }, - { - Id: "01KFGZH8JF0Z7RHQXP6QQFN62F", Name: "balance1", Type: avsproto.NodeType_NODE_TYPE_BALANCE.String(), Success: true, - StartAt: startTime + 186, - EndAt: startTime + 1096, - Config: balanceConfig, - ExecutionContext: chainRpcCtx, - OutputData: &avsproto.Execution_Step_Balance{ - Balance: &avsproto.BalanceNode_Output{Data: balanceOutput}, - }, - }, - { - Id: "01KFH6ETEFS0E9WPKV2WCDFRFH", Name: "transfer1", Type: avsproto.NodeType_NODE_TYPE_ETH_TRANSFER.String(), Success: true, - StartAt: startTime + 1096, - EndAt: startTime + 1097, - Config: transferConfig, - Metadata: transferMetadata, - ExecutionContext: chainRpcCtx, - OutputData: &avsproto.Execution_Step_EthTransfer{ - EthTransfer: &avsproto.ETHTransferNode_Output{Data: transferOutput}, - }, - }, - } - - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "01KFGZH8JF0Z7RHQXP6QQFN62F": { - Id: "01KFGZH8JF0Z7RHQXP6QQFN62F", Name: "balance1", - TaskType: &avsproto.TaskNode_Balance{Balance: &avsproto.BalanceNode{ - Config: &avsproto.BalanceNode_Config{ - Address: "{{settings.runner}}", - Chain: "sepolia", - }, - }}, - }, - "01KFH6ETEFS0E9WPKV2WCDFRFH": { - Id: "01KFH6ETEFS0E9WPKV2WCDFRFH", Name: "transfer1", - TaskType: &avsproto.TaskNode_EthTransfer{EthTransfer: &avsproto.ETHTransferNode{ - Config: &avsproto.ETHTransferNode_Config{ - Destination: "{{settings.recipient}}", - Amount: "{{settings.token_amount.amount}}", - }, - }}, - }, - "01K2JTNQBQHX00PRBE5X14Q932": { - Id: "01K2JTNQBQHX00PRBE5X14Q932", Name: "telegram_send", - TaskType: &avsproto.TaskNode_RestApi{RestApi: &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: "https://api.telegram.org/bot{{apContext.configVars.ap_notify_bot_token}}/sendMessage", - Method: "POST", - }, - }}, - }, - "01KG2ZJC16V948XHHPAFBSQKSX": { - Id: "01KG2ZJC16V948XHHPAFBSQKSX", Name: "email1", - TaskType: &avsproto.TaskNode_RestApi{RestApi: &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: "https://api.sendgrid.com/v3/mail/send", - Method: "POST", - }, - }}, - }, - } - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "Test settings.name", - "chain": "Sepolia", - "runner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "owner": "0x804e49e8C4eDb560AE7c48B554f6d2e27Bb81557", - "chain_id": float64(11155111), - "recipient": "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - "token_amount": map[string]interface{}{ - "amount": "10000000000000000", - "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "decimals": float64(18), - }, - }, - } - vm.mu.Unlock() - - // --- AI path --- - summarizer := NewContextMemorySummarizer(apiURL, apiKey) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - aiSummary, aiErr := summarizer.Summarize(ctx, vm, "telegram_send") - if aiErr != nil { - t.Logf("AI summarization failed: %v", aiErr) - t.Skip("Context-memory API not available") - } - t.Logf("AI Summary received: subject=%q, status=%q, bodyLen=%d", aiSummary.Subject, aiSummary.Status, len(aiSummary.Body)) - t.Logf("AI Body: %s", aiSummary.Body) - - // --- Deterministic path --- - detSummary := ComposeSummary(vm, "telegram_send") - t.Logf("Det Summary: subject=%q, status=%q, bodyLen=%d", detSummary.Subject, detSummary.Status, len(detSummary.Body)) - - // --- Compare --- - diffs := compareAndReport(t, "Simulation", aiSummary, detSummary) - - // --- Format for all channels --- - t.Log("\n=== AI Summary formatted for channels ===") - formatForAllChannels(t, "AI-Simulation", aiSummary, vm) - t.Log("\n=== Deterministic Summary formatted for channels ===") - formatForAllChannels(t, "Det-Simulation", detSummary, vm) - - // --- Report discrepancies --- - if len(diffs) > 0 { - t.Log("\n========================================") - t.Logf("DISCREPANCIES FOUND: %d", len(diffs)) - t.Log("========================================") - for i, d := range diffs { - t.Logf(" %d. %s", i+1, d.String()) - } - } else { - t.Log("\nNo discrepancies found between AI and deterministic paths.") - } - - // --- Key assertions --- - // Simulation should NOT have annotation - if detSummary.Annotation != "" { - t.Errorf("Deterministic should NOT have Annotation for simulation, got: %s", detSummary.Annotation) - } - // AI should report "success" (yellow warn for branch-skips is derived from SkippedSteps>0, - // not a distinct status value). - if aiSummary.Status != "success" { - t.Errorf("AI status should be success, got: %s", aiSummary.Status) - } - // Subject should contain "Simulation:" prefix - if !strings.HasPrefix(detSummary.Subject, "Simulation:") { - t.Errorf("Deterministic subject should start with 'Simulation:', got: %s", detSummary.Subject) - } - if !strings.Contains(aiSummary.Subject, "Test settings.name") { - t.Logf("NOTE: AI subject does not contain workflow name: %s", aiSummary.Subject) - } - - // AI should return meaningful trigger and executions with proper config/output data - if aiSummary.Trigger == "" { - t.Logf("DISCREPANCY: AI trigger is empty despite providing cron config with schedules") - } else { - t.Logf("AI trigger: %s", aiSummary.Trigger) - } - if len(aiSummary.Executions) == 0 { - t.Logf("DISCREPANCY: AI executions is empty despite providing ETH transfer output data") - } else { - for i, exec := range aiSummary.Executions { - t.Logf("AI execution[%d]: %s (txHash=%s)", i, exec.Description, exec.TxHash) - } - } - - // Deterministic path should NOT attach txHash for simulated steps - // The test fixture has a fake BigInt as transactionHash in metadata + simulated ExecutionContext - for i, exec := range detSummary.Executions { - if exec.TxHash != "" { - t.Errorf("Det execution[%d] should NOT have txHash for simulated step, got: %s", i, exec.TxHash) - } - } -} - -// ============================================================================ -// Test 3: Real execution (deployed workflow with real trigger) -// ============================================================================ - -func TestAIIntegration_RealExecution(t *testing.T) { - apiURL, apiKey := loadSepoliaConfig(t) - t.Logf("Testing Real Execution against: %s", apiURL) - - // Build VM mimicking a deployed workflow triggered by a real event - // Scenario: USDC stoploss — event trigger detects price drop, approve USDC, swap on Uniswap - vm := NewVM() - vm.IsSimulation = false - - realCtxStruct, _ := structpb.NewStruct(map[string]interface{}{ - "is_simulated": false, - "provider": "bundler", - "chain_id": float64(11155111), - }) - realCtx := structpb.NewStructValue(realCtxStruct) - - // Event trigger config and output - eventTriggerConfig, _ := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - "eventName": "Transfer", - "chain": "sepolia", - }) - triggerOutput, _ := structpb.NewValue(map[string]interface{}{ - "blockNumber": float64(7500000), - "logIndex": float64(42), - "address": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - "transactionHash": "0xabc123def456789abcdef0123456789abcdef0123456789abcdef0123456789a", - }) - - // Balance check config and output - balanceConfig, _ := structpb.NewValue(map[string]interface{}{ - "address": "{{settings.runner}}", - "chain": "sepolia", - "tokenAddresses": []interface{}{"0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238"}, - }) - balanceOutput, _ := structpb.NewValue([]interface{}{ - map[string]interface{}{ - "balance": "20990000", - "balanceFormatted": "20.99", - "decimals": float64(6), - "name": "USD Coin", - "symbol": "USDC", - "tokenAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - }, - }) - - // Approve config and output - approveConfig, _ := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - "callData": "approve(address,uint256)", - "callInput": "[\"0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E\", \"20990000\"]", - }) - approveOutput, _ := structpb.NewValue(map[string]interface{}{ - "approve": map[string]interface{}{ - "owner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "spender": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", - "value": "20990000", - }, - }) - approveMetadata, _ := structpb.NewValue(map[string]interface{}{ - "transactionHash": "0xdef456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0", - }) - - // Swap config and output - swapConfig, _ := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", - "callData": "exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))", - "callInput": "[\"0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238\",\"0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14\",3000,\"0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f\",20990000,0,0,0]", - }) - swapOutput, _ := structpb.NewValue(map[string]interface{}{ - "exactInputSingle": map[string]interface{}{ - "amountOut": "2235380089399511", - }, - }) - swapMetadata, _ := structpb.NewValue(map[string]interface{}{ - "transactionHash": "0x123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01", - }) - - startTime := int64(1769651702059) - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "node0", Name: "eventTrigger", Type: avsproto.TriggerType_TRIGGER_TYPE_EVENT.String(), Success: true, - StartAt: startTime, EndAt: startTime + 1, - Config: eventTriggerConfig, - ExecutionContext: realCtx, - OutputData: &avsproto.Execution_Step_EventTrigger{ - EventTrigger: &avsproto.EventTrigger_Output{Data: triggerOutput}, - }, - }, - { - Id: "node1", Name: "balance1", Type: avsproto.NodeType_NODE_TYPE_BALANCE.String(), Success: true, - StartAt: startTime + 100, EndAt: startTime + 500, - Config: balanceConfig, - ExecutionContext: realCtx, - OutputData: &avsproto.Execution_Step_Balance{ - Balance: &avsproto.BalanceNode_Output{Data: balanceOutput}, - }, - }, - { - Id: "node2", Name: "approve1", Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), Success: true, - StartAt: startTime + 500, EndAt: startTime + 2000, - Config: approveConfig, - Metadata: approveMetadata, - ExecutionContext: realCtx, - OutputData: &avsproto.Execution_Step_ContractWrite{ - ContractWrite: &avsproto.ContractWriteNode_Output{Data: approveOutput}, - }, - }, - { - Id: "node3", Name: "swap1", Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), Success: true, - StartAt: startTime + 2000, EndAt: startTime + 4000, - Config: swapConfig, - Metadata: swapMetadata, - ExecutionContext: realCtx, - OutputData: &avsproto.Execution_Step_ContractWrite{ - ContractWrite: &avsproto.ContractWriteNode_Output{Data: swapOutput}, - }, - }, - } - - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "node0": {Id: "node0", Name: "eventTrigger"}, - "node1": { - Id: "node1", Name: "balance1", - TaskType: &avsproto.TaskNode_Balance{Balance: &avsproto.BalanceNode{ - Config: &avsproto.BalanceNode_Config{ - Address: "{{settings.runner}}", - Chain: "sepolia", - }, - }}, - }, - "node2": { - Id: "node2", Name: "approve1", - TaskType: &avsproto.TaskNode_ContractWrite{ContractWrite: &avsproto.ContractWriteNode{ - Config: &avsproto.ContractWriteNode_Config{ - ContractAddress: "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238", - CallData: "approve(address,uint256)", - }, - }}, - }, - "node3": { - Id: "node3", Name: "swap1", - TaskType: &avsproto.TaskNode_ContractWrite{ContractWrite: &avsproto.ContractWriteNode{ - Config: &avsproto.ContractWriteNode_Config{ - ContractAddress: "0x3bFA4769FB09eefC5a80d6E87c3B9C650f7Ae48E", - CallData: "exactInputSingle((address,address,uint24,address,uint256,uint256,uint256,uint160))", - }, - }}, - }, - "node4": { - Id: "node4", Name: "telegram_send", - TaskType: &avsproto.TaskNode_RestApi{RestApi: &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: "https://api.telegram.org/bot123/sendMessage", - Method: "POST", - }, - }}, - }, - "node5": { - Id: "node5", Name: "email1", - TaskType: &avsproto.TaskNode_RestApi{RestApi: &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: "https://api.sendgrid.com/v3/mail/send", - Method: "POST", - }, - }}, - }, - } - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "Stoploss USDC->WETH", - "chain": "Sepolia", - "runner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "owner": "0x804e49e8C4eDb560AE7c48B554f6d2e27Bb81557", - "chain_id": float64(11155111), - }, - } - vm.mu.Unlock() - - // --- AI path --- - summarizer := NewContextMemorySummarizer(apiURL, apiKey) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - aiSummary, aiErr := summarizer.Summarize(ctx, vm, "telegram_send") - if aiErr != nil { - t.Logf("AI summarization failed: %v", aiErr) - t.Skip("Context-memory API not available") - } - t.Logf("AI Summary received: subject=%q, status=%q, bodyLen=%d", aiSummary.Subject, aiSummary.Status, len(aiSummary.Body)) - - // --- Deterministic path --- - detSummary := ComposeSummary(vm, "telegram_send") - t.Logf("Det Summary: subject=%q, status=%q, bodyLen=%d", detSummary.Subject, detSummary.Status, len(detSummary.Body)) - - // --- Compare --- - diffs := compareAndReport(t, "RealExecution", aiSummary, detSummary) - - // --- Format for all channels --- - t.Log("\n=== AI Summary formatted for channels ===") - formatForAllChannels(t, "AI-Real", aiSummary, vm) - t.Log("\n=== Deterministic Summary formatted for channels ===") - formatForAllChannels(t, "Det-Real", detSummary, vm) - - // --- Report discrepancies --- - if len(diffs) > 0 { - t.Log("\n========================================") - t.Logf("DISCREPANCIES FOUND: %d", len(diffs)) - t.Log("========================================") - for i, d := range diffs { - t.Logf(" %d. %s", i+1, d.String()) - } - } else { - t.Log("\nNo discrepancies found between AI and deterministic paths.") - } - - // --- Key assertions --- - // Real execution should NOT have annotation - if detSummary.Annotation != "" { - t.Errorf("Deterministic should NOT have Annotation for real execution, got: %s", detSummary.Annotation) - } - // Status model: "success" | "failed" | "error". Branch-skips are reported via - // SkippedSteps > 0, not as a distinct status value. - if aiSummary.Status != "success" { - t.Logf("NOTE: AI status is %q", aiSummary.Status) - } - if detSummary.Status != "success" { - t.Errorf("Deterministic status should be success, got: %s", detSummary.Status) - } - // Notification nodes (telegram_send, email1) should NOT be counted in step totals. - // getTotalWorkflowSteps counts: 1 (trigger) + non-notification TaskNodes. - // TaskNodes: eventTrigger + balance1 + approve1 + contractWrite1 = 4 non-notification - // Total = 1 + 4 = 5 (the trigger node is counted both as trigger and as a TaskNode) - // executedSteps = 4 (trigger + balance1 + approve1 + contractWrite1, excluding notification logs) - if strings.Contains(detSummary.SummaryLine, "out of") { - t.Logf("Det SummaryLine: %s", detSummary.SummaryLine) - if !strings.Contains(detSummary.SummaryLine, "4 out of 5") { - t.Errorf("Expected '4 out of 5' in SummaryLine, got: %s", detSummary.SummaryLine) - } - } - - // Real execution steps have valid tx hashes and non-simulated ExecutionContext - // The deterministic path should attach txHash for these steps - validTxHashes := map[string]string{ - "approve1": "0xdef456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0", - "swap1": "0x123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef01", - } - for i, exec := range detSummary.Executions { - if exec.TxHash == "" { - t.Logf("Det execution[%d] has no txHash (may not be CONTRACT_WRITE)", i) - } else { - t.Logf("Det execution[%d]: txHash=%s", i, exec.TxHash) - if !isValidTxHash(exec.TxHash) { - t.Errorf("Det execution[%d] has invalid txHash: %s", i, exec.TxHash) - } - } - } - _ = validTxHashes // logged above for reference -} - -// ============================================================================ -// Test 4: Format consistency check - AI summary through FormatForMessageChannels -// ============================================================================ - -func TestAIIntegration_FormatConsistency(t *testing.T) { - apiURL, apiKey := loadSepoliaConfig(t) - t.Logf("Testing Format Consistency against: %s", apiURL) - - // Build a simulation VM with proper config/output data for meaningful AI results - vm := NewVM() - vm.IsSimulation = true - - simCtxStruct, _ := structpb.NewStruct(map[string]interface{}{ - "is_simulated": true, - "provider": "tenderly", - "chain_id": float64(11155111), - }) - simCtx := structpb.NewStructValue(simCtxStruct) - - triggerConfig, _ := structpb.NewValue(map[string]interface{}{ - "schedules": []interface{}{"0 */6 * * *"}, - }) - triggerOutput, _ := structpb.NewValue(map[string]interface{}{ - "timestamp": float64(1769651702059), - "timestampIso": "2026-01-29T01:55:02.059Z", - }) - balanceConfig, _ := structpb.NewValue(map[string]interface{}{ - "address": "{{settings.runner}}", - "chain": "sepolia", - }) - balanceOutput, _ := structpb.NewValue([]interface{}{ - map[string]interface{}{ - "balance": "500000000000000000", "balanceFormatted": "0.5", - "decimals": float64(18), "name": "Ether", "symbol": "ETH", - }, - }) - transferConfig, _ := structpb.NewValue(map[string]interface{}{ - "destination": "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - "amount": "100000000000000000", - }) - transferOutput, _ := structpb.NewValue(map[string]interface{}{ - "transfer": map[string]interface{}{ - "from": "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - "to": "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - "value": "100000000000000000", - }, - }) - transferMetadata, _ := structpb.NewValue(map[string]interface{}{ - "transactionHash": "0x0000000000000000000000000000000000000000000001769651703156844000", - }) - - startTime := int64(1769651702059) - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "node0", Name: "timeTrigger", Type: avsproto.TriggerType_TRIGGER_TYPE_CRON.String(), Success: true, - StartAt: startTime, EndAt: startTime + 1, - Config: triggerConfig, - ExecutionContext: simCtx, - OutputData: &avsproto.Execution_Step_CronTrigger{ - CronTrigger: &avsproto.CronTrigger_Output{Data: triggerOutput}, - }, - }, - { - Id: "node1", Name: "balance1", Type: avsproto.NodeType_NODE_TYPE_BALANCE.String(), Success: true, - StartAt: startTime + 100, EndAt: startTime + 500, - Config: balanceConfig, - ExecutionContext: simCtx, - OutputData: &avsproto.Execution_Step_Balance{ - Balance: &avsproto.BalanceNode_Output{Data: balanceOutput}, - }, - }, - { - Id: "node2", Name: "transfer1", Type: avsproto.NodeType_NODE_TYPE_ETH_TRANSFER.String(), Success: true, - StartAt: startTime + 500, EndAt: startTime + 600, - Config: transferConfig, - Metadata: transferMetadata, - ExecutionContext: simCtx, - OutputData: &avsproto.Execution_Step_EthTransfer{ - EthTransfer: &avsproto.ETHTransferNode_Output{Data: transferOutput}, - }, - }, - } - - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "node0": {Id: "node0", Name: "timeTrigger"}, - "node1": { - Id: "node1", Name: "balance1", - TaskType: &avsproto.TaskNode_Balance{Balance: &avsproto.BalanceNode{ - Config: &avsproto.BalanceNode_Config{ - Address: "{{settings.runner}}", - Chain: "sepolia", - }, - }}, - }, - "node2": { - Id: "node2", Name: "transfer1", - TaskType: &avsproto.TaskNode_EthTransfer{EthTransfer: &avsproto.ETHTransferNode{ - Config: &avsproto.ETHTransferNode_Config{ - Destination: "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - Amount: "100000000000000000", - }, - }}, - }, - "node3": { - Id: "node3", Name: "telegram_send", - TaskType: &avsproto.TaskNode_RestApi{RestApi: &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: "https://api.telegram.org/bot123/sendMessage", - Method: "POST", - }, - }}, - }, - } - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "Format Test Workflow", - "chain": "Sepolia", - "runner": "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - }, - } - vm.mu.Unlock() - - // --- AI path --- - summarizer := NewContextMemorySummarizer(apiURL, apiKey) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - aiSummary, aiErr := summarizer.Summarize(ctx, vm, "telegram_send") - if aiErr != nil { - t.Skip("Context-memory API not available") - } - - // --- Deterministic path --- - detSummary := ComposeSummary(vm, "telegram_send") - - // --- Compare Telegram output --- - aiTelegram := FormatForMessageChannels(aiSummary, "telegram", vm) - detTelegram := FormatForMessageChannels(detSummary, "telegram", vm) - - t.Logf("AI Telegram:\n%s", aiTelegram) - t.Logf("\nDet Telegram:\n%s", detTelegram) - - // Check that both produce non-empty Telegram output - if aiTelegram == "" { - t.Error("AI Telegram output is empty") - } - if detTelegram == "" { - t.Error("Det Telegram output is empty") - } - - // Check that AI path uses structured formatters (not legacy body-based) - // AI summary should have structured data so formatTelegramFromStructured is used - hasAIStructured := len(aiSummary.Transfers) > 0 || aiSummary.Workflow != nil || - len(aiSummary.Executions) > 0 || len(aiSummary.Errors) > 0 || aiSummary.Trigger != "" - if !hasAIStructured { - t.Log("WARNING: AI summary has no structured data, will use legacy formatter") - } - - // Both paths should go through structured formatters (both set Executions) - hasDetStructured := len(detSummary.Executions) > 0 || len(detSummary.Errors) > 0 || detSummary.Trigger != "" - if !hasDetStructured { - t.Log("WARNING: Det summary has no structured data, will use legacy formatter") - } - - // --- Compare Email output --- - aiEmail := aiSummary.SendGridDynamicData() - detEmail := detSummary.SendGridDynamicData() - - t.Logf("\nAI Email keys: %v", mapKeys(aiEmail)) - t.Logf("Det Email keys: %v", mapKeys(detEmail)) - - // Both should have annotation only for deterministic singleNode (not this simulation) - if _, hasAnn := aiEmail["annotation"]; hasAnn { - t.Log("NOTE: AI email has annotation (unexpected for simulation)") - } - if _, hasAnn := detEmail["annotation"]; hasAnn { - t.Error("Det email should NOT have annotation for simulation") - } - - // Check key email fields - for _, key := range []string{"subject", "status", "analysisHtml"} { - aiVal, aiOk := aiEmail[key] - detVal, detOk := detEmail[key] - if aiOk && detOk { - t.Logf("Email[%s] AI=%q Det=%q", key, truncateStr(fmt.Sprintf("%v", aiVal), 100), truncateStr(fmt.Sprintf("%v", detVal), 100)) - } else if !aiOk && detOk { - t.Logf("Email[%s] MISSING in AI, Det=%q", key, truncateStr(fmt.Sprintf("%v", detVal), 100)) - } - } -} - -// ============================================================================ -// Test 5: Full pipeline through ComposeSummarySmart -// ============================================================================ - -func TestAIIntegration_ComposeSummarySmart(t *testing.T) { - apiURL, apiKey := loadSepoliaConfig(t) - t.Logf("Testing ComposeSummarySmart against: %s", apiURL) - - // Set up the global summarizer with the real API - origSummarizer := globalSummarizer - defer SetSummarizer(origSummarizer) - - SetSummarizer(NewContextMemorySummarizer(apiURL, apiKey)) - - // Build a simulation VM - vm := NewVM() - vm.IsSimulation = true - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - {Id: "node0", Name: "timeTrigger", Type: avsproto.TriggerType_TRIGGER_TYPE_CRON.String(), Success: true}, - {Id: "node1", Name: "balance1", Type: avsproto.NodeType_NODE_TYPE_BALANCE.String(), Success: true}, - } - - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "node0": {Id: "node0", Name: "timeTrigger"}, - "node1": {Id: "node1", Name: "balance1"}, - "node2": { - Id: "node2", Name: "email1", - TaskType: &avsproto.TaskNode_RestApi{RestApi: &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: "https://api.sendgrid.com/v3/mail/send", - Method: "POST", - }, - }}, - }, - } - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "Smart Test Workflow", - "chain": "Sepolia", - "runner": "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - }, - } - vm.mu.Unlock() - - // Call ComposeSummarySmart which tries AI first, falls back to deterministic - smartSummary := ComposeSummarySmart(vm, "email1") - - t.Logf("Smart Summary: subject=%q, status=%q, bodyLen=%d", smartSummary.Subject, smartSummary.Status, len(smartSummary.Body)) - t.Logf("Smart SummaryLine: %s", smartSummary.SummaryLine) - t.Logf("Smart Body: %s", smartSummary.Body) - - // Validate that ComposeSummarySmart returns a valid summary - if smartSummary.Subject == "" { - t.Error("ComposeSummarySmart returned empty subject") - } - if smartSummary.Body == "" { - t.Error("ComposeSummarySmart returned empty body") - } - if len(smartSummary.Body) < 40 { - t.Errorf("ComposeSummarySmart body too short (len=%d), might have fallen back", len(smartSummary.Body)) - } - - // Format output - t.Log("\n=== ComposeSummarySmart formatted output ===") - formatForAllChannels(t, "Smart", smartSummary, vm) - - // Also get pure deterministic for comparison - SetSummarizer(nil) - detSummary := ComposeSummarySmart(vm, "email1") - t.Logf("\nDet (fallback) Summary: subject=%q", detSummary.Subject) - - // Compare the two - if smartSummary.Subject != detSummary.Subject { - t.Logf("Subject differs between Smart (AI) and Det: AI=%q, Det=%q", smartSummary.Subject, detSummary.Subject) - } -} diff --git a/core/taskengine/summarizer_format_plaintext.go b/core/taskengine/summarizer_format_plaintext.go deleted file mode 100644 index c64fac2c..00000000 --- a/core/taskengine/summarizer_format_plaintext.go +++ /dev/null @@ -1,129 +0,0 @@ -package taskengine - -import "strings" - -// formatPlainTextFromStructured formats Summary into plain text using AI-generated strings -// Uses the API response fields directly without composing additional text -// Format: Subject as header, Smart Wallet, then trigger, executions, errors -func formatPlainTextFromStructured(s Summary) string { - var sb strings.Builder - - // Subject as header - if s.Subject != "" { - sb.WriteString(s.Subject) - sb.WriteString("\n") - } - - // Smart Wallet line (right beneath title) - if s.SmartWallet != "" { - sb.WriteString("Smart Wallet: ") - sb.WriteString(s.SmartWallet) - sb.WriteString("\n") - } - - // Add blank line before trigger if we have subject or smart wallet - if s.Subject != "" || s.SmartWallet != "" { - sb.WriteString("\n") - } - - // Trigger (AI-generated text) - if s.Trigger != "" { - sb.WriteString(s.Trigger) - sb.WriteString("\n\n") - } - - // Executions (AI-generated descriptions) - if len(s.Executions) > 0 { - for _, exec := range s.Executions { - sb.WriteString("- ") - sb.WriteString(exec.Description) - sb.WriteString("\n") - if exec.TxHash != "" { - if url := buildTxExplorerURL(s, exec.TxHash); url != "" { - sb.WriteString(" Transaction: ") - sb.WriteString(url) - sb.WriteString("\n") - } - } - } - } - - // "What Went Wrong" section — consistent with email - if len(s.Errors) > 0 { - sb.WriteString("\nWhat Went Wrong:\n") - for _, err := range s.Errors { - sb.WriteString("- ") - sb.WriteString(err) - sb.WriteString("\n") - } - } - - if s.Annotation != "" { - sb.WriteString("\n") - sb.WriteString(s.Annotation) - } - - return strings.TrimSpace(sb.String()) -} - -func formatPlainTextExampleMessage(workflowName, chainName string) string { - var sb strings.Builder - - sb.WriteString(workflowName) - sb.WriteString(" completed\n\n") - - sb.WriteString("Network: ") - sb.WriteString(chainName) - sb.WriteString("\n\n") - - sb.WriteString("Executed:\n") - sb.WriteString("• (Simulated) ") - sb.WriteString(ExampleExecutionMessage) - sb.WriteString("\n\n") - - sb.WriteString(ExampleExecutionAnnotation) - - return sb.String() -} - -// formatChannelFromBody is the legacy formatter using plain text body. -// Used as a fallback when no structured data is available. -func formatChannelFromBody(s Summary, channel string) string { - body := strings.TrimSpace(s.Body) - subject := strings.TrimSpace(s.Subject) - if body == "" { - return subject - } - // Extract the first sentence or up to ~200 chars, whichever comes first. - maxLen := 220 - msg := body - // Split on blank lines first (since email body uses double newlines). - parts := strings.SplitN(body, "\n\n", 2) - if len(parts) > 0 && strings.TrimSpace(parts[0]) != "" { - msg = strings.TrimSpace(parts[0]) - } - // Hard cap length for Telegram-style brevity. - if len(msg) > maxLen { - msg = msg[:maxLen] - // avoid cutting in the middle of a word - if idx := strings.LastIndex(msg, " "); idx > 0 { - msg = msg[:idx] - } - msg += "…" - } - // Channel-specific formatting - switch strings.ToLower(channel) { - case "telegram": - if subject != "" && !strings.Contains(msg, subject) { - return "" + subject + "\n" + msg - } - return msg - case "discord": - if subject != "" && !strings.Contains(msg, subject) { - return "**" + subject + "**\n" + msg - } - return msg - default: - return msg - } -} diff --git a/core/taskengine/summarizer_format_telegram.go b/core/taskengine/summarizer_format_telegram.go deleted file mode 100644 index b446d3bb..00000000 --- a/core/taskengine/summarizer_format_telegram.go +++ /dev/null @@ -1,308 +0,0 @@ -package taskengine - -import ( - "fmt" - "html" - "strings" -) - -// formatTelegramFromStructured formats Summary into Telegram HTML using structured data -// Uses the PRD format: emoji + subject, network, time, executions list, footer -// All user-controlled content is HTML-escaped to prevent XSS attacks -func formatTelegramFromStructured(s Summary) string { - var sb strings.Builder - - // Get status emoji - API returns subject WITHOUT emoji, aggregator prepends it - statusEmoji := getStatusEmoji(s) - - // Subject as header with emoji prefix - only bold the workflow name - if s.Subject != "" { - sb.WriteString(statusEmoji) - if statusEmoji != "" { - sb.WriteString(" ") - } - sb.WriteString(formatSubjectWithBoldName(s.Subject)) - sb.WriteString("\n") - } - - // Skipped note (success runs with branch-skipped steps) — surfaces why the - // header shows ⚠️ instead of ✅. - if s.SkippedNote != "" { - sb.WriteString("") - sb.WriteString(html.EscapeString(s.SkippedNote)) - sb.WriteString("\n") - } - - // Network: use body.network field, fallback to workflow.chain or derive from chainID - network := s.Network - if network == "" && s.Workflow != nil { - network = s.Workflow.Chain - if network == "" && s.Workflow.ChainID > 0 { - network = getChainDisplayName(s.Workflow.ChainID) - } - } - - // Time / Runner / Gas — metadata block. Network is folded into the Runner - // line ("Runner: 0x… on Sepolia") to save a line, since chain is contextual - // to the wallet that ran the workflow. When Runner is absent we keep - // Network as a standalone line so chain context isn't lost. - // Gas follows Runner so the "who and what it cost on-chain" pair stays - // adjacent. For simulations the Gas line is a placeholder - // ("⛽ (cost estimated at deploy)") — actual gas numbers only appear for - // deployed runs with real receipts. Runner addresses are intentionally - // NOT -wrapped (hex addresses don't trigger Telegram auto-linking; - // the wrap adds noise). - hasRunner := s.Runner != nil && s.Runner.SmartWallet != "" - costLine := formatTelegramGasLine(s) - if network != "" || s.TriggeredAt != "" || hasRunner || costLine != "" { - sb.WriteString("\n") - if s.TriggeredAt != "" { - sb.WriteString("Time: ") - sb.WriteString(html.EscapeString(formatTimestampHumanReadable(s.TriggeredAt))) - sb.WriteString("\n") - } - switch { - case hasRunner && network != "": - sb.WriteString("Runner: ") - sb.WriteString(html.EscapeString(truncateAddress(s.Runner.SmartWallet))) - sb.WriteString(" on ") - sb.WriteString(html.EscapeString(network)) - sb.WriteString("\n") - case hasRunner: - sb.WriteString("Runner: ") - sb.WriteString(html.EscapeString(truncateAddress(s.Runner.SmartWallet))) - sb.WriteString("\n") - case network != "": - sb.WriteString("Network: ") - sb.WriteString(html.EscapeString(network)) - sb.WriteString("\n") - } - if costLine != "" { - sb.WriteString(costLine) - } - } - - // Trigger section - if s.Trigger != "" { - sb.WriteString("\nTrigger: ") - sb.WriteString(html.EscapeString(s.Trigger)) - sb.WriteString("\n") - } - - // Display executions with "Executed:" header (PRD format) - if len(s.Executions) > 0 { - sb.WriteString("\nExecuted:\n") - for _, exec := range s.Executions { - sb.WriteString("• ") - sb.WriteString(formatBackticksForChannel(exec.Description, "telegram")) - sb.WriteString("\n") - if exec.TxHash != "" { - if explorerURL := buildTxExplorerURL(s, exec.TxHash); explorerURL != "" { - sb.WriteString(" Transaction: ") - sb.WriteString(html.EscapeString(truncateTxHash(exec.TxHash))) - sb.WriteString("\n") - } - } - } - } - - // "What Went Wrong" section — consistent with email - if len(s.Errors) > 0 { - sb.WriteString("\nWhat Went Wrong:\n") - for _, err := range s.Errors { - sb.WriteString("• ") - sb.WriteString(formatBackticksForChannel(err, "telegram")) - sb.WriteString("\n") - } - } - - if s.Annotation != "" { - sb.WriteString("\n") - sb.WriteString(html.EscapeString(s.Annotation)) - sb.WriteString("") - } - - return strings.TrimSpace(sb.String()) -} - -// getStatusEmoji returns the emoji for the summary's status. -// A success run with branch-skipped steps renders ⚠️ (warn) rather than ✅, -// matching the yellow badge on email. -func getStatusEmoji(s Summary) string { - switch s.Status { - case "success": - if s.SkippedSteps > 0 { - return "⚠️" - } - return "✅" - case "failed", "error": - return "❌" - default: - return "" - } -} - -// formatSubjectWithBoldName formats the subject with tags around the -// prefix + workflow name (everything except the trailing status suffix). -// Subject patterns: -// - "Simulation: {name} successfully completed" -// - "Run Node: {name} succeeded" -// - "Run #N: {name} successfully completed" -// - "{name} successfully completed" -// - And similar patterns for "failed to execute" and "partially executed" -func formatSubjectWithBoldName(subject string) string { - // Suffixes to look for (ordered from most specific to least) - suffixes := []string{ - " successfully completed", - " failed to execute", - " partially executed", - " succeeded", - } - - // Prefixes to look for - prefixes := []string{ - "Simulation: ", - "Run Node: ", - } - - // Check for "Run #N: " prefix pattern - runPrefix := "" - if strings.HasPrefix(subject, "Run #") { - // Find the ": " after "Run #N" - if idx := strings.Index(subject, ": "); idx > 0 { - runPrefix = subject[:idx+2] - } - } - - // Find which suffix matches - var suffix string - var nameEnd int - for _, s := range suffixes { - if strings.HasSuffix(subject, s) { - suffix = s - nameEnd = len(subject) - len(s) - break - } - } - - // Check for deployed workflow format: "{name}: succeeded (...)" or "{name}: failed at ..." - // Must be checked before the generic " failed at " pattern to avoid splitting at the wrong point. - if suffix == "" { - for _, marker := range []string{": succeeded (", ": failed at "} { - if idx := strings.Index(subject, marker); idx > 0 { - suffix = subject[idx:] - nameEnd = idx - break - } - } - } - - // Check for "failed at " suffix (Run Node failure format) - if suffix == "" && strings.Contains(subject, " failed at ") { - idx := strings.LastIndex(subject, " failed at ") - if idx > 0 { - suffix = subject[idx:] - nameEnd = idx - } - } - - // If no suffix found, just escape and return - if suffix == "" { - return html.EscapeString(subject) - } - - // Find the prefix and extract the name - var prefix string - nameStart := 0 - - if runPrefix != "" { - prefix = runPrefix - nameStart = len(runPrefix) - } else { - for _, p := range prefixes { - if strings.HasPrefix(subject, p) { - prefix = p - nameStart = len(p) - break - } - } - } - - // Extract the workflow name - name := subject[nameStart:nameEnd] - - // Build the formatted string: prefix + name + suffix - // Using prevents Telegram from auto-linking names that contain dots - var sb strings.Builder - sb.WriteString("") - if prefix != "" { - sb.WriteString(html.EscapeString(prefix)) - } - sb.WriteString(html.EscapeString(name)) - sb.WriteString("") - sb.WriteString(html.EscapeString(suffix)) - - return sb.String() -} - -// formatTelegramGasLine renders a single Gas line from Summary.Fees.Total — -// the actual on-chain gas the UserOp spent, in native token units. -// Format: "⛽ Gas: 0.000003 ETH ($0.01)". Unpriceable tokens render -// as "$?". For simulations the line collapses to the static -// "⛽ (see cost estimate before deploy)" placeholder. -// -// Returns "" when there's no gas to report (e.g. read-only workflows that -// only paid the platform fee). The platform fee and per-token value fees -// are tracked in Fees.Total for API consumers and billing but are NOT -// rendered in the notification — calling them "cost" under the ⛽ (gas) -// emoji was misleading. If a future product surface wants the full -// breakdown, render it under its own emoji on its own line. -func formatTelegramGasLine(s Summary) string { - if s.Workflow != nil && s.Workflow.IsSimulation { - return "⛽ (see cost estimate before deploy)\n" - } - if s.Fees == nil || len(s.Fees.Total) == 0 { - return "" - } - for _, t := range s.Fees.Total { - if t == nil || !t.IsGas || t.Amount == "" || t.Amount == "0" { - continue - } - usd := "$?" - if t.USD != "" { - usd = "$" + t.USD - } - return fmt.Sprintf("⛽ Gas: %s %s (%s)\n", - html.EscapeString(t.Amount), html.EscapeString(t.Unit), usd) - } - return "" -} - -func formatTelegramExampleMessage(workflowName, chainName string) string { - var sb strings.Builder - - // Status line with emoji and workflow name (code-wrapped to prevent auto-linking) - sb.WriteString("✅ ") - sb.WriteString(html.EscapeString(workflowName)) - sb.WriteString(" completed\n\n") - - // Network - sb.WriteString("Network: ") - sb.WriteString(html.EscapeString(chainName)) - sb.WriteString("\n\n") - - // Executed section with example - sb.WriteString("Executed:\n") - sb.WriteString("• (Simulated) ") - sb.WriteString(ExampleExecutionMessage) - sb.WriteString("\n\n") - - // Example notice - sb.WriteString("") - sb.WriteString(ExampleExecutionAnnotation) - sb.WriteString("") - - return sb.String() -} diff --git a/core/taskengine/summarizer_format_test.go b/core/taskengine/summarizer_format_test.go deleted file mode 100644 index 4a0dd0b4..00000000 --- a/core/taskengine/summarizer_format_test.go +++ /dev/null @@ -1,2117 +0,0 @@ -package taskengine - -import ( - "context" - "fmt" - "math/big" - "os" - "strings" - "testing" - - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "google.golang.org/protobuf/types/known/structpb" -) - -const ( - // Default auth token for local context-memory tests - ContextMemoryAuthToken = "test-auth-token-12345" - - // defaultSummarizerURL is the production Studio origin, used ONLY as a convenience - // fallback in tests when CONTEXT_MEMORY_URL is unset. Production code has no hardcoded - // default — it reads the origin from notifications.summary.api_endpoint and fails fast - // at startup if it is missing. - defaultSummarizerURL = "https://app.avaprotocol.org" -) - -type fakeSummarizer struct { - resp Summary - err error -} - -func (f *fakeSummarizer) Summarize(ctx context.Context, vm *VM, currentStepName string) (Summary, error) { - return f.resp, f.err -} - -func TestComposeSummarySmart_FallbackDeterministic(t *testing.T) { - SetSummarizer(nil) - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{"name": "Workflow X"} - vm.mu.Unlock() - vm.ExecutionLogs = []*avsproto.Execution_Step{{ - Name: "Step A", - Success: true, - }} - - s := ComposeSummarySmart(vm, "rest1") - if !strings.Contains(s.Subject, "Workflow X: succeeded") { - t.Fatalf("subject should contain 'Workflow X: succeeded', got: %q", s.Subject) - } - // Body should contain the workflow name and completion message - // The exact format may vary based on available context (smart wallet, owner, etc.) - // Just verify it contains the essential elements - if s.Body == "" { - t.Fatalf("body should not be empty") - } - if !strings.Contains(s.Body, "Workflow X") && !strings.Contains(s.Body, "Step A") { - t.Fatalf("body should mention workflow or step name, got: %q", s.Body) - } -} - -func TestComposeSummarySmart_UsesAISummarizer(t *testing.T) { - defer SetSummarizer(nil) - - // Check if we should use real context-memory API - authToken := os.Getenv("SERVICE_AUTH_TOKEN") - if authToken != "" { - // Use real context-memory API - baseURL := os.Getenv("CONTEXT_MEMORY_URL") - if baseURL == "" { - baseURL = defaultSummarizerURL // Test-only fallback when CONTEXT_MEMORY_URL is unset - } - t.Logf("Using real context-memory API at: %s", baseURL) - summarizer := NewContextMemorySummarizer(baseURL, authToken) - SetSummarizer(summarizer) - - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{"name": "Test Workflow"} - vm.mu.Unlock() - vm.ExecutionLogs = []*avsproto.Execution_Step{ - {Id: "step1", Name: "test_step", Type: "balance", Success: true}, - } - - s := ComposeSummarySmart(vm, "current") - if s.Subject == "" { - t.Fatalf("AI summarizer should return non-empty subject, got empty") - } - if len(s.Body) < 40 { - t.Fatalf("AI summarizer body should be at least 40 characters, got %d", len(s.Body)) - } - t.Logf("Real API response - Subject: %s, Body length: %d", s.Subject, len(s.Body)) - } else { - // Fallback to mock for CI/testing without SERVICE_AUTH_TOKEN - // Body must be at least 40 characters to pass validation in ComposeSummarySmart - f := &fakeSummarizer{resp: Summary{ - Subject: "AI subject", - Body: "This is a sufficiently long AI-generated body text that exceeds the 40 character minimum.", - }} - SetSummarizer(f) - vm := NewVM() - s := ComposeSummarySmart(vm, "current") - if s.Subject != "AI subject" { - t.Fatalf("ai summarizer subject not used: expected 'AI subject', got %q", s.Subject) - } - if !strings.Contains(s.Body, "AI-generated body text") { - t.Fatalf("ai summarizer body not used: got %q", s.Body) - } - } -} - -func TestComposeSummarySmart_AIFailsFallback(t *testing.T) { - defer SetSummarizer(nil) - f := &fakeSummarizer{err: context.DeadlineExceeded} - SetSummarizer(f) - vm := NewVM() - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{"name": "Workflow Y"} - vm.mu.Unlock() - vm.ExecutionLogs = []*avsproto.Execution_Step{{ - Name: "Done", - Success: true, - }} - - s := ComposeSummarySmart(vm, "rest1") - if !strings.Contains(s.Subject, "Workflow Y: succeeded") { - t.Fatalf("fallback failed, subject should contain 'Workflow Y: succeeded', got: %q", s.Subject) - } -} - -func TestFormatForMessageChannels_Telegram(t *testing.T) { - tests := []struct { - name string - summary Summary - expectedContain []string - maxLength int - }{ - { - name: "short summary with subject", - summary: Summary{ - Subject: "Swap: succeeded (3 steps)", - Body: "Smart wallet 0xabc executed a swap on Uniswap V3.", - }, - expectedContain: []string{"Swap: succeeded (3 steps)", "Smart wallet 0xabc executed a swap"}, - maxLength: 300, - }, - { - name: "long summary gets truncated", - summary: Summary{ - Subject: "Trade: succeeded (5 steps)", - Body: "Smart wallet 0x123 executed multiple trades on Uniswap V3. " + - "First it approved 1000 USDC to the router contract at 0xdef. " + - "Then it swapped 100 USDC for approximately 0.025 WETH via the pool. " + - "Finally it performed another swap of 50 USDC for DAI tokens at contract 0x456. " + - "All transactions completed successfully on Base network.", - }, - expectedContain: []string{"Trade: succeeded (5 steps)", "Smart wallet 0x123"}, - maxLength: 300, - }, - { - name: "empty body returns subject", - summary: Summary{ - Subject: "Workflow: failed (1 step)", - Body: "", - }, - expectedContain: []string{"Workflow: failed (1 step)"}, - maxLength: 100, - }, - { - name: "body with double newlines extracts first paragraph", - summary: Summary{ - Subject: "Approve: succeeded (1 step)", - Body: "Smart wallet 0xabc approved 500 USDC to Uniswap router.\n\nThis allows future swaps without additional approvals.", - }, - expectedContain: []string{"Approve: succeeded (1 step)", "Smart wallet 0xabc approved 500 USDC"}, - maxLength: 300, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := FormatForMessageChannels(tt.summary, "telegram", nil) - - // Check that result contains expected strings - for _, expected := range tt.expectedContain { - if !strings.Contains(result, expected) { - t.Errorf("expected result to contain %q, got: %s", expected, result) - } - } - - // Check length constraint - if len(result) > tt.maxLength { - t.Errorf("result too long: %d chars (max %d), got: %s", len(result), tt.maxLength, result) - } - - // Verify HTML parse mode compatibility (should have tags) - if tt.summary.Subject != "" && tt.summary.Body != "" { - if !strings.HasPrefix(result, "") { - t.Errorf("telegram message should start with tag, got: %s", result) - } - } - }) - } -} - -func TestFormatForMessageChannels_Discord(t *testing.T) { - summary := Summary{ - Subject: "Deploy: succeeded (2 steps)", - Body: "Contract deployed to 0xabc on Base network.", - } - - result := FormatForMessageChannels(summary, "discord", nil) - - // Discord should use markdown bold - if !strings.Contains(result, "**Deploy: succeeded (2 steps)**") { - t.Errorf("discord message should use markdown bold, got: %s", result) - } - if !strings.Contains(result, "Contract deployed to 0xabc") { - t.Errorf("discord message should contain body content, got: %s", result) - } -} - -func TestFormatTransferMessage(t *testing.T) { - tests := []struct { - name string - data *TransferEventData - expected string - }{ - { - name: "sent_eth", - data: &TransferEventData{ - Direction: "sent", - FromAddress: "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - ToAddress: "0x0000000000000000000000000000000000000002", - Value: "1.5", - TokenSymbol: "ETH", - TokenName: "Ether", - BlockTimestamp: 1768943005000, // milliseconds - ChainName: "Sepolia", - }, - expected: "⬆️ Sent 1.5 ETH to 0x0000000000000000000000000000000000000002 on Sepolia", - }, - { - name: "received_usdc", - data: &TransferEventData{ - Direction: "received", - FromAddress: "0x1234567890123456789012345678901234567890", - ToAddress: "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - Value: "100.50", - TokenSymbol: "USDC", - TokenName: "USD Coin", - BlockTimestamp: 1768943005, // seconds - ChainName: "Ethereum", - }, - expected: "⬇️ Received 100.50 USDC from 0x1234567890123456789012345678901234567890 on Ethereum", - }, - { - name: "nil_data", - data: nil, - expected: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := FormatTransferMessage(tt.data) - if tt.expected == "" { - if result != "" { - t.Errorf("expected empty string, got: %s", result) - } - return - } - if !strings.Contains(result, tt.expected) { - t.Errorf("expected result to contain %q, got: %s", tt.expected, result) - } - }) - } -} - -// TestExtractTransferEventData_FromVars tests that transfer data can be extracted -// from vm.vars["transfer_monitor"] when ExecutionLogs has no Transfer event. -// This covers the single-node Telegram notification use case. -func TestExtractTransferEventData_FromVars(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.vars = map[string]any{ - "transfer_monitor": map[string]interface{}{ - "data": map[string]interface{}{ - "eventName": "Transfer", - "direction": "sent", - "fromAddress": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "toAddress": "0x0000000000000000000000000000000000000002", - "value": "1.5", - "tokenSymbol": "ETH", - "tokenName": "Ether", - "blockTimestamp": int64(1768943005000), - }, - }, - "settings": map[string]interface{}{ - "chain": "base", - }, - } - vm.ExecutionLogs = []*avsproto.Execution_Step{} // Empty - no trigger step - vm.mu.Unlock() - - got := ExtractTransferEventData(vm) - if got == nil { - t.Fatal("expected non-nil TransferEventData from vars, got nil") - } - if got.Direction != "sent" { - t.Errorf("expected Direction 'sent', got %q", got.Direction) - } - if got.Value != "1.5" { - t.Errorf("expected Value '1.5', got %q", got.Value) - } - if got.TokenSymbol != "ETH" { - t.Errorf("expected TokenSymbol 'ETH', got %q", got.TokenSymbol) - } - if got.FromAddress != "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f" { - t.Errorf("expected FromAddress, got %q", got.FromAddress) - } - if got.ToAddress != "0x0000000000000000000000000000000000000002" { - t.Errorf("expected ToAddress, got %q", got.ToAddress) - } - // ChainName should be resolved from settings (lowercase as stored) - if got.ChainName != "base" { - t.Errorf("expected ChainName 'base', got %q", got.ChainName) - } - - // Verify FormatTransferMessage produces expected output - msg := FormatTransferMessage(got) - if !strings.Contains(msg, "1.5 ETH") { - t.Errorf("expected message to contain '1.5 ETH', got: %s", msg) - } - if !strings.Contains(msg, "Sent") { - t.Errorf("expected message to contain 'Sent', got: %s", msg) - } - if !strings.Contains(msg, "base") { - t.Errorf("expected message to contain 'base', got: %s", msg) - } -} - -// TestExtractTransferEventData_ExecutionLogsTakesPrecedence verifies that -// ExecutionLogs data is used when present, even if transfer_monitor is also set. -func TestExtractTransferEventData_ExecutionLogsTakesPrecedence(t *testing.T) { - vm := NewVM() - - // Create a protobuf struct for the event trigger data - eventData := map[string]interface{}{ - "eventName": "Transfer", - "direction": "received", - "fromAddress": "0x1111111111111111111111111111111111111111", - "toAddress": "0x2222222222222222222222222222222222222222", - "value": "99.0", - "tokenSymbol": "USDC", - "tokenName": "USD Coin", - "blockTimestamp": float64(1768943005000), - } - - // Convert to structpb.Value - protoData, _ := structpb.NewValue(eventData) - - vm.mu.Lock() - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "trigger", - Name: "eventTrigger", - Type: "eventTrigger", - Success: true, - OutputData: &avsproto.Execution_Step_EventTrigger{ - EventTrigger: &avsproto.EventTrigger_Output{ - Data: protoData, - }, - }, - }, - } - // Also set transfer_monitor with different data - vm.vars = map[string]any{ - "transfer_monitor": map[string]interface{}{ - "data": map[string]interface{}{ - "eventName": "Transfer", - "direction": "sent", - "value": "1.5", - "tokenSymbol": "ETH", - }, - }, - "settings": map[string]interface{}{ - "chain": "ethereum", - }, - } - vm.mu.Unlock() - - got := ExtractTransferEventData(vm) - if got == nil { - t.Fatal("expected non-nil TransferEventData, got nil") - } - // Should use ExecutionLogs data (received USDC), not transfer_monitor (sent ETH) - if got.Direction != "received" { - t.Errorf("expected Direction 'received' from ExecutionLogs, got %q", got.Direction) - } - if got.TokenSymbol != "USDC" { - t.Errorf("expected TokenSymbol 'USDC' from ExecutionLogs, got %q", got.TokenSymbol) - } - if got.Value != "99.0" { - t.Errorf("expected Value '99.0' from ExecutionLogs, got %q", got.Value) - } -} - -// TestFormatForMessageChannels_SingleNodeExample tests that single-node executions -// without transfer data show an example message with real workflow name and chain. -func TestFormatForMessageChannels_SingleNodeExample(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - // Single node execution: no TaskID, exactly one TaskNode - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "node1": {Id: "node1", Name: "singleNodeExecution_restApi"}, - } - vm.vars = map[string]any{ - "settings": map[string]interface{}{ - "name": "My Transfer Monitor", - "chain": "Base", - }, - } - // No transfer_monitor data - should trigger example message - vm.ExecutionLogs = []*avsproto.Execution_Step{} - vm.mu.Unlock() - - // Empty summary - no structured data - summary := Summary{} - - result := FormatForMessageChannels(summary, "telegram", vm) - - // Should contain workflow name from settings - if !strings.Contains(result, "My Transfer Monitor") { - t.Errorf("expected result to contain workflow name 'My Transfer Monitor', got: %s", result) - } - - // Should contain chain name from settings - if !strings.Contains(result, "Base") { - t.Errorf("expected result to contain chain name 'Base', got: %s", result) - } - - // Should contain the example execution line - if !strings.Contains(result, "(Simulated) On-chain transaction successfully completed") { - t.Errorf("expected result to contain example execution line, got: %s", result) - } - - // Should contain the example notice - if !strings.Contains(result, "This is an example") { - t.Errorf("expected result to contain example notice, got: %s", result) - } - - // Should be formatted as Telegram HTML - if !strings.Contains(result, "") { - t.Errorf("expected Telegram HTML formatting with tags, got: %s", result) - } - - t.Logf("Example message:\n%s", result) -} - -// TestFormatForMessageChannels_SingleNodeExample_Discord tests Discord formatting -func TestFormatForMessageChannels_SingleNodeExample_Discord(t *testing.T) { - vm := NewVM() - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "node1": {Id: "node1", Name: "singleNodeExecution_restApi"}, - } - vm.vars = map[string]any{ - "settings": map[string]interface{}{ - "name": "Price Alert", - "chain_id": 8453, // Base chain ID - }, - } - vm.ExecutionLogs = []*avsproto.Execution_Step{} - vm.mu.Unlock() - - summary := Summary{} - result := FormatForMessageChannels(summary, "discord", vm) - - // Should use Discord markdown formatting - if !strings.Contains(result, "**Price Alert**") { - t.Errorf("expected Discord markdown with **workflow name**, got: %s", result) - } - - // Chain should be resolved from chain_id - if !strings.Contains(result, "Base") { - t.Errorf("expected chain name 'Base' resolved from chain_id, got: %s", result) - } - - t.Logf("Discord example message:\n%s", result) -} - -// TestComposeSummarySmart_WithRealWorkflowState tests the full flow -// with realistic workflow state. Uses real context-memory API if SERVICE_AUTH_TOKEN is set. -func TestComposeSummarySmart_WithRealWorkflowState(t *testing.T) { - defer SetSummarizer(nil) - - vm := NewVM() - vm.TaskID = "01K6H8R583M8WFXM2Z4APP7JTN" - - // Simulate a workflow that ran 4 out of 7 steps - vm.ExecutionLogs = []*avsproto.Execution_Step{ - {Id: "trigger", Name: "eventTrigger", Type: "eventTrigger", Success: true}, - {Id: "step1", Name: "balance1", Type: "balance", Success: true}, - {Id: "step2", Name: "branch1", Type: "branch", Success: true}, - {Id: "step3", Name: "email_report", Type: "restApi", Success: true}, - } - - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "node1": {Id: "node1", Name: "balance1"}, - "node2": {Id: "node2", Name: "branch1"}, - "node3": {Id: "node3", Name: "approve_token1"}, - "node4": {Id: "node4", Name: "get_quote"}, - "node5": {Id: "node5", Name: "run_swap"}, - "node6": {Id: "node6", Name: "email_report"}, - } - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "Test template", - "chain": "Sepolia", - "runner": "0xeCb88a770e1b2Ba303D0dC3B1c6F239fAB014bAE", - }, - } - vm.mu.Unlock() - - // Check if we should use real context-memory API - authToken := os.Getenv("SERVICE_AUTH_TOKEN") - if authToken != "" { - // Use real context-memory API (defaults to localhost:3000) - baseURL := os.Getenv("CONTEXT_MEMORY_URL") - if baseURL == "" { - baseURL = defaultSummarizerURL // Test-only fallback when CONTEXT_MEMORY_URL is unset - } - t.Logf("Using real context-memory API at: %s", baseURL) - summarizer := NewContextMemorySummarizer(baseURL, authToken) - SetSummarizer(summarizer) - } else { - // Use deterministic fallback if no auth token - t.Log("SERVICE_AUTH_TOKEN not set, using deterministic fallback") - SetSummarizer(nil) - } - - summary := ComposeSummarySmart(vm, "email_report") - - // Should show workflow name and execution status - if !strings.Contains(summary.Subject, "Test template") { - t.Errorf("Subject should contain workflow name, got: %s", summary.Subject) - } - - if summary.Body == "" { - t.Error("Body should not be empty") - } - - t.Logf("Subject: %s", summary.Subject) - t.Logf("Body length: %d", len(summary.Body)) -} - -// TestComposeSummary_SimulationSubjectFormat tests that simulation workflows -// generate the correct subject format matching context-memory API expectations -func TestComposeSummary_SimulationSubjectFormat(t *testing.T) { - tests := []struct { - name string - workflowName string - executedSteps int - totalSteps int - hasFailures bool - hasSkippedNodes bool - isSimulation bool - expectedSubject string - expectedSummary string - }{ - { - name: "simulation successfully completed", - workflowName: "Test Stoploss", - executedSteps: 7, - totalSteps: 7, - hasFailures: false, - hasSkippedNodes: false, - isSimulation: true, - expectedSubject: "Simulation: Test Stoploss successfully completed", - expectedSummary: "Your workflow 'Test Stoploss' executed 7 out of 7 total steps", - }, - { - name: "simulation partially executed", - workflowName: "Test Stoploss", - executedSteps: 5, - totalSteps: 8, - hasFailures: false, - hasSkippedNodes: true, - isSimulation: true, - expectedSubject: "Simulation: Test Stoploss partially executed", - expectedSummary: "Your workflow 'Test Stoploss' executed 5 out of 8 total steps", - }, - { - name: "simulation failed to execute", - workflowName: "Test Stoploss", - executedSteps: 3, - totalSteps: 8, - hasFailures: true, - hasSkippedNodes: false, - isSimulation: true, - expectedSubject: "Simulation: Test Stoploss failed to execute", - expectedSummary: "Your workflow 'Test Stoploss' executed 3 out of 8 total steps", - }, - { - name: "deployed workflow succeeded", - workflowName: "Test Stoploss", - executedSteps: 7, - totalSteps: 7, - hasFailures: false, - hasSkippedNodes: false, - isSimulation: false, - expectedSubject: "Test Stoploss: succeeded (7 out of 7 steps)", - expectedSummary: "Your workflow 'Test Stoploss' executed 7 out of 7 total steps", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - vm := NewVM() - vm.IsSimulation = tt.isSimulation - - // Set up workflow name - vm.mu.Lock() - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": tt.workflowName, - "chain": "Sepolia", - }, - } - - // Set up task nodes (for total steps calculation) - vm.TaskNodes = make(map[string]*avsproto.TaskNode) - for i := 0; i < tt.totalSteps-1; i++ { // -1 because trigger is counted separately - nodeID := fmt.Sprintf("node%d", i) - vm.TaskNodes[nodeID] = &avsproto.TaskNode{ - Id: nodeID, - Name: fmt.Sprintf("step%d", i), - } - } - - // Set up execution logs - vm.ExecutionLogs = make([]*avsproto.Execution_Step, 0, tt.executedSteps) - // Add trigger (always first) - vm.ExecutionLogs = append(vm.ExecutionLogs, &avsproto.Execution_Step{ - Id: "trigger", - Name: "eventTrigger", - Type: "TRIGGER_TYPE_EVENT", - Success: true, - }) - - // Add executed steps - for i := 0; i < tt.executedSteps-1; i++ { // -1 because trigger is already added - stepName := fmt.Sprintf("step%d", i) - success := true - if tt.hasFailures && i == tt.executedSteps-2 { // Last step fails - success = false - } - vm.ExecutionLogs = append(vm.ExecutionLogs, &avsproto.Execution_Step{ - Id: fmt.Sprintf("step%d", i), - Name: stepName, - Type: "NODE_TYPE_BALANCE", - Success: success, - Error: func() string { - if !success { - return "test error" - } - return "" - }(), - }) - } - - // Mark some nodes as skipped if needed (by not including them in execution logs) - // The skipped count is calculated by comparing TaskNodes to ExecutionLogs - vm.mu.Unlock() - - summary := ComposeSummary(vm, "email1") - - // Verify subject format - if summary.Subject != tt.expectedSubject { - t.Errorf("Subject mismatch:\n expected: %q\n got: %q", tt.expectedSubject, summary.Subject) - } - - // Verify summary line format - if summary.SummaryLine != tt.expectedSummary { - t.Errorf("SummaryLine mismatch:\n expected: %q\n got: %q", tt.expectedSummary, summary.SummaryLine) - } - - // Verify body format - with the new structured format, Body starts with "Trigger: ..." - // The "Your workflow..." summary is now in SummaryLine field - if !strings.HasPrefix(summary.Body, "Trigger: ") { - t.Errorf("Body format mismatch:\n expected to start with: %q\n got: %q", "Trigger: ", summary.Body) - } - }) - } -} - -// TestComposeSummary_SimulationBodyFormat tests that simulation workflows -// generate the correct subject and summary line format matching context-memory API expectations -func TestComposeSummary_SimulationBodyFormat(t *testing.T) { - vm := NewVM() - vm.IsSimulation = true - - vm.mu.Lock() - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "Test Stoploss", - "chain": "Sepolia", - }, - } - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "node1": {Id: "node1", Name: "step1"}, - "node2": {Id: "node2", Name: "step2"}, - } - vm.mu.Unlock() - - // Create execution logs - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "trigger", - Name: "eventTrigger", - Type: "TRIGGER_TYPE_EVENT", - Success: true, - }, - { - Id: "step1", - Name: "step1", - Type: "NODE_TYPE_BALANCE", - Success: true, - }, - { - Id: "step2", - Name: "step2", - Type: "NODE_TYPE_BALANCE", - Success: true, - }, - } - - summary := ComposeSummary(vm, "email1") - - // Verify subject format matches context-memory API expectations - expectedSubject := "Simulation: Test Stoploss successfully completed" - if summary.Subject != expectedSubject { - t.Errorf("Subject mismatch:\n expected: %q\n got: %q", expectedSubject, summary.Subject) - } - - // Verify summary line format matches context-memory API expectations - expectedSummary := "Your workflow 'Test Stoploss' executed 3 out of 3 total steps" - if summary.SummaryLine != expectedSummary { - t.Errorf("SummaryLine mismatch:\n expected: %q\n got: %q", expectedSummary, summary.SummaryLine) - } - - // Verify body format - with the new structured format, Body starts with "Trigger: ..." - // The "Your workflow..." summary is now in SummaryLine field - if !strings.HasPrefix(summary.Body, "Trigger: ") { - t.Errorf("Body format mismatch:\n expected to start with: %q\n got: %q", "Trigger: ", summary.Body) - } - - // Verify SummaryLine contains the expected format - expectedSummaryLine := "Your workflow 'Test Stoploss' executed 3 out of 3 total steps" - if summary.SummaryLine != expectedSummaryLine { - t.Errorf("SummaryLine mismatch:\n expected: %q\n got: %q", expectedSummaryLine, summary.SummaryLine) - } - - t.Logf("Subject: %s", summary.Subject) - t.Logf("SummaryLine: %s", summary.SummaryLine) - t.Logf("Body: %s", summary.Body) -} - -// TestFormatTelegramFromStructured_PRDFormat tests the PRD-based Telegram format -// that uses body.network and body.executions array with emoji prepended by aggregator -func TestFormatTelegramFromStructured_PRDFormat(t *testing.T) { - tests := []struct { - name string - summary Summary - expectedContain []string - notContain []string - }{ - { - name: "simulation with transfer - PRD format", - summary: Summary{ - Subject: "Simulation: Recurring Payment successfully completed", // No emoji from API - Status: "success", - Network: "Sepolia", - Trigger: "(Simulated) Your scheduled task (every 3 days at 11:00 PM) triggered on Sepolia.", - TriggeredAt: "2026-01-22T04:51:18.509Z", - Executions: []ExecutionEntry{ - {Description: "(Simulated) Transferred 0.01 ETH to 0xc60e...C788"}, - }, - }, - expectedContain: []string{ - "✅ Simulation: Recurring Payment successfully completed", // Prefix + name code-wrapped - "Network: Sepolia", - "Time: Jan 22, 2026 at 4:51 AM UTC", - "Trigger: (Simulated) Your scheduled task (every 3 days at 11:00 PM) triggered on Sepolia.", - "Executed:", - "• (Simulated) Transferred 0.01 ETH to 0xc60e...C788", - }, - notContain: []string{}, - }, - { - name: "deployed run with transfer - PRD format", - summary: Summary{ - Subject: "Run #3: Recurring Payment successfully completed", // No emoji from API - Status: "success", - Network: "Sepolia", - TriggeredAt: "2026-01-22T04:51:18.509Z", - Executions: []ExecutionEntry{ - {Description: "Transferred 0.01 ETH to 0xc60e...C788"}, - }, - }, - expectedContain: []string{ - "✅ Run #3: Recurring Payment successfully completed", // Prefix + name code-wrapped - "Network: Sepolia", - "Time: Jan 22, 2026 at 4:51 AM UTC", - "Executed:", - "• Transferred 0.01 ETH to 0xc60e...C788", - }, - notContain: []string{ - "No on-chain transaction was executed", // No simulation notice for real runs - }, - }, - { - name: "failed run - PRD format", - summary: Summary{ - Subject: "Run #5: Recurring Payment failed to execute", // No emoji from API - Status: "failed", - Network: "Sepolia", - TriggeredAt: "2026-01-22T04:51:18.509Z", - Errors: []string{"transfer1: Insufficient balance for transfer"}, - }, - expectedContain: []string{ - "❌ Run #5: Recurring Payment failed to execute", // Prefix + name code-wrapped - "Network: Sepolia", - "What Went Wrong:", - "• transfer1: Insufficient balance for transfer", - }, - notContain: []string{ - "Executed:", - }, - }, - { - name: "success with skipped nodes renders warn emoji and skippedNote", - summary: Summary{ - Subject: "Run #2: My Workflow successfully completed", - Status: "success", - SkippedSteps: 1, - SkippedNote: "1 node was skipped by Branch condition.", - Network: "Ethereum", - TriggeredAt: "2026-01-22T04:51:18.509Z", - Executions: []ExecutionEntry{ - {Description: "Approved 100 USDC to router"}, - }, - }, - expectedContain: []string{ - "⚠️ Run #2: My Workflow successfully completed", - "1 node was skipped by Branch condition.", - "Network: Ethereum", - "Executed:", - "• Approved 100 USDC to router", - }, - notContain: []string{}, - }, - { - name: "success with branch-skipped errors and backtick expression", - summary: Summary{ - Subject: "Simulation: Copy of Test Recurring Batch Send successfully completed", - Status: "success", - SkippedSteps: 1, - SkippedNote: "1 node was skipped by Branch condition.", - Network: "Sepolia", - TriggeredAt: "2026-01-22T04:51:18.509Z", - Trigger: "(Simulated) Your scheduled task (every 3 minutes) triggered on Sepolia.", - Errors: []string{"loop1 - skipped due to condition not met: `code1.data.balance >= code1.data.totalNeeded` evaluated to false"}, - }, - expectedContain: []string{ - "⚠️ Simulation: Copy of Test Recurring Batch Send successfully completed", - "1 node was skipped by Branch condition.", - "Network: Sepolia", - "What Went Wrong:", - "• loop1 - skipped due to condition not met: code1.data.balance >= code1.data.totalNeeded evaluated to false", - }, - notContain: []string{ - "Executed:", - "`", // backticks should be converted to tags - }, - }, - { - name: "network fallback from workflow.chain", - summary: Summary{ - Subject: "Simulation: Test successfully completed", - Status: "success", - Network: "", // Empty network from API - Workflow: &WorkflowInfo{ - Chain: "Base", - ChainID: 8453, - }, - Executions: []ExecutionEntry{{Description: "(Simulated) Test executed"}}, - }, - expectedContain: []string{ - "Network: Base", // Falls back to workflow.chain - }, - }, - { - name: "network fallback from chainID", - summary: Summary{ - Subject: "Simulation: Test successfully completed", - Status: "success", - Network: "", // Empty network from API - Workflow: &WorkflowInfo{ - Chain: "", // Empty chain name - ChainID: 11155111, - }, - Executions: []ExecutionEntry{{Description: "(Simulated) Test executed"}}, - }, - expectedContain: []string{ - "Network: Sepolia", // Derived from chainID - }, - }, - { - name: "execution descriptions with backtick method names", - summary: Summary{ - Subject: "Run #1: DeFi Bot successfully completed", - Status: "success", - Network: "Ethereum", - Executions: []ExecutionEntry{ - {Description: "Called `approve` on 0xC364...42a0"}, - {Description: "`getUserAccountData` returned healthFactor: 1.85"}, - }, - }, - expectedContain: []string{ - "Executed:", - "• Called approve on 0xC364...42a0", - "• getUserAccountData returned healthFactor: 1.85", - }, - notContain: []string{ - "`approve`", // backticks should be converted - "`getUserAccountData`", - }, - }, - { - name: "no on-chain executions - fallback populated by ComposeSummary", - summary: Summary{ - Subject: "Simulation: My Workflow successfully completed", - Status: "success", - Network: "Sepolia", - TriggeredAt: "2026-01-28T07:31:51Z", - Trigger: "(Simulated) Scheduled task ran on Sepolia", - // ComposeSummary populates fallback execution entry when buildExecutionsArray returns empty - Executions: []ExecutionEntry{{Description: "(Simulated) On-chain transaction successfully completed"}}, - }, - expectedContain: []string{ - "✅ Simulation: My Workflow successfully completed", - "Network: Sepolia", - "Trigger: (Simulated) Scheduled task ran on Sepolia", - "Executed:", - "• (Simulated) On-chain transaction successfully completed", - }, - notContain: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := FormatForMessageChannels(tt.summary, "telegram", nil) - - // Check expected strings are present - for _, expected := range tt.expectedContain { - if !strings.Contains(result, expected) { - t.Errorf("expected result to contain %q\nGot:\n%s", expected, result) - } - } - - // Check unwanted strings are not present - for _, notExpected := range tt.notContain { - if strings.Contains(result, notExpected) { - t.Errorf("result should NOT contain %q\nGot:\n%s", notExpected, result) - } - } - }) - } -} - -// TestFormatTelegramFromStructured_Annotation tests that annotation renders in italic for Telegram -func TestFormatTelegramFromStructured_Annotation(t *testing.T) { - summary := Summary{ - Subject: "Run Node: My Workflow succeeded", - Status: "success", - Network: "Sepolia", - Executions: []ExecutionEntry{{Description: "Transferred 0.01 ETH"}}, - Annotation: "This is an example. Actual execution details will appear when the workflow is simulated or triggered by a real event.", - } - - result := FormatForMessageChannels(summary, "telegram", nil) - - // Should contain annotation in italic - if !strings.Contains(result, "This is an example.") { - t.Errorf("expected Telegram output to contain italic annotation, got:\n%s", result) - } - if !strings.Contains(result, "") { - t.Errorf("expected Telegram output to close italic tag, got:\n%s", result) - } - - // Without annotation, no italic tag - summaryNoAnnotation := Summary{ - Subject: "Simulation: My Workflow successfully completed", - Status: "success", - Network: "Sepolia", - Executions: []ExecutionEntry{{Description: "(Simulated) Transferred 0.01 ETH"}}, - } - resultNoAnnotation := FormatForMessageChannels(summaryNoAnnotation, "telegram", nil) - if strings.Contains(resultNoAnnotation, "") { - t.Errorf("expected no italic tag when Annotation is empty, got:\n%s", resultNoAnnotation) - } - - t.Logf("With annotation:\n%s", result) - t.Logf("Without annotation:\n%s", resultNoAnnotation) -} - -// TestFormatTelegramFromStructured_RunnerAndFees verifies the Runner and Cost -// blocks render from Summary.Runner / Summary.Fees, mirroring what the -// context-memory API now returns. Numbers come from the real Sepolia run -// captured in aggregator-sepolia.log on 2026-05-02 (see PRD). -func TestFormatTelegramFromStructured_RunnerAndFees(t *testing.T) { - summary := Summary{ - Subject: "Run #1: Automatically Split Incoming USDC Payments successfully completed", - Status: "success", - Network: "Sepolia", - Trigger: "Transfer event detected: received 1 USDC from 0x804e...1557 on Sepolia", - TriggeredAt: "2026-05-02T04:05:29.526Z", - Executions: []ExecutionEntry{ - {Description: "Transferred 0.2 USDC to 0x804e...1557"}, - {Description: "Transferred 0.5 USDC to 0x25d9...321D"}, - {Description: "Transferred 0.3 USDC to 0xfE66...bDA9"}, - }, - Workflow: &WorkflowInfo{IsSimulation: false}, - Runner: &RunnerInfo{ - SmartWallet: "0x8Ee38eB323c14a1752DABDA1cca9661AEE377017", - OwnerEOA: "0x804e49e8C4eDb560AE7c48B554f6d2e27Bb81557", - }, - Fees: &FeesInfo{ - ExecutionFee: &FeeAmount{Amount: "0.020000", Unit: "USD"}, - Total: []*TokenTotal{ - {Amount: "0.000003", Unit: "ETH", USD: "0.01", IsGas: true}, - {Amount: "0.02", Unit: "USD", USD: "0.02"}, // platform fee — hidden from notification (tracked for API consumers only) - }, - }, - } - - out := FormatForMessageChannels(summary, "telegram", nil) - - // Runner line: smart wallet + chain qualifier on a single line ("Runner: - // 0x… on Sepolia"). Owner intentionally omitted on Telegram — kept compact - // for the channel. Address is truncated (7-char prefix + 4-char suffix), - // NOT -wrapped (per PRD). - if !strings.Contains(out, "Runner: 0x8Ee38...7017 on Sepolia") { - t.Errorf("missing combined Runner+chain line in:\n%s", out) - } - // Standalone Network line should NOT render when Runner is present — - // chain is folded into the Runner line. - if strings.Contains(out, "Network:") { - t.Errorf("standalone Network line should not render when Runner is present, got:\n%s", out) - } - if strings.Contains(out, "Owner:") { - t.Errorf("Owner line should NOT render on Telegram, got:\n%s", out) - } - if strings.Contains(out, "0x8Ee3") { - t.Errorf("runner address should not be -wrapped, got:\n%s", out) - } - - // Gas line: only the native on-chain gas. Platform fee and value fees are - // tracked in Fees.Total for API consumers but intentionally hidden from - // the notification — grouping them under "⛽" misled users into reading - // them as gas. - if !strings.Contains(out, "⛽ Gas: 0.000003 ETH ($0.01)") { - t.Errorf("missing gas-only Gas line in:\n%s", out) - } - if strings.Contains(out, "platform fee") { - t.Errorf("platform fee should not render in the notification, got:\n%s", out) - } - if strings.Contains(out, "(~") || strings.Contains(out, "Value fee:") { - t.Errorf("Telegram should not render gas-units detail or Value fee line, got:\n%s", out) - } - if strings.Contains(out, "(see cost estimate before deploy)") { - t.Errorf("deployed run should not show simulation placeholder, got:\n%s", out) - } - - // Gas line follows Runner directly inside the metadata block (no blank - // line between them). - if !strings.Contains(out, "Runner: 0x8Ee38...7017 on Sepolia\n⛽ Gas: ") { - t.Errorf("Gas line should immediately follow Runner line, got:\n%s", out) - } - - t.Logf("Telegram render:\n%s", out) -} - -// TestFormatTelegramFromStructured_PlatformFeeOnly covers the read-only path: -// no on-chain steps, only the platform fee. With the gas-only notification -// format, the Gas line is omitted entirely (there's no gas to report) — -// the platform fee is still tracked in Fees.Total for billing but isn't -// surfaced in the notification. -func TestFormatTelegramFromStructured_PlatformFeeOnly(t *testing.T) { - summary := Summary{ - Subject: "Run #1: Read-Only Workflow successfully completed", - Status: "success", - Network: "Sepolia", - Workflow: &WorkflowInfo{IsSimulation: false}, - Runner: &RunnerInfo{SmartWallet: "0x8Ee38eB323c14a1752DABDA1cca9661AEE377017"}, - Fees: &FeesInfo{ - ExecutionFee: &FeeAmount{Amount: "0.02", Unit: "USD"}, - Total: []*TokenTotal{ - {Amount: "0.02", Unit: "USD", USD: "0.02"}, - }, - }, - } - out := FormatForMessageChannels(summary, "telegram", nil) - if strings.Contains(out, "⛽ Gas:") { - t.Errorf("read-only run has no gas — Gas line should be omitted, got:\n%s", out) - } - if strings.Contains(out, "platform fee") { - t.Errorf("platform fee should not render in the notification, got:\n%s", out) - } - if strings.Contains(out, " ETH (") { - t.Errorf("read-only run should not render an ETH line, got:\n%s", out) - } -} - -// TestFormatTelegramFromStructured_NoPriceService covers Moralis-not-configured: -// gas renders with $? for USD. Platform fee is not rendered in the -// notification under the gas-only format. -func TestFormatTelegramFromStructured_NoPriceService(t *testing.T) { - summary := Summary{ - Subject: "Run #1: Workflow successfully completed", - Status: "success", - Network: "Sepolia", - Workflow: &WorkflowInfo{IsSimulation: false}, - Runner: &RunnerInfo{SmartWallet: "0x8Ee38eB323c14a1752DABDA1cca9661AEE377017"}, - Fees: &FeesInfo{ - ExecutionFee: &FeeAmount{Amount: "0.02", Unit: "USD"}, - Total: []*TokenTotal{ - {Amount: "0.000003", Unit: "ETH", USD: "", IsGas: true}, // unpriced — renders "$?" - {Amount: "0.02", Unit: "USD", USD: "0.02"}, - }, - }, - } - out := FormatForMessageChannels(summary, "telegram", nil) - if !strings.Contains(out, "⛽ Gas: 0.000003 ETH ($?)") { - t.Errorf("expected unpriced ETH gas line, got:\n%s", out) - } - if strings.Contains(out, "platform fee") { - t.Errorf("platform fee should not render in the notification, got:\n%s", out) - } -} - -// TestFormatTelegramFromStructured_MultiToken_USDPlaceholder verifies the -// gas-only render isolates the native-token gas entry even when Fees.Total -// also includes per-token value-fee entries (USDC, PEPE here). Only the -// IsGas-marked native entry surfaces in the notification — the value-fee -// rows are tracked in Fees.Total for API consumers but intentionally -// hidden, because grouping them under "⛽" misleads users into reading -// them as gas. -func TestFormatTelegramFromStructured_MultiToken_USDPlaceholder(t *testing.T) { - summary := Summary{ - Subject: "Run #1: Multi-token Workflow successfully completed", - Status: "success", - Network: "Ethereum", - Executions: []ExecutionEntry{{Description: "Transferred 1.2 USDC"}}, - Workflow: &WorkflowInfo{IsSimulation: false}, - Runner: &RunnerInfo{SmartWallet: "0x8Ee38eB323c14a1752DABDA1cca9661AEE377017"}, - Fees: &FeesInfo{ - Total: []*TokenTotal{ - {Amount: "0.01", Unit: "ETH", USD: "25.00", IsGas: true}, - {Amount: "1.2", Unit: "USDC", USD: "1.20"}, - {Amount: "0.005", Unit: "PEPE", USD: ""}, // value-fee, unpriceable - }, - }, - } - - out := FormatForMessageChannels(summary, "telegram", nil) - want := "⛽ Gas: 0.01 ETH ($25.00)" - if !strings.Contains(out, want) { - t.Errorf("missing gas-only line %q in:\n%s", want, out) - } - // The old multi-token cost format put value-fee tokens inline as - // "0.01 ETH ($25.00), 1.2 USDC ($1.20), 0.005 PEPE ($?)". Verify those - // fragments are gone — USDC may still appear in the Executed section - // for a USDC transfer, so we check on the specific cost-line shape. - for _, leak := range []string{"1.2 USDC ($1.20)", "0.005 PEPE", "$25.00),"} { - if strings.Contains(out, leak) { - t.Errorf("value-fee token %q leaked into the Gas line, got:\n%s", leak, out) - } - } -} - -// TestPercentOfRaw verifies the value-fee percentage math against raw amounts. -func TestPercentOfRaw(t *testing.T) { - pct := func(s string) *big.Float { - v, _ := new(big.Float).SetString(s) - return v - } - cases := []struct { - name string - raw string - pct *big.Float - want string - }{ - {"1 USDC × 0.03% = 300 raw (6 decimals)", "1000000", pct("0.0003"), "300"}, - {"1000 USDC × 0.03%", "1000000000", pct("0.0003"), "300000"}, - {"zero raw", "0", pct("0.0003"), ""}, - {"zero pct", "1000000", pct("0"), ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - rawInt, _ := new(big.Int).SetString(tc.raw, 10) - got := percentOfRaw(rawInt, tc.pct) - if tc.want == "" { - if got != nil && got.Sign() > 0 { - t.Errorf("expected nil/zero, got %s", got.String()) - } - return - } - if got == nil || got.String() != tc.want { - t.Errorf("percentOfRaw(%s, %s) = %v, want %s", tc.raw, tc.pct.String(), got, tc.want) - } - }) - } -} - -// TestTokenBucketToTokenTotal verifies stablecoin shortcut + zero-rounding. -func TestTokenBucketToTokenTotal(t *testing.T) { - cases := []struct { - name string - bucket *tokenBucket - chainID uint64 - wantNil bool - wantAmount string - wantUnit string - wantUSD string - }{ - { - name: "USDC mainnet 1.0 token via stablecoin shortcut", - bucket: &tokenBucket{ - contract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - symbol: "USDC", - decimals: 6, - raw: mustBigInt("1000000"), - }, - chainID: 1, - wantAmount: "1", - wantUnit: "USDC", - wantUSD: "1", - }, - { - name: "0.0003 USDC via stablecoin shortcut", - bucket: &tokenBucket{ - contract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - symbol: "USDC", - decimals: 6, - raw: mustBigInt("300"), - }, - chainID: 1, - wantAmount: "0.0003", - wantUnit: "USDC", - wantUSD: "0", - }, - { - name: "below precision rounds to zero — omit", - bucket: &tokenBucket{ - contract: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", - symbol: "USDC", - decimals: 6, - raw: mustBigInt("0"), - }, - chainID: 1, - wantNil: true, - }, - { - name: "unknown ERC20 with no price service → empty USD", - bucket: &tokenBucket{ - contract: "0xff00000000000000000000000000000000000099", - symbol: "?", - decimals: 18, - raw: mustBigInt("1000000000000000000"), - }, - chainID: 1, - wantAmount: "1", - wantUnit: "?", - wantUSD: "", - }, - } - // Ensure no globalPriceService leaks into these tests. - prev := globalPriceService - globalPriceService = nil - t.Cleanup(func() { globalPriceService = prev }) - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got := tc.bucket.toTokenTotal(tc.chainID) - if tc.wantNil { - if got != nil { - t.Errorf("expected nil, got %+v", got) - } - return - } - if got == nil { - t.Fatalf("expected non-nil") - } - if got.Amount != tc.wantAmount { - t.Errorf("Amount = %q, want %q", got.Amount, tc.wantAmount) - } - if got.Unit != tc.wantUnit { - t.Errorf("Unit = %q, want %q", got.Unit, tc.wantUnit) - } - if got.USD != tc.wantUSD { - t.Errorf("USD = %q, want %q", got.USD, tc.wantUSD) - } - }) - } -} - -func mustBigInt(s string) *big.Int { - v, _ := new(big.Int).SetString(s, 10) - return v -} - -// TestFormatTelegramFromStructured_Simulation_PlaceholderCost confirms simulation -// runs render the "(see cost estimate before deploy)" placeholder instead of fake-precision -// numbers. Sim gas prices are conservative chain defaults, so any specific ETH/gas -// figure would mislead the user. -func TestFormatTelegramFromStructured_Simulation_PlaceholderCost(t *testing.T) { - summary := Summary{ - Subject: "Simulation: Test Workflow successfully completed", - Status: "success", - Network: "Sepolia", - Executions: []ExecutionEntry{{Description: "(Simulated) Transferred 0.01 ETH"}}, - Workflow: &WorkflowInfo{IsSimulation: true}, - Runner: &RunnerInfo{ - SmartWallet: "0x8Ee38eB323c14a1752DABDA1cca9661AEE377017", - OwnerEOA: "0x804e49e8C4eDb560AE7c48B554f6d2e27Bb81557", - }, - Fees: &FeesInfo{ - ExecutionFee: &FeeAmount{Amount: "0.020000", Unit: "USD"}, - Cogs: []*NodeCOGS{{ - NodeID: "transfer1", - StepName: "transfer1", - CostType: "gas", - Fee: &FeeAmount{Amount: "10500000000000", Unit: "WEI"}, - GasUnits: "21000", - }}, - }, - } - - out := FormatForMessageChannels(summary, "telegram", nil) - - if !strings.Contains(out, "⛽ (see cost estimate before deploy)") { - t.Errorf("simulation should render the deploy-time placeholder, got:\n%s", out) - } - for _, banned := range []string{"Cost:", "0.0000105 ETH", "21 K gas", "platform fee"} { - if strings.Contains(out, banned) { - t.Errorf("simulation should not render %q, got:\n%s", banned, out) - } - } -} - -// TestTruncateAddress tests the address truncation helper function -func TestTruncateAddress(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", "0x5d814...434f"}, - {"0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", "0xc60e7...C788"}, - {"0x1234567890abcdef1234567890abcdef12345678", "0x12345...5678"}, - {"short", "short"}, // Too short to truncate - {"", ""}, // Empty string - } - - for _, tt := range tests { - t.Run(tt.input, func(t *testing.T) { - result := truncateAddress(tt.input) - if result != tt.expected { - t.Errorf("truncateAddress(%q) = %q, want %q", tt.input, result, tt.expected) - } - }) - } -} - -// TestGetBlockExplorerURL tests the block explorer URL helper function -func TestGetBlockExplorerURL(t *testing.T) { - tests := []struct { - name string - chainID int64 - expected string - }{ - {"ethereum", 1, "https://etherscan.io"}, - {"sepolia", 11155111, "https://sepolia.etherscan.io"}, - {"polygon", 137, "https://polygonscan.com"}, - {"arbitrum", 42161, "https://arbiscan.io"}, - {"optimism", 10, "https://optimistic.etherscan.io"}, - {"base", 8453, "https://basescan.org"}, - {"base_sepolia", 84532, "https://sepolia.basescan.org"}, - {"bsc", 56, "https://bscscan.com"}, - {"avalanche", 43114, "https://snowtrace.io"}, - {"unknown", 999999, ""}, - {"zero", 0, ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getBlockExplorerURL(tt.chainID) - if result != tt.expected { - t.Errorf("getBlockExplorerURL(%d) = %q, want %q", tt.chainID, result, tt.expected) - } - }) - } -} - -// TestMapNameToChainID tests the chain name to chain ID reverse mapping -func TestMapNameToChainID(t *testing.T) { - tests := []struct { - name string - expected int64 - }{ - {"Mainnet", 1}, - {"Ethereum", 1}, - {"Sepolia", 11155111}, - {"Polygon", 137}, - {"Arbitrum", 42161}, - {"Arbitrum One", 42161}, - {"Optimism", 10}, - {"Base", 8453}, - {"Base-Sepolia", 84532}, - {"BSC", 56}, - {"BNB Chain", 56}, - {"Avalanche", 43114}, - {"Unknown", 0}, - {"", 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := mapNameToChainID(tt.name) - if result != tt.expected { - t.Errorf("mapNameToChainID(%q) = %d, want %d", tt.name, result, tt.expected) - } - }) - } -} - -// TestGetChainDisplayName tests the chain ID to display name helper function -func TestGetChainDisplayName(t *testing.T) { - tests := []struct { - chainID int64 - expected string - }{ - {1, "Ethereum"}, - {11155111, "Sepolia"}, - {137, "Polygon"}, - {42161, "Arbitrum One"}, - {10, "Optimism"}, - {8453, "Base"}, - {84532, "Base Sepolia"}, - {56, "BNB Chain"}, - {43114, "Avalanche"}, - {999999, ""}, // Unknown chain - } - - for _, tt := range tests { - t.Run(fmt.Sprintf("chainID_%d", tt.chainID), func(t *testing.T) { - result := getChainDisplayName(tt.chainID) - if result != tt.expected { - t.Errorf("getChainDisplayName(%d) = %q, want %q", tt.chainID, result, tt.expected) - } - }) - } -} - -// TestGetStatusEmoji tests the status emoji helper function. -// The warn variant (⚠️) is derived from (Status="success", SkippedSteps>0), not -// from a dedicated status string — that's the whole point of the new contract. -func TestGetStatusEmoji(t *testing.T) { - tests := []struct { - name string - summary Summary - expected string - }{ - {"success clean", Summary{Status: "success"}, "✅"}, - {"success with skipped", Summary{Status: "success", SkippedSteps: 1}, "⚠️"}, - {"failed", Summary{Status: "failed"}, "❌"}, - {"error", Summary{Status: "error"}, "❌"}, - {"unknown", Summary{Status: "unknown"}, ""}, - {"empty", Summary{}, ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := getStatusEmoji(tt.summary) - if result != tt.expected { - t.Errorf("getStatusEmoji(%+v) = %q, want %q", tt.summary, result, tt.expected) - } - }) - } -} - -// TestFormatSubjectWithBoldName tests that formatSubjectWithBoldName wraps the workflow name in tags -func TestFormatSubjectWithBoldName(t *testing.T) { - tests := []struct { - name string - subject string - expected string - }{ - { - name: "simulation success", - subject: "Simulation: My Workflow successfully completed", - expected: "Simulation: My Workflow successfully completed", - }, - { - name: "simulation failure", - subject: "Simulation: Test Workflow failed to execute", - expected: "Simulation: Test Workflow failed to execute", - }, - { - name: "simulation partial", - subject: "Simulation: Another Workflow partially executed", - expected: "Simulation: Another Workflow partially executed", - }, - { - name: "run number success", - subject: "Run #3: Payment Flow successfully completed", - expected: "Run #3: Payment Flow successfully completed", - }, - { - name: "run number failure", - subject: "Run #15: Swap Workflow failed to execute", - expected: "Run #15: Swap Workflow failed to execute", - }, - { - name: "no prefix success", - subject: "Simple Workflow successfully completed", - expected: "Simple Workflow successfully completed", - }, - { - name: "no prefix failure", - subject: "Basic Task failed to execute", - expected: "Basic Task failed to execute", - }, - { - name: "workflow name with special chars", - subject: "Simulation: Copy of Recurring Payment & Report successfully completed", - expected: "Simulation: Copy of Recurring Payment & Report successfully completed", - }, - { - name: "run node success", - subject: "Run Node: My Transfer succeeded", - expected: "Run Node: My Transfer succeeded", - }, - { - name: "run node failure", - subject: "Run Node: My Transfer failed at transfer1", - expected: "Run Node: My Transfer failed at transfer1", - }, - { - name: "deployed workflow success", - subject: "My AAVE Workflow: succeeded (3 out of 3 steps)", - expected: "My AAVE Workflow: succeeded (3 out of 3 steps)", - }, - { - name: "deployed workflow failure", - subject: "My AAVE Workflow: failed at transfer1 (2 out of 3 steps)", - expected: "My AAVE Workflow: failed at transfer1 (2 out of 3 steps)", - }, - { - name: "unknown format - fallback to escaped", - subject: "Some random subject", - expected: "Some random subject", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := formatSubjectWithBoldName(tt.subject) - if result != tt.expected { - t.Errorf("formatSubjectWithBoldName(%q)\n got: %q\n want: %q", tt.subject, result, tt.expected) - } - }) - } -} - -// TestComposeSummary_SimulateTaskFromClientPayload tests the deterministic path using -// the exact client payload from a SimulateTask request with settings.name as canonical source. -// Client payload: -// -// inputVariables: { -// transfer1: { data: {} }, -// balance1: { data: {} }, -// timeTrigger: { data: {} }, -// settings: { -// name: 'Test settings.name', -// chain: 'Sepolia', -// runner: '0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f', -// chain_id: 11155111, -// recipient: '0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788', -// token_amount: { amount: '10000000000000000', address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', decimals: 18 } -// } -// } -func TestComposeSummary_SimulateTaskFromClientPayload(t *testing.T) { - vm := NewVM() - vm.IsSimulation = true - - vm.mu.Lock() - vm.vars = map[string]interface{}{ - "transfer1": map[string]interface{}{ - "data": map[string]interface{}{}, - }, - "balance1": map[string]interface{}{ - "data": map[string]interface{}{}, - }, - "timeTrigger": map[string]interface{}{ - "data": map[string]interface{}{}, - }, - "settings": map[string]interface{}{ - "name": "Test settings.name", - "chain": "Sepolia", - "runner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - "chain_id": 11155111, - "recipient": "0xc60e71bd0f2e6d8832Fea1a2d56091C48493C788", - "token_amount": map[string]interface{}{ - "amount": "10000000000000000", - "address": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee", - "decimals": 18, - }, - }, - } - - // Set up task nodes matching the workflow (trigger + 2 processing nodes + 1 notification node) - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "transfer1": {Id: "transfer1", Name: "transfer1"}, - "balance1": {Id: "balance1", Name: "balance1"}, - "telegram_send": { - Id: "telegram_send", - Name: "telegram_send", - Type: avsproto.NodeType_NODE_TYPE_REST_API, - TaskType: &avsproto.TaskNode_RestApi{ - RestApi: &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: "https://api.telegram.org/bot123/sendMessage", - Method: "POST", - }, - }, - }, - }, - } - vm.mu.Unlock() - - // Set up execution logs: cron trigger + 2 processing steps + 1 notification step - // The notification step (telegram_send) should NOT inflate the executed count - startTime := int64(1738051035000) // 2025-01-28T07:57:15Z in ms - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "timeTrigger", - Name: "timeTrigger", - Type: "TRIGGER_TYPE_CRON", - Success: true, - StartAt: startTime, - }, - { - Id: "balance1", - Name: "balance1", - Type: "NODE_TYPE_BALANCE", - Success: true, - }, - { - Id: "transfer1", - Name: "transfer1", - Type: "NODE_TYPE_ETH_TRANSFER", - Success: true, - }, - { - Id: "telegram_send", - Name: "telegram_send", - Type: "NODE_TYPE_REST_API", - Success: true, - }, - } - - summary := ComposeSummary(vm, "transfer1") - - // Verify subject uses settings.name with Simulation prefix (SimulateTask is multi-node) - expectedSubject := "Simulation: Test settings.name successfully completed" - if summary.Subject != expectedSubject { - t.Errorf("Subject mismatch:\n expected: %q\n got: %q", expectedSubject, summary.Subject) - } - // Note: This is NOT a single-node execution (has 2+ TaskNodes), so it uses "Simulation:" prefix. - // Single-node RunNodeImmediately uses "Run Node:" prefix instead. - - // Verify step count excludes notification nodes: 3 total (trigger + balance1 + transfer1), - // NOT 4 (which would incorrectly include telegram_send) - expectedSummaryLine := "Your workflow 'Test settings.name' executed 3 out of 3 total steps" - if summary.SummaryLine != expectedSummaryLine { - t.Errorf("SummaryLine mismatch:\n expected: %q\n got: %q", expectedSummaryLine, summary.SummaryLine) - } - - // Verify status - if summary.Status != "success" { - t.Errorf("Status: expected %q, got %q", "success", summary.Status) - } - - // Verify Network is populated from settings.chain - if summary.Network != "Sepolia" { - t.Errorf("Network: expected %q, got %q", "Sepolia", summary.Network) - } - - // Verify Trigger is set (cron trigger with simulation prefix) - if !strings.Contains(summary.Trigger, "(Simulated)") { - t.Errorf("Trigger should contain '(Simulated)', got: %q", summary.Trigger) - } - if !strings.Contains(summary.Trigger, "Scheduled task ran") { - t.Errorf("Trigger should contain 'Scheduled task ran', got: %q", summary.Trigger) - } - if !strings.Contains(summary.Trigger, "Sepolia") { - t.Errorf("Trigger should contain 'Sepolia', got: %q", summary.Trigger) - } - - // Verify TriggeredAt is set from the trigger step's StartAt - if summary.TriggeredAt == "" { - t.Error("TriggeredAt should not be empty") - } - - // Verify Executions fallback (no CONTRACT_WRITE steps, so fallback entry is added) - if len(summary.Executions) != 1 { - t.Fatalf("Executions: expected 1 fallback entry, got %d: %v", len(summary.Executions), summary.Executions) - } - if summary.Executions[0].Description != "(Simulated) On-chain transaction successfully completed" { - t.Errorf("Executions[0]: expected fallback entry, got: %q", summary.Executions[0].Description) - } - - // Annotation should be empty for simulate_task (multi-node) executions - if summary.Annotation != "" { - t.Errorf("Annotation should be empty for simulate_task, got: %q", summary.Annotation) - } - - // Verify Telegram formatting - telegram := FormatForMessageChannels(summary, "telegram", nil) - - expectedTelegramContents := []string{ - "✅ Simulation: Test settings.name successfully completed", - "Runner: ", // chain qualifier folded onto Runner line — see formatTelegramFromStructured - " on Sepolia", - "Time:", - "Trigger: (Simulated) Scheduled task ran on Sepolia", - "Executed:", - "• (Simulated) On-chain transaction successfully completed", - } - for _, expected := range expectedTelegramContents { - if !strings.Contains(telegram, expected) { - t.Errorf("Telegram output missing %q\nFull output:\n%s", expected, telegram) - } - } - - // Standalone Network line should NOT render — chain is folded into Runner. - if strings.Contains(telegram, "Network:") { - t.Errorf("standalone Network line should not render when Runner is present, got:\n%s", telegram) - } - - // The simulation Cost placeholder uses italic text; allow that but no other italics. - if strings.Contains(telegram, "") && !strings.Contains(telegram, "(see cost estimate before deploy)") { - t.Errorf("Telegram should not contain italic annotation other than the cost placeholder, got:\n%s", telegram) - } - - t.Logf("Subject: %s", summary.Subject) - t.Logf("Status: %s", summary.Status) - t.Logf("Network: %s", summary.Network) - t.Logf("Trigger: %s", summary.Trigger) - t.Logf("TriggeredAt: %s", summary.TriggeredAt) - t.Logf("Executions: %v", summary.Executions) - t.Logf("Telegram:\n%s", telegram) -} - -// TestComposeSummary_RunNodeImmediateSubjectPrefix tests that single-node -// RunNodeImmediately executions use "Run Node:" prefix, not "Simulation:". -func TestComposeSummary_RunNodeImmediateSubjectPrefix(t *testing.T) { - vm := NewVM() - // RunNodeImmediately: no TaskID, exactly one TaskNode - vm.mu.Lock() - vm.TaskNodes = map[string]*avsproto.TaskNode{ - "transfer1": {Id: "transfer1", Name: "transfer1"}, - } - vm.vars = map[string]interface{}{ - "settings": map[string]interface{}{ - "name": "My Transfer Workflow", - "chain": "Sepolia", - "chain_id": 11155111, - }, - } - vm.mu.Unlock() - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "transfer1", - Name: "transfer1", - Type: "NODE_TYPE_ETH_TRANSFER", - Success: true, - }, - } - - summary := ComposeSummary(vm, "transfer1") - - // Should use "Run Node:" prefix, not "Simulation:" - expectedSubject := "Run Node: My Transfer Workflow succeeded" - if summary.Subject != expectedSubject { - t.Errorf("Subject mismatch:\n expected: %q\n got: %q", expectedSubject, summary.Subject) - } - - // Network should be populated - if summary.Network != "Sepolia" { - t.Errorf("Network: expected %q, got %q", "Sepolia", summary.Network) - } - - // SummaryLine should say "1 out of 1" (not "0 out of 1") - expectedSummaryLine := "Your workflow 'My Transfer Workflow' executed 1 out of 1 total steps" - if summary.SummaryLine != expectedSummaryLine { - t.Errorf("SummaryLine mismatch:\n expected: %q\n got: %q", expectedSummaryLine, summary.SummaryLine) - } - - // Annotation should be set for single-node runs - if summary.Annotation == "" { - t.Error("Annotation should be set for single-node RunNodeImmediately executions") - } - if !strings.Contains(summary.Annotation, "This is an example") { - t.Errorf("Annotation should contain example disclaimer, got: %q", summary.Annotation) - } - - t.Logf("Subject: %s", summary.Subject) - t.Logf("Network: %s", summary.Network) - t.Logf("SummaryLine: %s", summary.SummaryLine) - t.Logf("Annotation: %s", summary.Annotation) -} - -// TestIsValidTxHash tests the Ethereum tx hash validation helper -func TestIsValidTxHash(t *testing.T) { - tests := []struct { - name string - hash string - expected bool - }{ - {"valid_hash", "0xdef456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0", true}, - {"valid_hash_uppercase", "0xDEF456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0", true}, - {"valid_hash_mixed", "0xAbCdEf0123456789abcdef0123456789AbCdEf0123456789abcdef0123456789", true}, - {"empty", "", false}, - {"no_prefix", "def456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0a", false}, - {"too_short", "0x1234", false}, - {"too_long", "0xdef456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0a", false}, - {"non_hex_chars", "0xggg456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0", false}, - {"bigint_simulation_id", "0x0000000000000000000000000000000000000000000001769651703156844000", true}, // 64 hex chars, format-valid (caught by isStepSimulated instead) - {"just_0x", "0x", false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isValidTxHash(tt.hash) - if result != tt.expected { - t.Errorf("isValidTxHash(%q) = %v, want %v (len=%d)", tt.hash, result, tt.expected, len(tt.hash)) - } - }) - } -} - -// TestIsStepSimulated tests the per-step simulation detection helper -func TestIsStepSimulated(t *testing.T) { - // Helper to create ExecutionContext structpb.Value - makeCtx := func(m map[string]interface{}) *structpb.Value { - v, _ := structpb.NewValue(m) - return v - } - - tests := []struct { - name string - step *avsproto.Execution_Step - expected bool - }{ - { - "nil_context", - &avsproto.Execution_Step{}, - false, - }, - { - "snake_case_true", - &avsproto.Execution_Step{ - ExecutionContext: makeCtx(map[string]interface{}{ - "is_simulated": true, - "provider": "tenderly", - }), - }, - true, - }, - { - "snake_case_false", - &avsproto.Execution_Step{ - ExecutionContext: makeCtx(map[string]interface{}{ - "is_simulated": false, - "provider": "chain_rpc", - }), - }, - false, - }, - { - "camel_case_true", - &avsproto.Execution_Step{ - ExecutionContext: makeCtx(map[string]interface{}{ - "isSimulated": true, - "provider": "tenderly", - }), - }, - true, - }, - { - "camel_case_false", - &avsproto.Execution_Step{ - ExecutionContext: makeCtx(map[string]interface{}{ - "isSimulated": false, - "provider": "chain_rpc", - }), - }, - false, - }, - { - "no_simulated_key", - &avsproto.Execution_Step{ - ExecutionContext: makeCtx(map[string]interface{}{ - "provider": "chain_rpc", - }), - }, - false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := isStepSimulated(tt.step) - if result != tt.expected { - t.Errorf("isStepSimulated() = %v, want %v", result, tt.expected) - } - }) - } -} - -// TestBuildExecutionsArray_SkipsSimulatedTxHash verifies that buildExecutionsArray -// does not attach txHash for simulated steps or invalid tx hashes -func TestBuildExecutionsArray_SkipsSimulatedTxHash(t *testing.T) { - makeCtx := func(isSimulated bool) *structpb.Value { - v, _ := structpb.NewValue(map[string]interface{}{ - "is_simulated": isSimulated, - "provider": "tenderly", - }) - return v - } - - validTxHash := "0xdef456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef0" - malformedHash := "0x1234" // too short to be valid - - // Metadata with valid tx hash - validMeta, _ := structpb.NewValue(map[string]interface{}{ - "transactionHash": validTxHash, - }) - // Metadata with malformed hash (wrong length) - malformedMeta, _ := structpb.NewValue(map[string]interface{}{ - "transactionHash": malformedHash, - }) - - // Contract write config with callData - writeConfig, _ := structpb.NewValue(map[string]interface{}{ - "contractAddress": "0x1234567890abcdef1234567890abcdef12345678", - "callData": "transfer(address,uint256)", - }) - - vm := NewVM() - vm.IsSimulation = true - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "step1", Name: "transfer_real", Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Success: true, - Config: writeConfig, - Metadata: validMeta, - ExecutionContext: makeCtx(false), // real execution - }, - { - Id: "step2", Name: "transfer_simulated", Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Success: true, - Config: writeConfig, - Metadata: validMeta, - ExecutionContext: makeCtx(true), // simulated — should skip txHash - }, - { - Id: "step3", Name: "transfer_malformed_hash", Type: avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE.String(), - Success: true, - Config: writeConfig, - Metadata: malformedMeta, - ExecutionContext: makeCtx(false), // real but malformed hash — should skip txHash - }, - } - - executions := buildExecutionsArray(vm) - - if len(executions) != 3 { - t.Fatalf("Expected 3 executions, got %d", len(executions)) - } - - // Step 1: real execution, valid hash → should have txHash - if executions[0].TxHash != validTxHash { - t.Errorf("Step 1 (real, valid hash): expected txHash=%q, got %q", validTxHash, executions[0].TxHash) - } - - // Step 2: simulated → should NOT have txHash - if executions[1].TxHash != "" { - t.Errorf("Step 2 (simulated): expected empty txHash, got %q", executions[1].TxHash) - } - - // Step 3: real but malformed hash → should NOT have txHash - if executions[2].TxHash != "" { - t.Errorf("Step 3 (real, malformed hash): expected empty txHash, got %q", executions[2].TxHash) - } -} - -func TestFormatBackticksToHTML(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - { - name: "no backticks", - input: "loop1 - skipped due to branch condition", - expected: "loop1 - skipped due to branch condition", - }, - { - name: "single backtick expression", - input: "condition not met: `balance >= totalNeeded` evaluated to false", - expected: "condition not met: balance >= totalNeeded evaluated to false", - }, - { - name: "multiple backtick expressions", - input: "`code1.data.balance` is less than `code1.data.totalNeeded`", - expected: "code1.data.balance is less than code1.data.totalNeeded", - }, - { - name: "unclosed backtick passes through", - input: "missing closing `backtick", - expected: "missing closing `backtick", - }, - { - name: "HTML chars outside backticks are escaped", - input: "value with `expr >= 0` end", - expected: "value <foo> with expr >= 0 end", - }, - { - name: "empty backticks", - input: "empty `` expression", - expected: "empty expression", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := formatBackticksToHTML(tt.input) - if got != tt.expected { - t.Errorf("formatBackticksToHTML(%q)\n got: %q\n want: %q", tt.input, got, tt.expected) - } - }) - } -} - -func TestFormatBackticksForChannel(t *testing.T) { - input := "condition: `x >= y` failed" - expectedHTML := "condition: x >= y failed" - - // Telegram and email should convert backticks to with proper escaping - for _, ch := range []string{"telegram", "email"} { - got := formatBackticksForChannel(input, ch) - if got != expectedHTML { - t.Errorf("channel %q:\n got: %q\n want: %q", ch, got, expectedHTML) - } - } - - // Discord and plaintext should pass through as-is - for _, ch := range []string{"discord", "plaintext", ""} { - got := formatBackticksForChannel(input, ch) - if got != input { - t.Errorf("channel %q: expected pass-through, got %q", ch, got) - } - } -} - -func TestBuildStatusHtml(t *testing.T) { - tests := []struct { - name string - summary Summary - wantColor string - wantText string - }{ - {"success clean", Summary{Status: "success"}, "#D1FAE5", "All steps completed successfully"}, - {"success with skipped uses skippedNote", Summary{Status: "success", SkippedSteps: 1, SkippedNote: "1 node was skipped by Branch condition."}, "#FEF3C7", "1 node was skipped by Branch condition."}, - {"failed", Summary{Status: "failed"}, "#FEE2E2", "Execution failed"}, - {"error", Summary{Status: "error"}, "#FEE2E2", "System error"}, - {"default", Summary{}, "#D1FAE5", "Completed"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - html := buildStatusHtml(tt.summary) - if !strings.Contains(html, tt.wantColor) { - t.Errorf("buildStatusHtml(%+v): expected color %q in output:\n%s", tt.summary, tt.wantColor, html) - } - if !strings.Contains(html, tt.wantText) { - t.Errorf("buildStatusHtml(%+v): expected text %q in output:\n%s", tt.summary, tt.wantText, html) - } - }) - } -} diff --git a/core/taskengine/summarizer_run_node_test.go b/core/taskengine/summarizer_run_node_test.go deleted file mode 100644 index 2f180af7..00000000 --- a/core/taskengine/summarizer_run_node_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package taskengine - -import ( - "strings" - "testing" - - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "google.golang.org/protobuf/types/known/structpb" -) - -func TestComposeSummary_RunNodeRestSubjectAndBody(t *testing.T) { - vm := NewVM() - - vm.AddVar("aa_sender", "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f") - vm.mu.Lock() - vm.vars["settings"] = map[string]interface{}{ - "name": "Automated Stop-Loss on Uniswap V3", - "chain": "Sepolia", - "runner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - } - vm.TaskNodes["email_report_success"] = &avsproto.TaskNode{ - Id: "email_report_success", - Name: "email_report_success", - Type: avsproto.NodeType_NODE_TYPE_REST_API, - } - vm.mu.Unlock() - - restPayload := map[string]interface{}{ - "status": 202, - "statusText": "Accepted", - "url": "https://app.avaprotocol.org/api/notify", - } - restValue, err := structpb.NewValue(restPayload) - if err != nil { - t.Fatalf("failed to build rest payload: %v", err) - } - - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "email_report_success", - Name: "email_report_success", - Success: true, - Type: avsproto.NodeType_NODE_TYPE_REST_API.String(), - OutputData: &avsproto.Execution_Step_RestApi{ - RestApi: &avsproto.RestAPINode_Output{ - Data: restValue, - }, - }, - }, - } - - summary := ComposeSummary(vm, "email_report_success") - - expectedSubject := "Run Node: Automated Stop-Loss on Uniswap V3 succeeded" - if summary.Subject != expectedSubject { - t.Fatalf("expected subject %q, got %q", expectedSubject, summary.Subject) - } - - if !strings.Contains(summary.Body, "REST API node") { - t.Fatalf("expected body to mention REST API node, got: %q", summary.Body) - } - - if !strings.Contains(summary.Body, "HTTP 202") { - t.Fatalf("expected body to mention HTTP status, got: %q", summary.Body) - } - - if !strings.Contains(summary.Body, "Ava Protocol") { - t.Fatalf("expected body to mention the Ava Protocol notification provider, got: %q", summary.Body) - } -} diff --git a/core/taskengine/tenderly_client_test.go b/core/taskengine/tenderly_client_test.go index 19d73a29..85631737 100644 --- a/core/taskengine/tenderly_client_test.go +++ b/core/taskengine/tenderly_client_test.go @@ -2137,6 +2137,7 @@ func TestContractWrite_WETHDeposit_WithETHValue_Sepolia(t *testing.T) { nodeType := "contractWrite" nodeConfig := map[string]interface{}{ "contractAddress": "0xfff9976782d46cc05630d1f6ebab18b2324d6b14", // WETH on Sepolia + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": []interface{}{ map[string]interface{}{ "constant": false, diff --git a/core/taskengine/trigger_data_flattening_test.go b/core/taskengine/trigger_data_flattening_test.go index a3da5c92..e5e4ed8a 100644 --- a/core/taskengine/trigger_data_flattening_test.go +++ b/core/taskengine/trigger_data_flattening_test.go @@ -448,6 +448,7 @@ func TestContractReadCamelCaseResolution(t *testing.T) { // Test contract read that uses camelCase template contractReadConfig := map[string]interface{}{ "contractAddress": "{{eventTrigger.data.contractAddress}}", // camelCase template + "chainId": 11155111, // explicit chain (G5 strict) "contractAbi": []interface{}{ map[string]interface{}{ "inputs": []interface{}{}, diff --git a/core/taskengine/utils_calldata_test.go b/core/taskengine/utils_calldata_test.go index 48cdf08f..7c60a3d9 100644 --- a/core/taskengine/utils_calldata_test.go +++ b/core/taskengine/utils_calldata_test.go @@ -490,6 +490,7 @@ func TestContractRead_InvalidNumericValue_ResponseStructure(t *testing.T) { TaskType: &avsproto.TaskNode_ContractRead{ ContractRead: &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (strict) ContractAddress: "0xEd1f6473345F45b75F8179591dd5bA1888cf2FB3", ContractAbi: contractAbi, MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -506,7 +507,8 @@ func TestContractRead_InvalidNumericValue_ResponseStructure(t *testing.T) { } req := &avsproto.RunNodeWithInputsReq{ - Node: contractReadNode, + ChainId: 11155111, // explicit chain (strict) + Node: contractReadNode, InputVariables: map[string]*structpb.Value{ "settings": structpb.NewStructValue(&structpb.Struct{ Fields: map[string]*structpb.Value{ @@ -649,6 +651,7 @@ func TestContractWrite_InvalidNumericValue_ResponseStructure(t *testing.T) { TaskType: &avsproto.TaskNode_ContractWrite{ ContractWrite: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (strict) ContractAddress: "0xA0b86a33E6441d0be3c7bb50e65Eb42d5E0b2b4b", ContractAbi: contractAbi, MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ @@ -667,7 +670,8 @@ func TestContractWrite_InvalidNumericValue_ResponseStructure(t *testing.T) { } req := &avsproto.RunNodeWithInputsReq{ - Node: contractWriteNode, + ChainId: 11155111, // explicit chain (strict) + Node: contractWriteNode, InputVariables: map[string]*structpb.Value{ "settings": structpb.NewStructValue(&structpb.Struct{ Fields: map[string]*structpb.Value{ diff --git a/core/taskengine/vm.go b/core/taskengine/vm.go index d5fd0830..6ac07c93 100644 --- a/core/taskengine/vm.go +++ b/core/taskengine/vm.go @@ -327,48 +327,38 @@ func (v *VM) WithChainConfigResolver(resolver func(chainID int64) *config.SmartW return v } -// resolveSmartWalletForNode picks the SmartWalletConfig a node should -// use, walking three sources in order: -// -// 1. The node's own chain_id override (set on Config when the SDK or -// UI threads it through explicitly). -// 2. The task's ChainId (the workflow's target chain). Loop iterations -// create nested nodes via createNestedNodeFromLoop, which returns -// the inner node proto verbatim — those inner nodes don't carry a -// per-iteration chain_id, so without this fallback the resolver -// would land on v.smartWalletConfig. In gateway mode that default -// is populated from chains[0] (mainnet by convention; see -// core/config/config.go:367), causing paymaster ops for any other -// chain's workflow to dial the wrong RPC and surface as -// "no contract code at given address" (Sentry EIGENLAYER-AVS-1N/1M). -// 3. v.smartWalletConfig as a last-resort default — correct in -// single-chain mode where chainConfigResolver is nil and the VM -// was constructed with the only smart_wallet config that exists. -func (v *VM) resolveSmartWalletForNode(nodeChainID int64) *config.SmartWalletConfig { +// resolveSmartWalletForNode picks the SmartWalletConfig a chain-aware node +// should use. Post-G5 a task carries no chain, so the node's own chain_id is +// REQUIRED — there is nothing to inherit and no default to fall back to: +// - chain_id <= 0 is a hard error in all modes. An isolated run +// (RunNodeImmediately / simulate) must supply the chain on the request, +// which is stamped onto the node before execution (stampNodeChainIfUnset). +// - Gateway mode: the chain must resolve against the configured set, else +// error (no silent fallback — the Sentry EIGENLAYER-AVS-1N/1M footgun). +// - Single-chain mode (no resolver): the sole smart_wallet config is used. +func (v *VM) resolveSmartWalletForNode(nodeChainID int64) (*config.SmartWalletConfig, error) { + if nodeChainID <= 0 { + return nil, fmt.Errorf("chain-aware node requires an explicit chain_id (got %d); a task no longer provides a default chain", nodeChainID) + } if v.chainConfigResolver != nil { - if nodeChainID > 0 { - if resolved := v.chainConfigResolver(nodeChainID); resolved != nil { - return resolved - } - } - if taskChainID := v.taskChainID(); taskChainID > 0 { - if resolved := v.chainConfigResolver(taskChainID); resolved != nil { - return resolved - } + if resolved := v.chainConfigResolver(nodeChainID); resolved != nil { + return resolved, nil } + return nil, fmt.Errorf("chain_id %d is not configured on this aggregator", nodeChainID) + } + if v.smartWalletConfig != nil { + return v.smartWalletConfig, nil } - return v.smartWalletConfig + return nil, fmt.Errorf("no smart wallet config available for chain_id %d", nodeChainID) } -// taskChainID returns the chain id of the workflow this VM is executing, -// or 0 if the task wasn't set (e.g. RunNodeImmediately) or the task -// itself has no chain_id stored (legacy workflows created before -// per-task chain_id was required). -func (v *VM) taskChainID() int64 { - if v.task == nil || v.task.Task == nil { - return 0 +// vmDefaultChainID returns the VM's default-config chain, used for +// chain-invariant lookups (e.g. wallet salt) now that tasks carry no chain. +func (v *VM) vmDefaultChainID() int64 { + if v.smartWalletConfig != nil { + return v.smartWalletConfig.ChainID } - return v.task.Task.ChainId + return 0 } func (v *VM) GetTriggerNameAsVar() (string, error) { @@ -1472,7 +1462,12 @@ func (v *VM) runContractRead(taskNode *avsproto.TaskNode) (*avsproto.Execution_S return executionLog, err } - swConfig := v.resolveSmartWalletForNode(node.Config.GetChainId()) + swConfig, err := v.resolveSmartWalletForNode(node.Config.GetChainId()) + if err != nil { + executionLog = v.createExecutionStep(stepID, false, err.Error(), "", time.Now().UnixMilli()) + executionLog.EndAt = time.Now().UnixMilli() + return executionLog, err + } if swConfig == nil || swConfig.EthRpcUrl == "" { err := fmt.Errorf("smart wallet config or ETH RPC URL not set for contract read (chain_id=%d)", node.Config.GetChainId()) executionLog = v.createExecutionStep(stepID, false, err.Error(), "", time.Now().UnixMilli()) @@ -1515,7 +1510,12 @@ func (v *VM) runContractWrite(taskNode *avsproto.TaskNode) (*avsproto.Execution_ } stepID := taskNode.Id var executionLog *avsproto.Execution_Step - swConfig := v.resolveSmartWalletForNode(node.Config.GetChainId()) + swConfig, err := v.resolveSmartWalletForNode(node.Config.GetChainId()) + if err != nil { + executionLog = v.createExecutionStep(stepID, false, err.Error(), "", time.Now().UnixMilli()) + executionLog.EndAt = time.Now().UnixMilli() + return executionLog, err + } if swConfig == nil || swConfig.EthRpcUrl == "" { err := fmt.Errorf("smart wallet config or ETH RPC URL not set for contract write (chain_id=%d)", node.Config.GetChainId()) executionLog = v.createExecutionStep(stepID, false, err.Error(), "", time.Now().UnixMilli()) @@ -1564,7 +1564,7 @@ func (v *VM) runContractWrite(taskNode *avsproto.TaskNode) (*avsproto.Execution_ _, hasSalt := v.vars["aa_salt"] v.mu.Unlock() if !hasSalt && v.db != nil { - if wallet, err := GetWallet(v.db, v.task.Task.ChainId, v.TaskOwner, v.task.SmartWalletAddress); err == nil && wallet != nil && wallet.Salt != nil { + if wallet, err := GetWallet(v.db, v.vmDefaultChainID(), v.TaskOwner, v.task.SmartWalletAddress); err == nil && wallet != nil && wallet.Salt != nil { v.AddVar("aa_salt", wallet.Salt) } } @@ -1740,7 +1740,12 @@ func (v *VM) runEthTransfer(taskNode *avsproto.TaskNode) (*avsproto.Execution_St } stepID := taskNode.Id var executionLog *avsproto.Execution_Step - swConfig := v.resolveSmartWalletForNode(node.Config.GetChainId()) + swConfig, err := v.resolveSmartWalletForNode(node.Config.GetChainId()) + if err != nil { + executionLog = v.createExecutionStep(stepID, false, err.Error(), "", time.Now().UnixMilli()) + executionLog.EndAt = time.Now().UnixMilli() + return executionLog, err + } if swConfig == nil || swConfig.EthRpcUrl == "" { err := fmt.Errorf("smart wallet config or ETH RPC URL not set for ETH transfer (chain_id=%d)", node.Config.GetChainId()) executionLog = v.createExecutionStep(stepID, false, err.Error(), "", time.Now().UnixMilli()) @@ -2591,6 +2596,7 @@ func CreateNodeFromType(nodeType string, config map[string]interface{}, nodeID s node.Type = avsproto.NodeType_NODE_TYPE_CONTRACT_READ // Create contract read node with proper configuration contractConfig := &avsproto.ContractReadNode_Config{} + contractConfig.ChainId = int64(chainIDFromSettingsValue(config["chainId"])) // Use camelCase only for consistency with JavaScript SDK if address, ok := config["contractAddress"].(string); ok { @@ -2663,6 +2669,7 @@ func CreateNodeFromType(nodeType string, config map[string]interface{}, nodeID s node.Type = avsproto.NodeType_NODE_TYPE_CONTRACT_WRITE // Create contract write node with proper configuration contractConfig := &avsproto.ContractWriteNode_Config{} + contractConfig.ChainId = int64(chainIDFromSettingsValue(config["chainId"])) // Use camelCase only for consistency with JavaScript SDK if address, ok := config["contractAddress"].(string); ok { @@ -2841,6 +2848,7 @@ func CreateNodeFromType(nodeType string, config map[string]interface{}, nodeID s node.Type = avsproto.NodeType_NODE_TYPE_ETH_TRANSFER // Create ETH transfer node with proper configuration ethConfig := &avsproto.ETHTransferNode_Config{} + ethConfig.ChainId = int64(chainIDFromSettingsValue(config["chainId"])) if destination, ok := config["destination"].(string); ok { ethConfig.Destination = destination } @@ -2980,6 +2988,7 @@ func CreateNodeFromType(nodeType string, config map[string]interface{}, nodeID s } case "contractRead": crConfig := &avsproto.ContractReadNode_Config{} + crConfig.ChainId = int64(chainIDFromSettingsValue(runnerConfig["chainId"])) // Extract contract configuration if contractAddress, ok := runnerConfig["contractAddress"].(string); ok { @@ -3056,6 +3065,7 @@ func CreateNodeFromType(nodeType string, config map[string]interface{}, nodeID s } case "ethTransfer": etConfig := &avsproto.ETHTransferNode_Config{} + etConfig.ChainId = int64(chainIDFromSettingsValue(runnerConfig["chainId"])) if destination, ok := runnerConfig["destination"].(string); ok { etConfig.Destination = destination } @@ -3084,6 +3094,7 @@ func CreateNodeFromType(nodeType string, config map[string]interface{}, nodeID s } case "contractWrite": cwConfig := &avsproto.ContractWriteNode_Config{} + cwConfig.ChainId = int64(chainIDFromSettingsValue(runnerConfig["chainId"])) // Extract contract configuration if contractAddress, ok := runnerConfig["contractAddress"].(string); ok { @@ -3594,6 +3605,12 @@ func ExtractNodeConfiguration(taskNode *avsproto.TaskNode) map[string]interface{ "contractAbi": contractAbiArray, } + // Carry the per-node chain so isolated paths (runNodeImmediately, + // simulation, loop-nested) don't lose it and run on the wrong chain. + if cid := contractRead.Config.GetChainId(); cid != 0 { + config["chainId"] = cid + } + // Handle method calls - extract fields to simple map for protobuf compatibility if len(contractRead.Config.MethodCalls) > 0 { methodCallsArray := make([]interface{}, len(contractRead.Config.MethodCalls)) @@ -3647,6 +3664,12 @@ func ExtractNodeConfiguration(taskNode *avsproto.TaskNode) map[string]interface{ "contractAbi": contractAbiArray, } + // Carry the per-node chain so isolated paths (runNodeImmediately, + // simulation, loop-nested) don't lose it and run on the wrong chain. + if cid := contractWrite.Config.GetChainId(); cid != 0 { + config["chainId"] = cid + } + // Extract optional value field (ETH to send with transaction) if contractWrite.Config.Value != nil { config["value"] = *contractWrite.Config.Value @@ -3750,6 +3773,12 @@ func ExtractNodeConfiguration(taskNode *avsproto.TaskNode) map[string]interface{ "amount": ethTransfer.Config.Amount, } + // Carry the per-node chain so isolated paths (runNodeImmediately, + // simulation, loop-nested) don't lose it and run on the wrong chain. + if cid := ethTransfer.Config.GetChainId(); cid != 0 { + config["chainId"] = cid + } + // Clean up complex protobuf types before returning return removeComplexProtobufTypes(config) } diff --git a/core/taskengine/vm_contract_operations_test.go b/core/taskengine/vm_contract_operations_test.go index b695d1a4..cae5c364 100644 --- a/core/taskengine/vm_contract_operations_test.go +++ b/core/taskengine/vm_contract_operations_test.go @@ -69,6 +69,7 @@ func TestVM_ContractRead_BasicExecution(t *testing.T) { node := &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", // Chainlink ETH/USD ContractAbi: decimalsABIValues, MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -107,6 +108,7 @@ func TestVM_ContractRead_DecimalFormatting(t *testing.T) { node := &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", // Chainlink ETH/USD ContractAbi: MustConvertJSONABIToProtobufValues(testChainlinkABI), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -199,6 +201,7 @@ func TestVM_ContractRead_LatestRoundData(t *testing.T) { // Test reading latest round data from ETH/USD price feed node := &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", ContractAbi: MustConvertJSONABIToProtobufValues(testLatestRoundDataABI), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -235,6 +238,7 @@ func TestVM_ContractRead_ErrorHandling(t *testing.T) { setupVM: func(v *VM) { v.smartWalletConfig = nil }, node: &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", ContractAbi: MustConvertJSONABIToProtobufValues(testDecimalsABI), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -271,6 +275,7 @@ func TestVM_ContractRead_ErrorHandling(t *testing.T) { }, node: &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "invalid-address", ContractAbi: MustConvertJSONABIToProtobufValues(testDecimalsABI), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -318,6 +323,7 @@ func TestVM_ContractRead_ErrorHandling(t *testing.T) { }, node: &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", // Valid Chainlink contract ContractAbi: MustConvertJSONABIToProtobufValues(testChainlinkABI), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -353,6 +359,7 @@ func TestVM_ContractRead_ErrorHandling(t *testing.T) { }, node: &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c", // Client's contract address ContractAbi: MustConvertJSONABIToProtobufValues(testChainlinkABI), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -388,6 +395,7 @@ func TestVM_ContractRead_ErrorHandling(t *testing.T) { }, node: &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c", // Address that returns empty data ContractAbi: MustConvertJSONABIToProtobufValues(testChainlinkABI), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -453,6 +461,7 @@ func TestVM_ContractWrite_BasicExecution(t *testing.T) { // Test contract write (will likely fail due to lack of actual transaction setup, but should not panic) node := &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x742d35Cc6634C0532925a3b8D091d2B5e57a9C7E", // Test address ContractAbi: MustConvertJSONABIToProtobufValues(testTransferABI), MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ @@ -532,6 +541,7 @@ func TestVM_ContractWrite_ErrorHandling(t *testing.T) { setupVM: func(v *VM) { v.smartWalletConfig = nil }, node: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x742d35Cc6634C0532925a3b8D091d2B5e57a9C7E", ContractAbi: MustConvertJSONABIToProtobufValues(testSimpleFunctionABI), MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ @@ -567,6 +577,7 @@ func TestVM_ContractWrite_ErrorHandling(t *testing.T) { }, node: &avsproto.ContractWriteNode{ Config: &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "invalid-address", ContractAbi: MustConvertJSONABIToProtobufValues(testSimpleFunctionABI), MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ diff --git a/core/taskengine/vm_execution_flow_test.go b/core/taskengine/vm_execution_flow_test.go index 95144653..4aa36a6a 100644 --- a/core/taskengine/vm_execution_flow_test.go +++ b/core/taskengine/vm_execution_flow_test.go @@ -25,6 +25,7 @@ func TestVM_EthTransfer_BasicExecution(t *testing.T) { // Test ETH transfer node (will likely fail due to insufficient funds, but should not panic) node := &avsproto.ETHTransferNode{ Config: &avsproto.ETHTransferNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) Destination: "0x742d35Cc6634C0532925a3b8D091d2B5e57a9C7E", // Test address Amount: "0.001", // Small amount in ETH }, @@ -56,6 +57,7 @@ func TestVM_EthTransfer_ErrorHandling(t *testing.T) { setupVM: func(v *VM) { v.smartWalletConfig = nil }, node: &avsproto.ETHTransferNode{ Config: &avsproto.ETHTransferNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) Destination: "0x742d35Cc6634C0532925a3b8D091d2B5e57a9C7E", Amount: "0.001", }, @@ -68,6 +70,7 @@ func TestVM_EthTransfer_ErrorHandling(t *testing.T) { setupVM: func(v *VM) { v.smartWalletConfig = testutil.GetTestSmartWalletConfig() }, node: &avsproto.ETHTransferNode{ Config: &avsproto.ETHTransferNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) Destination: "invalid-address", Amount: "0.001", }, @@ -80,6 +83,7 @@ func TestVM_EthTransfer_ErrorHandling(t *testing.T) { setupVM: func(v *VM) { v.smartWalletConfig = testutil.GetTestSmartWalletConfig() }, node: &avsproto.ETHTransferNode{ Config: &avsproto.ETHTransferNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) Destination: "0x742d35Cc6634C0532925a3b8D091d2B5e57a9C7E", Amount: "invalid-amount", }, diff --git a/core/taskengine/vm_node_runners_test.go b/core/taskengine/vm_node_runners_test.go index 6c7ef20a..2069aa89 100644 --- a/core/taskengine/vm_node_runners_test.go +++ b/core/taskengine/vm_node_runners_test.go @@ -26,6 +26,7 @@ func TestVM_ContractReadRunner(t *testing.T) { // Test contract read with proper configuration node := &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", // Chainlink ETH/USD price feed ContractAbi: MustConvertJSONABIToProtobufValues(testDecimalsABIForRunners), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -63,6 +64,7 @@ func TestVM_ContractReadRunner_MissingConfig(t *testing.T) { // Test contract read with missing smart wallet config node := &avsproto.ContractReadNode{ Config: &avsproto.ContractReadNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419", ContractAbi: MustConvertJSONABIToProtobufValues(testDecimalsABIForRunners), MethodCalls: []*avsproto.ContractReadNode_MethodCall{ @@ -194,6 +196,7 @@ func TestVM_EthTransferRunner(t *testing.T) { // Test ETH transfer with missing smart wallet config (should fail gracefully) node := &avsproto.ETHTransferNode{ Config: &avsproto.ETHTransferNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) Destination: "0x742d35Cc6634C0532925a3b8D091d2B5e57a9C7E", Amount: "1000000000000000", // 0.001 ETH in wei }, diff --git a/core/taskengine/vm_resolve_smart_wallet_test.go b/core/taskengine/vm_resolve_smart_wallet_test.go index a0ad39cb..1120ea7b 100644 --- a/core/taskengine/vm_resolve_smart_wallet_test.go +++ b/core/taskengine/vm_resolve_smart_wallet_test.go @@ -4,8 +4,6 @@ import ( "testing" "github.com/AvaProtocol/EigenLayer-AVS/core/config" - "github.com/AvaProtocol/EigenLayer-AVS/model" - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" "github.com/ethereum/go-ethereum/common" ) @@ -52,69 +50,62 @@ func TestResolveSmartWalletForNode_TaskChainIDFallback(t *testing.T) { return registry[chainID] } - newVM := func(taskChainID int64, vmDefault *config.SmartWalletConfig) *VM { + newVM := func(vmDefault *config.SmartWalletConfig) *VM { vm := NewVM() vm.smartWalletConfig = vmDefault vm.chainConfigResolver = resolver - if taskChainID != 0 { - vm.task = &model.Workflow{ - Task: &avsproto.Task{ChainId: taskChainID}, - } - } return vm } + // Post-G5: a task carries no chain, so there is no inheritance. A node's + // explicit chain resolves (or errors if unconfigured); a 0 node chain is a + // hard error (chain_id required on chain-aware nodes in all modes). + _ = sepoliaCfg tests := []struct { name string nodeChainID int64 - taskChainID int64 vmDefault *config.SmartWalletConfig want *config.SmartWalletConfig + wantErr bool }{ { - name: "node chain_id takes precedence over task chain_id", + name: "explicit node chain_id resolves", nodeChainID: baseChainID, - taskChainID: sepoliaChainID, vmDefault: mainnetCfg, want: baseCfg, }, { - name: "node chain_id 0 falls back to task chain_id (loop iteration case)", + // A 0 node chain_id is a hard error (chain required, no inheritance). + name: "node chain_id 0 errors", nodeChainID: 0, - taskChainID: sepoliaChainID, - vmDefault: mainnetCfg, // populated from chains[0] = mainnet — the bug fixture - want: sepoliaCfg, - }, - { - name: "both 0 falls back to vm default (single-chain mode shape)", - nodeChainID: 0, - taskChainID: 0, vmDefault: mainnetCfg, - want: mainnetCfg, + wantErr: true, }, { - name: "unknown node chain_id but known task chain_id resolves to task", + name: "explicit unknown node chain_id errors", nodeChainID: 999999, // not registered - taskChainID: sepoliaChainID, vmDefault: mainnetCfg, - want: sepoliaCfg, - }, - { - name: "both unknown falls back to vm default", - nodeChainID: 999998, - taskChainID: 0, - vmDefault: mainnetCfg, - want: mainnetCfg, + wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - vm := newVM(tt.taskChainID, tt.vmDefault) - got := vm.resolveSmartWalletForNode(tt.nodeChainID) + vm := newVM(tt.vmDefault) + got, err := vm.resolveSmartWalletForNode(tt.nodeChainID) + if tt.wantErr { + if err == nil { + t.Fatalf("resolveSmartWalletForNode(%d): expected error, got chain %d (%s)", + tt.nodeChainID, chainIDOrZero(got), rpcOrEmpty(got)) + } + return + } + if err != nil { + t.Fatalf("resolveSmartWalletForNode(%d): unexpected error: %v", tt.nodeChainID, err) + } if got != tt.want { - t.Fatalf("resolveSmartWalletForNode(%d) with task=%d: got chain %d (%s), want chain %d (%s)", - tt.nodeChainID, tt.taskChainID, + t.Fatalf("resolveSmartWalletForNode(%d): got chain %d (%s), want chain %d (%s)", + tt.nodeChainID, chainIDOrZero(got), rpcOrEmpty(got), chainIDOrZero(tt.want), rpcOrEmpty(tt.want)) } @@ -123,20 +114,18 @@ func TestResolveSmartWalletForNode_TaskChainIDFallback(t *testing.T) { } // TestResolveSmartWalletForNode_NoResolver covers the single-chain -// (non-gateway) shape: chainConfigResolver is nil, so the resolver -// must always return v.smartWalletConfig regardless of the node or -// task chain_id values. +// (non-gateway) shape: chainConfigResolver is nil, so the resolver returns +// v.smartWalletConfig for an explicit (>0) node chain_id; 0 is a hard error. func TestResolveSmartWalletForNode_NoResolver(t *testing.T) { defaultCfg := &config.SmartWalletConfig{ChainID: 1, EthRpcUrl: "https://default/rpc"} vm := NewVM() vm.smartWalletConfig = defaultCfg - vm.task = &model.Workflow{Task: &avsproto.Task{ChainId: 11_155_111}} - if got := vm.resolveSmartWalletForNode(8453); got != defaultCfg { - t.Fatalf("expected default config when chainConfigResolver is nil, got %v", got) + if got, err := vm.resolveSmartWalletForNode(8453); err != nil || got != defaultCfg { + t.Fatalf("expected default config when chainConfigResolver is nil, got %v (err %v)", got, err) } - if got := vm.resolveSmartWalletForNode(0); got != defaultCfg { - t.Fatalf("expected default config when chainConfigResolver is nil (chain_id 0), got %v", got) + if _, err := vm.resolveSmartWalletForNode(0); err == nil { + t.Fatalf("expected error for chain_id 0 (explicit chain required), got nil") } } diff --git a/core/taskengine/vm_runner_contract_write_data_priority_test.go b/core/taskengine/vm_runner_contract_write_data_priority_test.go index 77f41431..dcbf8e07 100644 --- a/core/taskengine/vm_runner_contract_write_data_priority_test.go +++ b/core/taskengine/vm_runner_contract_write_data_priority_test.go @@ -104,6 +104,7 @@ func TestContractWriteDataPriority(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": sepoliaUsdcAddress.Hex(), + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": contractAbi, "methodCalls": []interface{}{ map[string]interface{}{ @@ -165,6 +166,7 @@ func TestContractWriteDataPriority(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": sepoliaUsdcAddress.Hex(), + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": contractAbi, "methodCalls": []interface{}{ map[string]interface{}{ @@ -225,6 +227,7 @@ func TestContractWriteDataPriority(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": sepoliaUsdcAddress.Hex(), + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": contractAbi, "methodCalls": []interface{}{ map[string]interface{}{ @@ -292,6 +295,7 @@ func TestContractWriteDataPriority(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": sepoliaUsdcAddress.Hex(), + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": contractAbi, "methodCalls": []interface{}{ map[string]interface{}{ diff --git a/core/taskengine/vm_runner_contract_write_simulation_test.go b/core/taskengine/vm_runner_contract_write_simulation_test.go index c1281527..ef82f8b5 100644 --- a/core/taskengine/vm_runner_contract_write_simulation_test.go +++ b/core/taskengine/vm_runner_contract_write_simulation_test.go @@ -77,7 +77,8 @@ func TestContractWriteTenderlySimulation(t *testing.T) { // Test run_node_immediately nodeConfig := map[string]interface{}{ "contractAddress": sepoliaUsdcAddress.Hex(), - "contractAbi": contractAbi, // Now using parsed array instead of JSON string + "chainId": int64(11155111), // explicit chain (strict) + "contractAbi": contractAbi, // Now using parsed array instead of JSON string "methodCalls": []interface{}{ map[string]interface{}{ "callData": approveCallData, @@ -190,6 +191,7 @@ func TestContractWriteTenderlySimulation(t *testing.T) { // Exact node config from client request nodeConfig := map[string]interface{}{ "contractAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": contractAbi, "methodCalls": []interface{}{ map[string]interface{}{ @@ -319,6 +321,7 @@ func TestContractWriteTenderlySimulation(t *testing.T) { nodeConfig := map[string]interface{}{ "contractAddress": "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", + "chainId": int64(11155111), // explicit chain (strict) "contractAbi": transferAbi, "methodCalls": []interface{}{ map[string]interface{}{ diff --git a/core/taskengine/vm_runner_contract_write_uniswap_test.go b/core/taskengine/vm_runner_contract_write_uniswap_test.go index 616bb06a..0756dc78 100644 --- a/core/taskengine/vm_runner_contract_write_uniswap_test.go +++ b/core/taskengine/vm_runner_contract_write_uniswap_test.go @@ -114,6 +114,7 @@ func TestContractWriteNode_UniswapV3Quote(t *testing.T) { // Create the node config nodeConfig := &avsproto.ContractWriteNode_Config{ + ChainId: 11155111, // explicit chain (G5 strict) ContractAddress: "0xed1f6473345f45b75f8179591dd5ba1888cf2fb3", ContractAbi: []*structpb.Value{abiValue}, MethodCalls: []*avsproto.ContractWriteNode_MethodCall{ diff --git a/core/taskengine/vm_runner_rest.go b/core/taskengine/vm_runner_rest.go index 5bdcf107..e8afdec1 100644 --- a/core/taskengine/vm_runner_rest.go +++ b/core/taskengine/vm_runner_rest.go @@ -3,7 +3,6 @@ package taskengine import ( "encoding/json" "fmt" - "html" "net/http" "net/http/httptest" "strings" @@ -19,44 +18,6 @@ const ( MockAPIEndpoint = "https://mock-api.ap-aggregator.local" ) -// SendGrid Dynamic Template ID for AI-generated workflow summaries -const ( - SendGridSummaryTemplateID = "d-3b4b885af0fc45ad822024ebc72f169c" -) - -// Status HTML badge SVG icons for email notifications -const ( - statusIconSuccess = `` - statusIconWarn = `` - statusIconFailed = `` -) - -// buildStatusHtml generates the status badge HTML for email notifications. -// The Summary carries the context-memory-supplied status ("success" | "failed" | "error") -// plus step counts — a success run with SkippedSteps>0 renders yellow ("warn") and -// uses s.SkippedNote as the badge text so the skip is visible. -func buildStatusHtml(s Summary) string { - var iconSvg, bgColor, textColor string - switch { - case s.Status == "success" && s.SkippedSteps > 0: - iconSvg = statusIconWarn - bgColor = "#FEF3C7" // light yellow - textColor = "#92400E" // dark amber - case s.Status == "failed" || s.Status == "error": - iconSvg = statusIconFailed - bgColor = "#FEE2E2" // light red - textColor = "#991B1B" // dark red - default: // "success" without skipped steps, or unknown - iconSvg = statusIconSuccess - bgColor = "#D1FAE5" // light green - textColor = "#065F46" // dark green - } - return fmt.Sprintf( - `
%s%s
`, - bgColor, textColor, iconSvg, getStatusDisplayText(s), - ) -} - // HTTPRequestExecutor interface for making HTTP requests type HTTPRequestExecutor interface { ExecuteRequest(method, url, body string, headers map[string]string) (*resty.Response, error) @@ -682,10 +643,9 @@ func (r *RestProcessor) Execute(stepID string, node *avsproto.RestAPINode) (*avs r.vm.logger.Debug("REST API body preprocessing", "contentType", contentType, "usedJSONPreprocessing", isJSONContent) } - // Optionally compose summary and inject into provider payloads when enabled via nodeConfig.options.summarize - // Keep a reference to the composed summary so we can return it to clients even - // when the notification provider (Studio /api/notify) returns an empty body. - var summaryForClient *Summary + // Optionally forward raw execution data to Studio /api/notify (Path B), which summarizes + // AND sends and returns the summary (surfaced to the SDK after the POST). The gateway no + // longer summarizes or sends notifications itself — it is a pass-through to /api/notify. isStudioNotify := false if shouldSummarize(r.vm, node) { provider := detectNotificationProvider(url) @@ -693,11 +653,8 @@ func (r *RestProcessor) Execute(stepID string, node *avsproto.RestAPINode) (*avs r.vm.logger.Info("REST API summarize enabled", "provider", provider, "url", url) } if provider == "studio-notify" { - // Path B: forward raw execution data to Studio /api/notify, which summarizes AND - // sends and returns the summary (surfaced to the SDK after the POST). The gateway no - // longer summarizes or sends email itself. isStudioNotify = true - if cm, ok := globalSummarizer.(*ContextMemorySummarizer); ok && cm != nil { + if cm := globalSummarizer; cm != nil { if payload, err := cm.BuildNotifyPayload(r.vm, r.vm.GetNodeNameAsVar(stepID)); err == nil { var bodyObj map[string]interface{} if json.Unmarshal([]byte(body), &bodyObj) == nil { @@ -708,6 +665,8 @@ func (r *RestProcessor) Execute(stepID string, node *avsproto.RestAPINode) (*avs } } body = FormatAsJSON(bodyObj) + } else if r.vm.logger != nil { + r.vm.logger.Warn("REST API notify: body is not JSON, skipping payload injection") } } else if r.vm.logger != nil { r.vm.logger.Warn("REST API notify: failed to build raw payload", "error", err) @@ -715,20 +674,10 @@ func (r *RestProcessor) Execute(stepID string, node *avsproto.RestAPINode) (*avs } else if r.vm.logger != nil { r.vm.logger.Warn("REST API notify: no summarizer configured to build payload") } - } else if provider == "telegram" { - // Legacy gateway-side Telegram send (moves to /api/notify in Phase 3). - currentName := r.vm.GetNodeNameAsVar(stepID) - s := ComposeSummarySmart(r.vm, currentName) - summaryForClient = &s - var bodyObj map[string]interface{} - if json.Unmarshal([]byte(body), &bodyObj) == nil { - telegramMsg := FormatForMessageChannels(s, "telegram", r.vm) - bodyObj["parse_mode"] = "HTML" - bodyObj["text"] = telegramMsg - body = FormatAsJSON(bodyObj) - } else if r.vm.logger != nil { - r.vm.logger.Warn("REST API summarize enabled but body is not JSON, skipping injection") - } + } else if provider == "telegram" && r.vm.logger != nil { + // Legacy direct-to-Telegram nodes no longer get gateway-side summary injection + // (Path B routes telegram through /api/notify). Warn so the no-op is debuggable. + r.vm.logger.Warn("REST API summarize on legacy direct-Telegram node — re-save the workflow in Studio to route through /api/notify") } } @@ -810,7 +759,6 @@ func (r *RestProcessor) Execute(stepID string, node *avsproto.RestAPINode) (*avs // Parse response body var bodyData interface{} bodyStr := string(response.Body()) - statusSuccess := response.StatusCode() < 400 if bodyStr != "" { var jsonData interface{} if err := json.Unmarshal(response.Body(), &jsonData); err == nil { @@ -834,34 +782,10 @@ func (r *RestProcessor) Execute(stepID string, node *avsproto.RestAPINode) (*avs bodyData = bodyStr } } else { - // If the provider didn't return a body (common for notification providers with 202 Accepted), - // surface the composed summary in the server response so clients have subject/body to display. - // For single-node notification calls, derive success from the HTTP response status. - if summaryForClient != nil { - // Build a summary for clients based on HTTP response status - var subj string - var bod string - if statusSuccess { - subj = summaryForClient.Subject - bod = summaryForClient.Body - } else { - subj = summaryForClient.Subject - if strings.TrimSpace(summaryForClient.Body) != "" { - bod = summaryForClient.Body - } else { - bod = fmt.Sprintf( - "Smart wallet executed 1 notification step, but the provider returned HTTP %d.\n\nThe email could not be sent.", - response.StatusCode(), - ) - } - } - bodyData = map[string]interface{}{ - "subject": subj, - "body": bod, - } - } else { - bodyData = "" - } + // No body returned (common for notification providers with 202 Accepted). The + // studio-notify summary, when the provider returns one, is extracted from the + // response body above; otherwise there is nothing to surface. + bodyData = "" } // Create standard format response @@ -938,37 +862,6 @@ func detectNotificationProvider(u string) string { return "" } -// buildStyledHTMLEmail wraps a plain-text body into a simple, safe HTML layout -// suitable for email clients. It escapes the input text and preserves paragraph -// breaks by turning double newlines into

blocks and single newlines into
. -func buildStyledHTMLEmail(subject, body string) string { - // Escape HTML to avoid injection - safe := html.EscapeString(body) - // Normalize newlines - safe = strings.ReplaceAll(safe, "\r\n", "\n") - // Split by paragraphs (double newline) - parts := strings.Split(safe, "\n\n") - var paragraphs []string - for _, p := range parts { - if strings.TrimSpace(p) == "" { - continue - } - // Convert single newlines within a paragraph to
- p = strings.ReplaceAll(p, "\n", "
") - paragraphs = append(paragraphs, "

"+p+"

") - } - - content := strings.Join(paragraphs, "\n") - // Minimal, responsive-friendly light theme - return "" + - "" + html.EscapeString(subject) + "" + - "" + - "
" + content + "
" -} - // escapeJSONString properly escapes a string for use within JSON func escapeJSONString(s string) string { // Use Go's built-in JSON marshaling to properly escape the string @@ -1079,55 +972,3 @@ func (r *RestProcessor) preprocessJSONWithVariableMapping(text string) string { } return result } - -// shortHex formats a hex string as 0xABCD…WXYZ for compact display. If too short, returns as-is. -func shortHex(s string) string { - s = strings.TrimSpace(s) - if s == "" { - return s - } - if strings.HasPrefix(s, "0x") { - if len(s) > 10 { // 0x + 4 prefix + … + 4 suffix - return s[:6] + "…" + s[len(s)-4:] - } - return s - } - if len(s) > 8 { - return s[:4] + "…" + s[len(s)-4:] - } - return s -} - -// extractPreheaderFromSummaryText finds the first meaningful line after the -// "Workflow Summary" heading and truncates it for email preheader usage. -func extractPreheaderFromSummaryText(text, fallback string) string { - t := strings.TrimSpace(text) - if t == "" { - return fallback - } - lines := strings.Split(t, "\n") - for _, ln := range lines { - s := strings.TrimSpace(ln) - if s == "" { - continue - } - if strings.EqualFold(s, "Summary") { - continue - } - // Truncate to ~180 chars to fit preheader best practices - if len(s) > 180 { - return s[:177] + "..." - } - return s - } - return fallback -} - -// getMapKeys returns keys from a map[string]struct{} for logging -func getMapKeys(m map[string]struct{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - return keys -} diff --git a/core/taskengine/vm_runner_rest_sendgrid_integration_test.go b/core/taskengine/vm_runner_rest_sendgrid_integration_test.go deleted file mode 100644 index dca62645..00000000 --- a/core/taskengine/vm_runner_rest_sendgrid_integration_test.go +++ /dev/null @@ -1,260 +0,0 @@ -//go:build integration -// +build integration - -package taskengine - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "github.com/AvaProtocol/EigenLayer-AVS/core/testutil" - "github.com/AvaProtocol/EigenLayer-AVS/model" - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "google.golang.org/protobuf/types/known/structpb" -) - -// TestSendGridEmailWithContextMemoryResponse tests that the context-memory API response -// is correctly used to send a SendGrid email with the proper subject and summary fields. -// This test uses the actual response format from the context-memory API. -func TestSendGridEmailWithContextMemoryResponse(t *testing.T) { - // Load test config from test.yaml - // Use GetTestRPC() to ensure config is loaded (it calls loadTestConfigOnce internally) - _ = testutil.GetTestRPC() - testConfig := testutil.GetTestConfig() - if testConfig == nil { - t.Fatal("Test config must be loaded from test.yaml") - } - - // Extract SendGrid API key from config - sendgridKey := "" - if testConfig.MacroSecrets != nil { - if key, ok := testConfig.MacroSecrets["sendgrid_key"]; ok { - sendgridKey = key - } - } - if sendgridKey == "" { - t.Skip("sendgrid_key not found in config, skipping SendGrid integration test") - } - - // Mock context-memory API server that returns the exact response from terminal output - contextMemoryResponse := map[string]interface{}{ - "subject": "Simulation: Test Stoploss successfully completed", - "summary": "Your workflow 'Test Stoploss' executed 8 out of 8 total steps", - "analysisHtml": "

What Executed On-Chain

✓ Approved 20,990,000 to 0x3bFA...e48E for trading

✓ (Simulated) Swapped for ~1.8729 via Uniswap V3

", - "body": "What Executed On-Chain\n✓ Approved 20,990,000 to 0x3bFA...e48E for trading\n✓ (Simulated) Swapped for ~1.8729 via Uniswap V3", - "statusHtml": "
All steps completed successfully
", - "status": "success", - "promptVersion": "v1.1.0", - "branchSummary": map[string]interface{}{ - "text": "Summary\nAll nodes were executed successfully.\nExecuted 2 on-chain transaction(s).\nWhat Executed Successfully\n✓ Approved token to \n✓ Swapped for ~0.001872 via Uniswap V3 (exactInputSingle)", - }, - } - - contextMemoryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("expected POST request to context-memory API, got %s", r.Method) - } - if !strings.Contains(r.URL.Path, "/api/summarize") { - t.Errorf("expected /api/summarize path, got %s", r.URL.Path) - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if err := json.NewEncoder(w).Encode(contextMemoryResponse); err != nil { - t.Errorf("failed to encode context-memory response: %v", err) - } - })) - defer contextMemoryServer.Close() - - // Use real SendGrid API endpoint for integration testing - sendgridAPIURL := "https://api.sendgrid.com/v3/mail/send" - - // Set up global summarizer from config, but override baseURL to use mock server - // Create a modified config with mock server URL in macros.secrets - mockConfig := *testConfig - if mockConfig.MacroSecrets == nil { - mockConfig.MacroSecrets = make(map[string]string) - } - // Copy existing secrets - for k, v := range testConfig.MacroSecrets { - mockConfig.MacroSecrets[k] = v - } - // Override context_api_endpoint to use mock server - mockConfig.MacroSecrets["context_api_endpoint"] = contextMemoryServer.URL - summarizer, err := NewContextMemorySummarizerFromAggregatorConfig(&mockConfig) - if err != nil { - t.Fatalf("Failed to create context-memory summarizer from config: %v", err) - } - if summarizer == nil { - t.Fatal("Failed to create context-memory summarizer from config") - } - SetSummarizer(summarizer) - defer SetSummarizer(nil) // Clean up after test - - // Set global macro secrets so template variables like {{apContext.configVars.sendgrid_key}} can be resolved - if testConfig.MacroSecrets != nil { - SetMacroSecrets(testConfig.MacroSecrets) - defer SetMacroSecrets(nil) // Clean up after test - } - - // Create Options with summarize=true - optsVal, err := structpb.NewValue(map[string]interface{}{"summarize": true}) - if err != nil { - t.Fatalf("failed to create Options value: %v", err) - } - - // Extract secrets from config - secrets := make(map[string]string) - if testConfig.MacroSecrets != nil { - for k, v := range testConfig.MacroSecrets { - secrets[k] = v - } - } - - // Create a minimal VM with settings that match the test scenario - trigger := &avsproto.TaskTrigger{ - Id: "trigger1", - Name: "trigger1", - } - vm, err := NewVMWithData(&model.Workflow{ - Task: &avsproto.Task{ - Id: "test-task", - Trigger: trigger, - Nodes: []*avsproto.TaskNode{ - { - Id: "email1", - Name: "email1", - TaskType: &avsproto.TaskNode_RestApi{ - RestApi: &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: sendgridAPIURL, - Method: "POST", - Headers: map[string]string{ - "Authorization": "Bearer {{apContext.configVars.sendgrid_key}}", - "Content-Type": "application/json", - }, - Body: `{ - "from": { - "email": "notifications@avaprotocol.org", - "name": "AP Studio Notification" - }, - "personalizations": [{ - "to": [{ - "email": "dev@avaprotocol.org" - }] - }] - }`, - Options: optsVal, - }, - }, - }, - }, - }, - Edges: []*avsproto.TaskEdge{}, - }, - }, nil, testutil.GetTestSmartWalletConfig(), secrets) - if err != nil { - t.Fatalf("failed to create VM: %v", err) - } - - // Set up VM variables to match the test scenario - vm.mu.Lock() - vm.vars["aa_sender"] = "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f" - vm.vars["settings"] = map[string]interface{}{ - "name": "Test Stoploss", - "isSimulation": true, - "runner": "0x5d814Cc9E94B2656f59Ee439D44AA1b6ca21434f", - } - vm.mu.Unlock() - - // Add some execution logs to simulate a workflow execution - vm.mu.Lock() - vm.ExecutionLogs = []*avsproto.Execution_Step{ - { - Id: "01KAMATPAVF34V03HACRMVWN23", - Name: "eventTrigger", - Type: "TRIGGER_TYPE_EVENT", - Success: true, - }, - { - Id: "01KAMAWC7FHYTBBKXE495EMCMH", - Name: "balance1", - Type: "NODE_TYPE_BALANCE", - Success: true, - }, - { - Id: "01KAMAY4JNJ3GSQE6BF4CM663B", - Name: "code1", - Type: "NODE_TYPE_CUSTOM_CODE", - Success: true, - }, - { - Id: "01KAMBKSM3XEGH8Z8S6R5S0MWC", - Name: "branch1", - Type: "NODE_TYPE_BRANCH", - Success: true, - }, - { - Id: "01KAMC4STMZAVH2QR236BV7W3Y", - Name: "approve1", - Type: "NODE_TYPE_CONTRACT_WRITE", - Success: true, - }, - { - Id: "01KAMBMJY33EKE372E6FA28QPV", - Name: "contractRead1", - Type: "NODE_TYPE_CONTRACT_READ", - Success: true, - }, - { - Id: "01KCHPTWWC3NWQCK782JKF7M85", - Name: "calc_slippage", - Type: "NODE_TYPE_CUSTOM_CODE", - Success: true, - }, - { - Id: "01KAMC9PVJYZGH8K0KEFEY76TX", - Name: "contractWrite1", - Type: "NODE_TYPE_CONTRACT_WRITE", - Success: true, - }, - } - vm.mu.Unlock() - - // Execute the REST API node - processor := NewRestProcessor(vm) - node := vm.TaskNodes["email1"] - if node == nil { - t.Fatal("node email1 not found") - } - restApiNode := node.GetRestApi() - if restApiNode == nil { - t.Fatal("node email1 is not a REST API node") - } - step, err := processor.Execute("email1", restApiNode) - if err != nil { - t.Fatalf("failed to execute REST API node: %v", err) - } - - // The production code handles the SendGrid API call and response parsing - // If step.Success is true, the email was sent successfully - if !step.Success { - // Handle specific error cases - if strings.Contains(step.Error, "403") || strings.Contains(step.Error, "Forbidden") { - t.Skipf("SendGrid returned 403 Forbidden - likely sender email not verified. Error: %s. Skipping test.", step.Error) - } - t.Fatalf("REST API node execution failed: %s", step.Error) - } - - // Verify expected values that should be in the email - expectedSubject := "Simulation: Test Stoploss successfully completed" - expectedSummary := "Your workflow 'Test Stoploss' executed 8 out of 8 total steps" - - t.Logf("✅ SendGrid email sent successfully to dev@avaprotocol.org") - t.Logf(" Expected Subject: %q", expectedSubject) - t.Logf(" Expected Summary: %q", expectedSummary) - t.Logf(" Note: Check dev@avaprotocol.org inbox to verify email content matches expected values") -} diff --git a/core/taskengine/vm_runner_rest_test.go b/core/taskengine/vm_runner_rest_test.go index 5f012984..4def9943 100644 --- a/core/taskengine/vm_runner_rest_test.go +++ b/core/taskengine/vm_runner_rest_test.go @@ -13,7 +13,6 @@ import ( "github.com/AvaProtocol/EigenLayer-AVS/model" "github.com/AvaProtocol/EigenLayer-AVS/pkg/gow" avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" - "google.golang.org/protobuf/types/known/structpb" ) func TestRestRequest(t *testing.T) { @@ -863,220 +862,6 @@ func TestRestRequestSendGridGlobalSecret(t *testing.T) { t.Logf("✅ Verified global secret access via {{apContext.configVars.sendgrid_key}} template") } -func TestRestSummarizeTelegramHTML(t *testing.T) { - // Expect composed subject/body - expectedWorkflowName := "Using sdk test wallet" - - // Mock Telegram endpoint that inspects incoming request - telegramServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("expected POST request, got %s", r.Method) - } - if !strings.Contains(strings.ToLower(r.URL.Path), "/sendmessage") { - t.Errorf("expected /sendMessage path, got: %s", r.URL.Path) - } - // Read JSON body - var req map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Fatalf("failed to decode telegram request body: %v", err) - } - // Verify parse_mode and text - if pm, _ := req["parse_mode"].(string); pm != "HTML" { - t.Errorf("expected parse_mode=HTML, got: %v", pm) - } - text, _ := req["text"].(string) - t.Logf("telegram composed text: %q", text) - // Verify that summarization happened - check for HTML formatting and key workflow info - if !strings.Contains(text, "") || !strings.Contains(text, "") { - t.Errorf("telegram text missing HTML bold tags, summarization may not have occurred. got: %q", text) - } - if !strings.Contains(text, expectedWorkflowName) { - t.Errorf("telegram text missing workflow name %q. got: %q", expectedWorkflowName, text) - } - if !strings.Contains(strings.ToLower(text), "succeed") { - t.Errorf("telegram text missing success indicator. got: %q", text) - } - if text == "placeholder" { - t.Errorf("telegram text was not replaced by summary, still shows placeholder") - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "result": map[string]interface{}{"message_id": 1}}) - })) - defer telegramServer.Close() - - // Node with summarize=true via Config.Options - optsVal, _ := structpb.NewValue(map[string]interface{}{"summarize": true}) - node := &avsproto.RestAPINode{ - Config: &avsproto.RestAPINode_Config{ - Url: telegramServer.URL + "/botX/sendMessage", - Headers: map[string]string{"Content-Type": "application/json"}, - Body: `{"chat_id": 1, "text": "placeholder"}`, - Method: "POST", - Options: optsVal, - }, - } - - nodes := []*avsproto.TaskNode{{ - Id: "tg-sum", - Name: "restApi", - TaskType: &avsproto.TaskNode_RestApi{RestApi: node}, - }} - trigger := &avsproto.TaskTrigger{Id: "trig", Name: "trig"} - edges := []*avsproto.TaskEdge{{Id: "e1", Source: trigger.Id, Target: "tg-sum"}} - - vm, err := NewVMWithData(&model.Workflow{Task: &avsproto.Task{Id: "tg-sum", Nodes: nodes, Edges: edges, Trigger: trigger}}, nil, testutil.GetTestSmartWalletConfig(), nil) - if err != nil { - t.Fatalf("failed to create VM: %v", err) - } - - // Provide settings.name and a last successful step - vm.AddVar("settings", map[string]interface{}{"name": expectedWorkflowName}) - vm.ExecutionLogs = append(vm.ExecutionLogs, &avsproto.Execution_Step{Name: "run_swap", Success: true}) - - processor := NewRestProcessor(vm) - step, err := processor.Execute("tg-sum", node) - if err != nil { - t.Fatalf("expected success, got error: %v", err) - } - if !step.Success { - t.Fatalf("expected step success, got failure: %s", step.Error) - } -} - -func TestRestSummarizeSendGridInjection(t *testing.T) { - // TODO: This test requires the summarization feature to be fully implemented - // The summarize option should inject summary, statusHtml, and analysisHtml into - // SendGrid dynamic_template_data, but this feature may not be complete yet. - // Skipping until summarization is implemented in the REST API processor. - t.Skip("Skipping until summarization feature is fully implemented") - - expectedWorkflowName := "Using sdk test wallet" - - // Mock SendGrid endpoint; verify subject and dynamicTemplateData - sendgridServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("expected POST, got %s", r.Method) - } - if !strings.Contains(r.URL.Path, "/v3/mail/send") && !strings.Contains(r.URL.Path, "/mail/send") { - t.Errorf("expected /v3/mail/send path, got: %s", r.URL.Path) - } - var req map[string]interface{} - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - t.Fatalf("failed to decode sendgrid request: %v", err) - } - - // Verify template_id is injected - templateID, _ := req["template_id"].(string) - if templateID == "" { - t.Error("expected template_id to be set") - } - t.Logf("sendgrid template_id: %q", templateID) - - // Verify subject at top level - subj, _ := req["subject"].(string) - t.Logf("sendgrid top-level subject: %q", subj) - - // With new changes, top-level subject may be from original body (placeholder) - // The real subject is in dynamic_template_data - // So we check dynamic_template_data subject instead - - // Verify dynamic_template_data in personalizations - pers, ok := req["personalizations"].([]interface{}) - if !ok || len(pers) == 0 { - t.Fatal("expected personalizations array") - } - - firstPers, ok := pers[0].(map[string]interface{}) - if !ok { - t.Fatal("personalizations[0] should be an object") - } - - // With new changes, we no longer set subject in personalizations or top-level - // Subject comes from dynamic_template_data only (for template {{{subject}}}) - // So we don't check for persSubj or top-level subject matching - - // Check dynamic_template_data - dynamicData, ok := firstPers["dynamic_template_data"].(map[string]interface{}) - if !ok { - t.Fatal("expected dynamic_template_data in personalizations[0]") - } - - // Verify new variables are present (fields may be empty if summarization is not configured) - // Just check that the keys exist in dynamic_template_data - if _, exists := dynamicData["analysisHtml"]; !exists { - t.Error("analysisHtml key should exist in dynamic_template_data") - } - if _, exists := dynamicData["summary"]; !exists { - t.Error("summary key should exist in dynamic_template_data") - } - if _, exists := dynamicData["statusHtml"]; !exists { - t.Error("statusHtml key should exist in dynamic_template_data") - } - - // Verify subject in dynamic_template_data (new location for subject) - dtdSubject, _ := dynamicData["subject"].(string) - if dtdSubject == "" { - t.Error("subject in dynamic_template_data should not be empty") - } - // Verify subject contains workflow name and execution indicator - if !strings.Contains(dtdSubject, expectedWorkflowName) { - t.Errorf("dynamic_template_data subject missing workflow name. want to contain %q got %q", expectedWorkflowName, dtdSubject) - } - subjLower := strings.ToLower(dtdSubject) - if !strings.Contains(subjLower, "succeeded") && !strings.Contains(subjLower, "successfully") && !strings.Contains(subjLower, "partially") && !strings.Contains(subjLower, "failed") { - t.Errorf("subject missing execution status (succeeded/successfully/partially/failed). got %q", dtdSubject) - } - - // runner and eoaAddress keys should exist (may be empty strings depending on VM state) - if _, exists := dynamicData["runner"]; !exists { - t.Error("runner key missing in dynamic_template_data") - } - if _, exists := dynamicData["eoaAddress"]; !exists { - t.Error("eoaAddress key missing in dynamic_template_data") - } - - // Verify no content array (should be removed for Dynamic Templates) - if _, hasContent := req["content"]; hasContent { - t.Error("content array should be removed when using Dynamic Templates") - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"message": "accepted"}) - })) - defer sendgridServer.Close() - - optsVal, _ := structpb.NewValue(map[string]interface{}{"summarize": true}) - node := &avsproto.RestAPINode{Config: &avsproto.RestAPINode_Config{ - Url: sendgridServer.URL + "/v3/mail/send", - Headers: map[string]string{"Content-Type": "application/json"}, - Body: `{"personalizations":[{"to":[{"email":"user@example.com"}]}],"from":{"email":"noreply@example.com"},"subject":"placeholder","content":[{"type":"text/plain","value":"placeholder"}]}`, - Method: "POST", - Options: optsVal, - }} - - nodes := []*avsproto.TaskNode{{Id: "sg-sum", Name: "restApi", TaskType: &avsproto.TaskNode_RestApi{RestApi: node}}} - trigger := &avsproto.TaskTrigger{Id: "trig", Name: "trig"} - edges := []*avsproto.TaskEdge{{Id: "e1", Source: trigger.Id, Target: "sg-sum"}} - vm, err := NewVMWithData(&model.Workflow{Task: &avsproto.Task{Id: "sg-sum", Nodes: nodes, Edges: edges, Trigger: trigger}}, nil, testutil.GetTestSmartWalletConfig(), nil) - if err != nil { - t.Fatalf("failed to create VM: %v", err) - } - vm.AddVar("settings", map[string]interface{}{"name": expectedWorkflowName, "owner": "0x804e49e8C4eDb560AE7c48B554f6d2e27Bb81557"}) - vm.ExecutionLogs = append(vm.ExecutionLogs, &avsproto.Execution_Step{Name: "run_swap", Success: true}) - - processor := NewRestProcessor(vm) - step, err := processor.Execute("sg-sum", node) - if err != nil { - t.Fatalf("expected success, got error: %v", err) - } - if !step.Success { - t.Fatalf("expected step success, got failure: %s", step.Error) - } -} - func TestRestRequestArbitraryGlobalSecret(t *testing.T) { // This test demonstrates that ANY variable name can be used in config // without being defined anywhere in the source code diff --git a/migrations/chain_scoped_keys.go b/migrations/chain_scoped_keys.go index d72ecf27..7b486387 100644 --- a/migrations/chain_scoped_keys.go +++ b/migrations/chain_scoped_keys.go @@ -5,9 +5,9 @@ import ( "strings" "github.com/AvaProtocol/EigenLayer-AVS/core/migrator" + avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" "github.com/AvaProtocol/EigenLayer-AVS/storage" storageschema "github.com/AvaProtocol/EigenLayer-AVS/storage/schema" - avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf" ) // MigrateKeysToChainScoped rewrites legacy single-chain storage keys to the @@ -16,9 +16,9 @@ import ( // // Legacy → chain-scoped: // -// t:{status}:{taskID} → t:{chainID}:{status}:{taskID} -// u:{owner}:{wallet}:{taskID} → u:{chainID}:{owner}:{wallet}:{taskID} -// history:{taskID}:{executionID} → history:{chainID}:{taskID}:{executionID} +// t:{status}:{taskID} → t:{chainID}:{status}:{taskID} +// u:{owner}:{wallet}:{taskID} → u:{chainID}:{owner}:{wallet}:{taskID} +// history:{taskID}:{executionID} → history:{chainID}:{taskID}:{executionID} // // The migration is idempotent: re-running on a partially-migrated DB skips // keys that are already in chain-scoped form and only rewrites the legacy diff --git a/migrations/chain_scoped_keys_test.go b/migrations/chain_scoped_keys_test.go index df3adfb1..97f7a86b 100644 --- a/migrations/chain_scoped_keys_test.go +++ b/migrations/chain_scoped_keys_test.go @@ -20,7 +20,7 @@ func TestMigrateKeysToChainScoped(t *testing.T) { "t:c:task-completed-1": []byte(`{"id":"task-completed-1"}`), "t:i:task-disabled-1": []byte(`{"id":"task-disabled-1"}`), // u: user-tasks - "u:0xowner1:0xwalletA:task-enabled-1": []byte("ref"), + "u:0xowner1:0xwalletA:task-enabled-1": []byte("ref"), "u:0xowner2:0xwalletB:task-completed-1": []byte("ref"), // history: executions "history:task-enabled-1:exec-1": []byte(`{"id":"exec-1"}`), @@ -86,9 +86,9 @@ func TestMigrateKeysToChainScoped_Idempotent(t *testing.T) { const chainID = int64(8453) seed := map[string][]byte{ - "t:a:task-1": []byte("v"), - "history:task-1:exec-1": []byte("v"), - "u:0xowner:0xwallet:task-1": []byte("ref"), + "t:a:task-1": []byte("v"), + "history:task-1:exec-1": []byte("v"), + "u:0xowner:0xwallet:task-1": []byte("ref"), } if err := db.BatchWrite(toBatch(seed)); err != nil { t.Fatalf("seed: %v", err) diff --git a/migrations/migrations.go b/migrations/migrations.go index 3ac75d3a..298ba690 100644 --- a/migrations/migrations.go +++ b/migrations/migrations.go @@ -35,4 +35,11 @@ var Migrations = []migrator.Migration{ // ACTIVE MIGRATIONS // ======================================== // Add new migrations here that need to be applied + { + // G5: a task no longer carries a chain. Storage moved from the + // chain-bucketed schema (t:::) to chain-agnostic + // keys (t::). This deletes the now-orphaned old rows. + Name: "20260626-wipe-chain-bucketed-task-keys", + Function: WipeChainBucketedTaskKeys, + }, } diff --git a/migrations/wipe_chain_bucketed_task_keys.go b/migrations/wipe_chain_bucketed_task_keys.go new file mode 100644 index 00000000..ef54d063 --- /dev/null +++ b/migrations/wipe_chain_bucketed_task_keys.go @@ -0,0 +1,67 @@ +package migrations + +import ( + "fmt" + "strconv" + "strings" + + "github.com/AvaProtocol/EigenLayer-AVS/storage" +) + +// WipeChainBucketedTaskKeys deletes task-related rows written under the old +// chain-bucketed key schema, retired by G5 (see PLAN_CHAIN_DECOUPLING.md). +// +// Before G5, a task carried a chain_id and storage keyed every row by it: +// +// t::: (workflow) +// u:::: (user index) +// history::: (execution) +// +// G5 removed the task-level chain — a task belongs to no chain; each +// chain-aware trigger/node carries its own. Storage is now chain-agnostic: +// +// t:: u::: history:: +// +// The new readers never look under the old keys, so any pre-G5 row is dead. +// We do NOT backfill them onto the new schema — re-deriving a chain on a task's +// behalf is guesswork, and the deployed set is tiny. Owners re-create affected +// workflows with explicit per-part chains. This migration removes the orphans. +// +// Detection is unambiguous: the segment immediately after the prefix is a +// chain_id (a plain integer) in the OLD schema, but a status letter (t:), +// a 0x-prefixed address (u:), or a ULID (history:) in the NEW schema — none of +// which parse as an int64. So "first segment parses as int64" == old key. +// +// Safety: the migrator takes a full DB backup before running and records +// completion so this runs exactly once; re-running finds nothing. +func WipeChainBucketedTaskKeys(db storage.Storage) (int, error) { + var toDelete [][]byte + for _, prefix := range []string{"t:", "u:", "history:"} { + err := db.IterateKeysOnly([]byte(prefix), func(key []byte) error { + parts := strings.SplitN(string(key), ":", 3) + if len(parts) < 3 { + return nil + } + // parts[1] is the first segment after the prefix. Only the old + // chain-bucketed schema has a plain integer here. + if _, err := strconv.ParseInt(parts[1], 10, 64); err != nil { + return nil + } + // Key bytes are iterator-owned — copy before retaining. + toDelete = append(toDelete, append([]byte{}, key...)) + return nil + }) + if err != nil { + return 0, fmt.Errorf("scan %q: %w", prefix, err) + } + } + + deleted := 0 + for _, key := range toDelete { + if err := db.Delete(key); err != nil { + return deleted, fmt.Errorf("delete %q: %w", string(key), err) + } + deleted++ + } + return deleted, nil +} diff --git a/migrations/wipe_chain_bucketed_task_keys_test.go b/migrations/wipe_chain_bucketed_task_keys_test.go new file mode 100644 index 00000000..4391b2db --- /dev/null +++ b/migrations/wipe_chain_bucketed_task_keys_test.go @@ -0,0 +1,57 @@ +package migrations + +import ( + "testing" + + "github.com/AvaProtocol/EigenLayer-AVS/storage" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWipeChainBucketedTaskKeys(t *testing.T) { + db, err := storage.NewWithPath(t.TempDir()) + require.NoError(t, err) + defer storage.Destroy(db.(*storage.BadgerStorage)) + + // OLD chain-bucketed rows (chain segment after the prefix) — must be deleted. + old := [][]byte{ + []byte("t:11155111:a:01abc"), + []byte("t:1:i:01def"), + []byte("u:8453:0xowner:0xwallet:01abc"), + []byte("history:1:01abc:01exec"), + } + // NEW chain-agnostic rows — must survive. + keep := [][]byte{ + []byte("t:a:01abc"), + []byte("t:i:01def"), + []byte("u:0xowner:0xwallet:01abc"), + []byte("history:01abc:01exec"), + } + // Unrelated namespaces — untouched. + other := [][]byte{ + []byte("w:1:0xowner:0xwallet"), // wallet keys stay per-chain + []byte("secret:0xowner:foo"), + } + + for _, k := range append(append(append([][]byte{}, old...), keep...), other...) { + require.NoError(t, db.Set(k, []byte("1"))) + } + + n, err := WipeChainBucketedTaskKeys(db) + require.NoError(t, err) + assert.Equal(t, len(old), n, "should delete exactly the old chain-bucketed rows") + + for _, k := range old { + exists, _ := db.Exist(k) + assert.False(t, exists, "old key must be deleted: %s", k) + } + for _, k := range append(append([][]byte{}, keep...), other...) { + exists, _ := db.Exist(k) + assert.True(t, exists, "key must survive: %s", k) + } + + // Idempotent: a second run finds nothing. + n2, err := WipeChainBucketedTaskKeys(db) + require.NoError(t, err) + assert.Equal(t, 0, n2) +} diff --git a/model/workflow.go b/model/workflow.go index 23bc7b73..f88bbb10 100644 --- a/model/workflow.go +++ b/model/workflow.go @@ -224,7 +224,6 @@ func NewWorkflowFromProtobuf(user *User, body *avsproto.CreateTaskReq) (*Workflo StartAt: body.StartAt, MaxExecution: body.MaxExecution, InputVariables: body.InputVariables, // Contains enriched settings - ChainId: body.ChainId, // Propagate chain_id from request (0 = default chain) // initial state for task Status: avsproto.TaskStatus_Enabled, diff --git a/operator/worker_loop.go b/operator/worker_loop.go index 168f1d5a..9cdf008a 100644 --- a/operator/worker_loop.go +++ b/operator/worker_loop.go @@ -1052,15 +1052,18 @@ func (o *Operator) StreamMessages() { continue } - taskChainID := resp.TaskMetadata.GetChainId() + // TaskMetadata.ChainId is the trigger's MONITORING chain (G2), + // set by the aggregator from the event/block trigger's own + // config — which may differ from where the task's nodes act. + monitorChainID := resp.TaskMetadata.GetChainId() if trigger := triggerObj.GetEvent(); trigger != nil { - o.logger.Info("📥 Monitoring event trigger", "task_id", resp.Id, "chain_id", taskChainID) + o.logger.Info("📥 Monitoring event trigger", "task_id", resp.Id, "chain_id", monitorChainID) - set, ok := o.triggersForChain(taskChainID) + set, ok := o.triggersForChain(monitorChainID) if !ok { o.logger.Warn("⚠️ Dropping event task — operator does not monitor this chain", - "task_id", resp.Id, "chain_id", taskChainID, + "task_id", resp.Id, "chain_id", monitorChainID, "supported", o.supportedChainIDs()) continue } @@ -1080,12 +1083,12 @@ func (o *Operator) StreamMessages() { } }() } else if trigger := triggerObj.GetBlock(); trigger != nil { - o.logger.Info("📦 Monitoring block trigger", "task_id", resp.Id, "chain_id", taskChainID, "interval", trigger.Config.GetInterval()) + o.logger.Info("📦 Monitoring block trigger", "task_id", resp.Id, "chain_id", monitorChainID, "interval", trigger.Config.GetInterval()) - set, ok := o.triggersForChain(taskChainID) + set, ok := o.triggersForChain(monitorChainID) if !ok { o.logger.Warn("⚠️ Dropping block task — operator does not monitor this chain", - "task_id", resp.Id, "chain_id", taskChainID, + "task_id", resp.Id, "chain_id", monitorChainID, "supported", o.supportedChainIDs()) continue } diff --git a/protobuf/avs.pb.go b/protobuf/avs.pb.go index f06d5df5..e926bb2f 100644 --- a/protobuf/avs.pb.go +++ b/protobuf/avs.pb.go @@ -2247,9 +2247,6 @@ type Task struct { // These variables are available globally to all nodes during execution // and can be referenced using JavaScript template syntax like ${variableName} InputVariables map[string]*structpb.Value `protobuf:"bytes,15,rep,name=input_variables,json=inputVariables,proto3" json:"input_variables,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Target chain for this task's execution (e.g., 11155111 for Sepolia, 84532 for Base Sepolia). - // 0 means use the aggregator's default chain (backward compatible with single-chain mode). - ChainId int64 `protobuf:"varint,16,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` // last_validation_error is the most recent message from a validation // rejection (e.g. "task smart wallet address does not belong to owner"). // Empty when the task has never been rejected, or when a later execution @@ -2401,13 +2398,6 @@ func (x *Task) GetInputVariables() map[string]*structpb.Value { return nil } -func (x *Task) GetChainId() int64 { - if x != nil { - return x.ChainId - } - return 0 -} - func (x *Task) GetLastValidationError() string { if x != nil { return x.LastValidationError @@ -2438,11 +2428,8 @@ type CreateTaskReq struct { // These variables will be available globally to all nodes during execution // and can be referenced using JavaScript template syntax like ${variableName} InputVariables map[string]*structpb.Value `protobuf:"bytes,9,rep,name=input_variables,json=inputVariables,proto3" json:"input_variables,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - // Target chain for execution (e.g., 11155111 for Sepolia, 84532 for Base Sepolia). - // 0 means use the aggregator's default chain (backward compatible with single-chain mode). - ChainId int64 `protobuf:"varint,10,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CreateTaskReq) Reset() { @@ -2538,13 +2525,6 @@ func (x *CreateTaskReq) GetInputVariables() map[string]*structpb.Value { return nil } -func (x *CreateTaskReq) GetChainId() int64 { - if x != nil { - return x.ChainId - } - return 0 -} - type CreateTaskResp struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -7236,7 +7216,7 @@ func (x *CronTrigger_Output) GetData() *structpb.Value { type BlockTrigger_Config struct { state protoimpl.MessageState `protogen:"open.v1"` Interval int64 `protobuf:"varint,1,opt,name=interval,proto3" json:"interval,omitempty"` - // Chain to watch blocks on. 0 = inherit task's chain_id. + // Chain to watch blocks on. Required: a task carries no chain to inherit. ChainId int64 `protobuf:"varint,2,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -7517,7 +7497,7 @@ type EventTrigger_Config struct { // Default: 300 (5 minutes cooldown - prevents repeated firing when conditions remain true) // Set to 0 to disable cooldown (triggers fire immediately when conditions match) CooldownSeconds *uint32 `protobuf:"varint,2,opt,name=cooldown_seconds,json=cooldownSeconds,proto3,oneof" json:"cooldown_seconds,omitempty"` - // Chain to watch events on. 0 = inherit task's chain_id. + // Chain to watch events on. Required: a task carries no chain to inherit. ChainId int64 `protobuf:"varint,3,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -7740,7 +7720,7 @@ type ETHTransferNode_Config struct { state protoimpl.MessageState `protogen:"open.v1"` Destination string `protobuf:"bytes,1,opt,name=destination,proto3" json:"destination,omitempty"` Amount string `protobuf:"bytes,2,opt,name=amount,proto3" json:"amount,omitempty"` - // Chain to execute the transfer on. 0 = inherit task's chain_id. + // Chain to execute the transfer on. Required: a task carries no chain to inherit. ChainId int64 `protobuf:"varint,3,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -7855,7 +7835,7 @@ type ContractWriteNode_Config struct { Value *string `protobuf:"bytes,6,opt,name=value,proto3,oneof" json:"value,omitempty"` // Custom gas limit for the transaction (as string to handle large numbers) GasLimit *string `protobuf:"bytes,7,opt,name=gas_limit,json=gasLimit,proto3,oneof" json:"gas_limit,omitempty"` - // Chain to execute the write on. 0 = inherit task's chain_id. + // Chain to execute the write on. Required: a task carries no chain to inherit. ChainId int64 `protobuf:"varint,8,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -8228,7 +8208,7 @@ type ContractReadNode_Config struct { ContractAbi []*structpb.Value `protobuf:"bytes,2,rep,name=contract_abi,json=contractAbi,proto3" json:"contract_abi,omitempty"` // Array of method calls to execute serially MethodCalls []*ContractReadNode_MethodCall `protobuf:"bytes,3,rep,name=method_calls,json=methodCalls,proto3" json:"method_calls,omitempty"` - // Chain to read state from. 0 = inherit task's chain_id. + // Chain to read state from. Required: a task carries no chain to inherit. ChainId int64 `protobuf:"varint,4,opt,name=chain_id,json=chainId,proto3" json:"chain_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -10087,7 +10067,7 @@ const file_avs_proto_rawDesc = "" + "\abalance\x18\x1e \x01(\v2\x1e.aggregator.BalanceNode.OutputH\x00R\abalance\x12\x19\n" + "\bstart_at\x18\x0e \x01(\x03R\astartAt\x12\x15\n" + "\x06end_at\x18\x0f \x01(\x03R\x05endAtB\r\n" + - "\voutput_data\"\xb9\x06\n" + + "\voutput_data\"\xa4\x06\n" + "\x04Task\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x05owner\x18\x02 \x01(\tR\x05owner\x120\n" + @@ -10105,13 +10085,12 @@ const file_avs_proto_rawDesc = "" + "\atrigger\x18\f \x01(\v2\x17.aggregator.TaskTriggerR\atrigger\x12*\n" + "\x05nodes\x18\r \x03(\v2\x14.aggregator.TaskNodeR\x05nodes\x12*\n" + "\x05edges\x18\x0e \x03(\v2\x14.aggregator.TaskEdgeR\x05edges\x12M\n" + - "\x0finput_variables\x18\x0f \x03(\v2$.aggregator.Task.InputVariablesEntryR\x0einputVariables\x12\x19\n" + - "\bchain_id\x18\x10 \x01(\x03R\achainId\x122\n" + + "\x0finput_variables\x18\x0f \x03(\v2$.aggregator.Task.InputVariablesEntryR\x0einputVariables\x122\n" + "\x15last_validation_error\x18\x11 \x01(\tR\x13lastValidationError\x12F\n" + "\x1fconsecutive_validation_failures\x18\x12 \x01(\rR\x1dconsecutiveValidationFailures\x1aY\n" + "\x13InputVariablesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12,\n" + - "\x05value\x18\x02 \x01(\v2\x16.google.protobuf.ValueR\x05value:\x028\x01\"\x8d\x04\n" + + "\x05value\x18\x02 \x01(\v2\x16.google.protobuf.ValueR\x05value:\x028\x01J\x04\b\x10\x10\x11\"\xf8\x03\n" + "\rCreateTaskReq\x121\n" + "\atrigger\x18\x01 \x01(\v2\x17.aggregator.TaskTriggerR\atrigger\x12\x19\n" + "\bstart_at\x18\x02 \x01(\x03R\astartAt\x12\x1d\n" + @@ -10122,12 +10101,11 @@ const file_avs_proto_rawDesc = "" + "\x04name\x18\x06 \x01(\tR\x04name\x12*\n" + "\x05nodes\x18\a \x03(\v2\x14.aggregator.TaskNodeR\x05nodes\x12*\n" + "\x05edges\x18\b \x03(\v2\x14.aggregator.TaskEdgeR\x05edges\x12V\n" + - "\x0finput_variables\x18\t \x03(\v2-.aggregator.CreateTaskReq.InputVariablesEntryR\x0einputVariables\x12\x19\n" + - "\bchain_id\x18\n" + - " \x01(\x03R\achainId\x1aY\n" + + "\x0finput_variables\x18\t \x03(\v2-.aggregator.CreateTaskReq.InputVariablesEntryR\x0einputVariables\x1aY\n" + "\x13InputVariablesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12,\n" + - "\x05value\x18\x02 \x01(\v2\x16.google.protobuf.ValueR\x05value:\x028\x01\" \n" + + "\x05value\x18\x02 \x01(\v2\x16.google.protobuf.ValueR\x05value:\x028\x01J\x04\b\n" + + "\x10\v\" \n" + "\x0eCreateTaskResp\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"$\n" + "\fNonceRequest\x12\x14\n" + diff --git a/protobuf/avs.proto b/protobuf/avs.proto index 8a285e07..88e5e932 100644 --- a/protobuf/avs.proto +++ b/protobuf/avs.proto @@ -148,7 +148,7 @@ message CronTrigger { message BlockTrigger { message Config { int64 interval = 1; - // Chain to watch blocks on. 0 = inherit task's chain_id. + // Chain to watch blocks on. Required: a task carries no chain to inherit. int64 chain_id = 2; } @@ -218,7 +218,7 @@ message EventTrigger { // Default: 300 (5 minutes cooldown - prevents repeated firing when conditions remain true) // Set to 0 to disable cooldown (triggers fire immediately when conditions match) optional uint32 cooldown_seconds = 2; - // Chain to watch events on. 0 = inherit task's chain_id. + // Chain to watch events on. Required: a task carries no chain to inherit. int64 chain_id = 3; } @@ -380,7 +380,7 @@ message ETHTransferNode { message Config { string destination = 1; string amount = 2; - // Chain to execute the transfer on. 0 = inherit task's chain_id. + // Chain to execute the transfer on. Required: a task carries no chain to inherit. int64 chain_id = 3; } @@ -406,7 +406,7 @@ message ContractWriteNode { optional string value = 6; // Custom gas limit for the transaction (as string to handle large numbers) optional string gas_limit = 7; - // Chain to execute the write on. 0 = inherit task's chain_id. + // Chain to execute the write on. Required: a task carries no chain to inherit. int64 chain_id = 8; } @@ -451,7 +451,7 @@ message ContractReadNode { repeated google.protobuf.Value contract_abi = 2; // Array of method calls to execute serially repeated MethodCall method_calls = 3; - // Chain to read state from. 0 = inherit task's chain_id. + // Chain to read state from. Required: a task carries no chain to inherit. int64 chain_id = 4; } @@ -812,9 +812,10 @@ message Task { // and can be referenced using JavaScript template syntax like ${variableName} map input_variables = 15; - // Target chain for this task's execution (e.g., 11155111 for Sepolia, 84532 for Base Sepolia). - // 0 means use the aggregator's default chain (backward compatible with single-chain mode). - int64 chain_id = 16; + // Field 16 (chain_id) removed: a task no longer belongs to a chain. Chain + // lives only on chain-aware triggers/nodes (each carries its own chain_id); + // per-chain views are derived from the parts. See PLAN_CHAIN_DECOUPLING.md (G5). + reserved 16; // last_validation_error is the most recent message from a validation // rejection (e.g. "task smart wallet address does not belong to owner"). @@ -853,9 +854,10 @@ message CreateTaskReq { // and can be referenced using JavaScript template syntax like ${variableName} map input_variables = 9; - // Target chain for execution (e.g., 11155111 for Sepolia, 84532 for Base Sepolia). - // 0 means use the aggregator's default chain (backward compatible with single-chain mode). - int64 chain_id = 10; + // Field 10 (chain_id) removed: a task no longer carries a chain. Each + // chain-aware trigger/node specifies its own chain_id (required). See + // PLAN_CHAIN_DECOUPLING.md (G5). + reserved 10; } message CreateTaskResp {