diff --git a/aggregator/rpc_server.go b/aggregator/rpc_server.go index ce803c51..045f86aa 100644 --- a/aggregator/rpc_server.go +++ b/aggregator/rpc_server.go @@ -55,49 +55,6 @@ type RpcServer struct { chainID *big.Int } -// FallbackPriceService provides hardcoded fallback prices when Moralis is unavailable -type FallbackPriceService struct{} - -func newFallbackPriceService() *FallbackPriceService { - return &FallbackPriceService{} -} - -func (fps *FallbackPriceService) GetNativeTokenPriceUSD(chainID int64) (*big.Float, error) { - // Only include chains that the aggregator actually supports: Ethereum and Base - fallbackPrices := map[int64]float64{ - 1: 2500.0, // Ethereum Mainnet - 11155111: 2500.0, // Ethereum Sepolia - 8453: 2500.0, // Base Mainnet - 84532: 2500.0, // Base Sepolia - } - - if price, exists := fallbackPrices[chainID]; exists { - return big.NewFloat(price), nil - } - return big.NewFloat(2500.0), nil // Default ETH price -} - -func (fps *FallbackPriceService) GetNativeTokenSymbol(chainID int64) string { - // Only include chains that the aggregator actually supports: Ethereum and Base - // All supported chains use ETH as the native token - tokenSymbols := map[int64]string{ - 1: "ETH", // Ethereum Mainnet - 11155111: "ETH", // Ethereum Sepolia - 8453: "ETH", // Base Mainnet - 84532: "ETH", // Base Sepolia - } - - if symbol, exists := tokenSymbols[chainID]; exists { - return symbol - } - return "ETH" -} - -// FallbackPriceInfo provides information about fallback pricing for logging -func (fps *FallbackPriceService) FallbackPriceInfo() string { - return "using conservative ETH price of $2500" -} - // Get nonce of an existing smart wallet of a given owner func (r *RpcServer) GetWallet(ctx context.Context, payload *avsproto.GetWalletReq) (*avsproto.GetWalletResp, error) { user, err := r.verifyAuth(ctx) @@ -1191,23 +1148,15 @@ func (r *RpcServer) EstimateFees(ctx context.Context, req *avsproto.EstimateFees return nil, status.Errorf(codes.InvalidArgument, "expire_at must be after created_at") } - // Create price service (Moralis if API key available, otherwise fallback) + // Price service is required for USD-equivalent fee numbers. When Moralis + // isn't configured, callers receive cogs (WEI) and executionFee (USD) as + // raw values without USD-equivalent conversions — and notifications render + // "$?" rather than a fabricated number. var priceService taskengine.PriceService if r.config.MoralisApiKey != "" { priceService = services.GetMoralisService(r.config.MoralisApiKey, r.config.Logger) } else { - priceService = newFallbackPriceService() - var fallbackPriceInfo string - // Try to extract fallback price info for logging - type fallbackPricer interface { - FallbackPriceInfo() string - } - if fp, ok := priceService.(fallbackPricer); ok { - fallbackPriceInfo = fp.FallbackPriceInfo() - } else { - fallbackPriceInfo = "unknown fallback price" - } - r.config.Logger.Warn(fmt.Sprintf("No Moralis API key configured, using fallback price service for fee estimation (%s)", fallbackPriceInfo)) + r.config.Logger.Warn("No Moralis API key configured; fee estimates will lack USD-equivalent conversions and notifications will render $? for token totals") } // Create fee estimator - use configuration-aware version if fee rates are configured diff --git a/aggregator/task_engine.go b/aggregator/task_engine.go index 8df0ea88..65f1a762 100644 --- a/aggregator/task_engine.go +++ b/aggregator/task_engine.go @@ -97,15 +97,17 @@ func (agg *Aggregator) startTaskEngine(ctx context.Context) { agg.logger, ) - // Create price service for fee conversion (USD → ETH) + // Price service for fee conversion (USD → ETH and ERC20 lookups). When + // Moralis isn't configured, it stays nil and callers gracefully degrade + // (notifications render "$?" for unknown prices). var priceService taskengine.PriceService if agg.config.MoralisApiKey != "" { priceService = services.GetMoralisService(agg.config.MoralisApiKey, agg.logger) } else { - priceService = newFallbackPriceService() + agg.logger.Warn("No Moralis API key configured; USD-equivalent fee numbers will be unavailable") } - // Store price service on engine for use in simulation path + // Store price service on engine (nil-safe — engine and summarizer handle absence). agg.engine.SetPriceService(priceService) // Create executor with engine reference for atomic execution indexing diff --git a/core/services/moralis_service.go b/core/services/moralis_service.go index 32edf0b6..6668ca4f 100644 --- a/core/services/moralis_service.go +++ b/core/services/moralis_service.go @@ -193,6 +193,53 @@ func (ms *MoralisService) GetNativeTokenSymbol(chainID int64) string { return "ETH" // Default fallback } +// GetERC20PriceUSD fetches the USD price for an ERC20 contract on the given +// chain. Returns an error when the price isn't available — callers render +// "$?" rather than fabricate a number. No internal $1.00 / fallback. +// +// NOTE: Stablecoin shortcut (taskengine.LookupStablecoinSymbol → $1.00) is +// applied at the caller layer in taskengine, not here, to keep the +// services/taskengine dependency direction correct. +func (ms *MoralisService) GetERC20PriceUSD(chainID int64, contractAddress string) (*big.Float, error) { + if contractAddress == "" { + return nil, fmt.Errorf("contract address required") + } + + moralisChain := ms.chainIDToMoralisChain(chainID) + if moralisChain == "" { + return nil, fmt.Errorf("unsupported chain ID: %d", chainID) + } + + cacheKey := fmt.Sprintf("erc20_%d_%s", chainID, contractAddress) + if cached := ms.getCachedPrice(cacheKey); cached != nil { + return cached.Price, nil + } + + if ms.apiKey == "" { + return nil, fmt.Errorf("moralis API key not configured") + } + + url := fmt.Sprintf("https://deep-index.moralis.io/api/v2.2/erc20/%s/price", contractAddress) + resp, err := ms.httpClient.R(). + SetQueryParams(map[string]string{"chain": moralisChain}). + SetHeader("X-API-Key", ms.apiKey). + SetResult(&MoralisTokenPriceResponse{}). + Get(url) + if err != nil { + return nil, fmt.Errorf("moralis ERC20 price request failed: %w", err) + } + if resp.StatusCode() != 200 { + return nil, fmt.Errorf("moralis ERC20 price returned status %d: %s", resp.StatusCode(), resp.String()) + } + result := resp.Result().(*MoralisTokenPriceResponse) + if result.UsdPrice <= 0 { + return nil, fmt.Errorf("invalid ERC20 price from Moralis: %f", result.UsdPrice) + } + price := big.NewFloat(result.UsdPrice) + ms.setCachedPrice(cacheKey, price, "") + return price, nil +} + // GetPriceDataAge returns the age of cached price data in seconds func (ms *MoralisService) GetPriceDataAge(chainID int64) int64 { cacheKey := fmt.Sprintf("chain_%d", chainID) diff --git a/core/taskengine/blockchain_constants.go b/core/taskengine/blockchain_constants.go index a39df417..332a075a 100644 --- a/core/taskengine/blockchain_constants.go +++ b/core/taskengine/blockchain_constants.go @@ -1,5 +1,7 @@ package taskengine +import "strings" + // Ethereum gas cost constants const ( // StandardGasCost represents the standard gas cost for a simple Ethereum transaction (21000 gas) @@ -25,6 +27,94 @@ const ( DefaultGasPriceHex = "0x1dcd6500" ) +// DefaultGasPriceByChain provides per-chain conservative simulation gas prices (wei). +// Chosen to lean slightly high so simulated cogs don't undershoot real costs. +// Real-time gas pricing is a separate follow-up. +var DefaultGasPriceByChain = map[uint64]uint64{ + 1: 5_000_000_000, // Ethereum Mainnet — 5 gwei + 11155111: 500_000_000, // Ethereum Sepolia — 0.5 gwei + 8453: 50_000_000, // Base — 0.05 gwei + 84532: 10_000_000, // Base Sepolia — 0.01 gwei +} + +// GetDefaultGasPrice returns the per-chain default simulation gas price (wei), +// falling back to DefaultGasPrice (0.5 gwei) for unknown chains. +func GetDefaultGasPrice(chainID uint64) uint64 { + if v, ok := DefaultGasPriceByChain[chainID]; ok { + return v + } + return DefaultGasPrice +} + +// StablecoinInfo carries the display symbol and ERC20 decimals for a stablecoin +// hard-coded as $1.00. Decimals are required to format raw token amounts. +type StablecoinInfo struct { + Symbol string + Decimals uint32 +} + +// Stablecoins maps chain ID → lowercased contract address → StablecoinInfo +// for fully-reserved or strongly-collateralized USD stablecoins. Lookups +// treat each listed address as exactly $1.00 USD without a price-service +// network hop — covers the bulk of real-world value-fee calculation cases. +// Tokens not in this map fall through to PriceService.GetERC20PriceUSD; on +// miss the renderer prints the "$?" placeholder. +// +// Inclusion criteria (high bar — incorrect ≈$1.00 assumptions miscompute fees): +// - Fully reserved by audited issuer (Circle, Paxos, Tether, PayPal, Ripple, +// First Digital, TrueUSD, Gemini), OR +// - Overcollateralized by crypto with strong peg history (DAI, USDS, LUSD, +// sDAI which redeems 1:1 against DAI). +// +// Algorithmic / synthetic / new-untested stablecoins (USDe, USDD, USD1, USDF, +// FRAX) are deliberately excluded — they go through the price service like any +// other ERC20 so a depeg event surfaces correctly. +var Stablecoins = map[uint64]map[string]StablecoinInfo{ + // Ethereum Mainnet + 1: { + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {"USDC", 6}, + "0xdac17f958d2ee523a2206206994597c13d831ec7": {"USDT", 6}, + "0x6b175474e89094c44da98b954eedeac495271d0f": {"DAI", 18}, + "0xdc035d45d973e3ec169d2276ddab16f1e407384f": {"USDS", 18}, + "0x6c3ea9036406852006290770bedfcaba0e23a0e8": {"PYUSD", 6}, + "0x83f20f44975d03b1b09e64809b757c47f942beea": {"sDAI", 18}, + "0xc5f0f7b66764f6ec8c8dff7ba683102295e16409": {"FDUSD", 18}, + "0x0000000000085d4780b73119b644ae5ecd22b376": {"TUSD", 18}, + "0x056fd409e1d7a124bd7017459dfea2f387b6d5cd": {"GUSD", 2}, + "0x5f98805a4e8be255a32880fdec7f6728c6568ba0": {"LUSD", 18}, + "0x8292bb45bf1ee4d140127049757c2e0ff06317ed": {"RLUSD", 18}, + "0xe343167631d89b6ffc58b88d6b7fb0228795491d": {"USDG", 6}, + }, + // Base Mainnet + 8453: { + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": {"USDC", 6}, // Circle native + "0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca": {"USDC", 6}, // bridged (legacy) + "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2": {"USDT", 6}, + "0x50c5725949a6f0c72e6c4a641f24049a917db0cb": {"DAI", 18}, + }, + // Ethereum Sepolia (testnet) + 11155111: { + "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": {"USDC", 6}, // Circle test deployment + "0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0": {"USDT", 6}, + }, + // Base Sepolia (testnet) + 84532: { + "0x036cbd53842c5426634e7929541ec2318f3dcf7e": {"USDC", 6}, // Circle test deployment + }, +} + +// LookupStablecoin returns symbol+decimals for a stablecoin contract, or +// (StablecoinInfo{}, false) if the address isn't in the chain's hard-coded +// $1.00 list. Address matching is case-insensitive. +func LookupStablecoin(chainID uint64, contractAddress string) (StablecoinInfo, bool) { + chainMap, ok := Stablecoins[chainID] + if !ok { + return StablecoinInfo{}, false + } + info, ok := chainMap[strings.ToLower(contractAddress)] + return info, ok +} + // Contract method constants const ( // UnknownMethodName represents a placeholder for contract method names that need to be resolved from ABI diff --git a/core/taskengine/blockchain_constants_test.go b/core/taskengine/blockchain_constants_test.go index 7e047ed3..8fe16321 100644 --- a/core/taskengine/blockchain_constants_test.go +++ b/core/taskengine/blockchain_constants_test.go @@ -150,3 +150,60 @@ func BenchmarkGetChainSearchRanges(b *testing.B) { _ = GetChainSearchRanges(chainID) } } + +func TestGetDefaultGasPrice(t *testing.T) { + cases := []struct { + name string + chainID uint64 + want uint64 + }{ + {"Ethereum Mainnet", 1, 5_000_000_000}, + {"Ethereum Sepolia", 11155111, 500_000_000}, + {"Base", 8453, 50_000_000}, + {"Base Sepolia", 84532, 10_000_000}, + {"Unknown chain falls back to DefaultGasPrice", 99999, DefaultGasPrice}, + {"Zero chain falls back to DefaultGasPrice", 0, DefaultGasPrice}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := GetDefaultGasPrice(tc.chainID); got != tc.want { + t.Errorf("GetDefaultGasPrice(%d) = %d, want %d", tc.chainID, got, tc.want) + } + }) + } +} + +func TestLookupStablecoin(t *testing.T) { + cases := []struct { + name string + chainID uint64 + address string + wantOk bool + wantSym string + wantDec uint32 + }{ + {"USDC mainnet", 1, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", true, "USDC", 6}, + {"USDC mainnet uppercase", 1, "0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48", true, "USDC", 6}, + {"DAI mainnet", 1, "0x6b175474e89094c44da98b954eedeac495271d0f", true, "DAI", 18}, + {"PYUSD mainnet", 1, "0x6c3ea9036406852006290770bedfcaba0e23a0e8", true, "PYUSD", 6}, + {"GUSD has 2 decimals", 1, "0x056fd409e1d7a124bd7017459dfea2f387b6d5cd", true, "GUSD", 2}, + {"USDC base", 8453, "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", true, "USDC", 6}, + {"USDC sepolia (Circle test)", 11155111, "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", true, "USDC", 6}, + {"unknown ERC20 mainnet", 1, "0x0000000000000000000000000000000000000001", false, "", 0}, + {"unknown chain", 99999, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", false, "", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + info, ok := LookupStablecoin(tc.chainID, tc.address) + if ok != tc.wantOk { + t.Errorf("LookupStablecoin(%d, %s) ok = %v, want %v", tc.chainID, tc.address, ok, tc.wantOk) + } + if info.Symbol != tc.wantSym { + t.Errorf("symbol = %q, want %q", info.Symbol, tc.wantSym) + } + if info.Decimals != tc.wantDec { + t.Errorf("decimals = %d, want %d", info.Decimals, tc.wantDec) + } + }) + } +} diff --git a/core/taskengine/engine.go b/core/taskengine/engine.go index 80dfb405..147342e7 100644 --- a/core/taskengine/engine.go +++ b/core/taskengine/engine.go @@ -295,6 +295,10 @@ 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 @@ -374,6 +378,10 @@ 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() { diff --git a/core/taskengine/fee_estimator.go b/core/taskengine/fee_estimator.go index 111e5a1f..0ed0ce63 100644 --- a/core/taskengine/fee_estimator.go +++ b/core/taskengine/fee_estimator.go @@ -34,6 +34,12 @@ type FeeEstimator struct { type PriceService interface { GetNativeTokenPriceUSD(chainID int64) (*big.Float, error) GetNativeTokenSymbol(chainID int64) string + // GetERC20PriceUSD returns the USD price for an ERC20 contract on the + // given chain. Implementations should short-circuit hard-coded stablecoins + // (LookupStablecoinSymbol) before hitting the network. Returns an error + // when the price isn't available — callers render "$?" rather than + // fabricate a number. + GetERC20PriceUSD(chainID int64, contractAddress string) (*big.Float, error) } // FeeRates holds the fee configuration for the estimator. @@ -192,7 +198,12 @@ func (fe *FeeEstimator) EstimateFees(ctx context.Context, req *avsproto.Estimate // Step 3: Value fee — workflow-level classification valueFee := fe.classifyWorkflowValue(req) - nativeTokenSymbol := fe.priceService.GetNativeTokenSymbol(chainID) + nativeTokenSymbol := "ETH" + if fe.priceService != nil { + if sym := fe.priceService.GetNativeTokenSymbol(chainID); sym != "" { + nativeTokenSymbol = sym + } + } fe.logger.Info("✅ Fee estimation completed", "execution_fee_usd", fe.feeRates.ExecutionFeeUSD, diff --git a/core/taskengine/fee_estimator_test.go b/core/taskengine/fee_estimator_test.go index ed9556f8..6d086ab2 100644 --- a/core/taskengine/fee_estimator_test.go +++ b/core/taskengine/fee_estimator_test.go @@ -2,6 +2,7 @@ package taskengine import ( "context" + "fmt" "math/big" "testing" @@ -28,6 +29,11 @@ func (mock *mockPriceService) GetNativeTokenSymbol(chainID int64) string { return "ETH" } +func (mock *mockPriceService) GetERC20PriceUSD(chainID int64, contractAddress string) (*big.Float, error) { + // Test mock — no ERC20 price lookups needed for fee-estimator tests. + return nil, fmt.Errorf("ERC20 price lookup not supported in tests") +} + func TestFeeEstimator_ChainIDDetection(t *testing.T) { logger, err := sdklogging.NewZapLogger(sdklogging.Development) require.NoError(t, err) diff --git a/core/taskengine/summarizer.go b/core/taskengine/summarizer.go index 56b82035..3290d4cf 100644 --- a/core/taskengine/summarizer.go +++ b/core/taskengine/summarizer.go @@ -2,6 +2,7 @@ package taskengine import ( "context" + "net/http" "strings" "time" @@ -33,6 +34,30 @@ 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 @@ -100,7 +125,14 @@ func NewContextMemorySummarizerFromAggregatorConfig(c *config.Config) Summarizer if strings.TrimSpace(authToken) == "" { return nil } - return NewContextMemorySummarizer(baseURL, authToken) + if baseURL == "" { + baseURL = ContextAPIURL + } + return &ContextMemorySummarizer{ + baseURL: baseURL, + authToken: authToken, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } } // FormatForMessageChannels converts a Summary into a concise chat message diff --git a/core/taskengine/summarizer_context_memory.go b/core/taskengine/summarizer_context_memory.go index ea6ef1c3..3df64dd6 100644 --- a/core/taskengine/summarizer_context_memory.go +++ b/core/taskengine/summarizer_context_memory.go @@ -281,16 +281,16 @@ func (c *ContextMemorySummarizer) Summarize(ctx context.Context, vm *VM, current } } - // Convert API response workflow to Summary workflow - var workflow *WorkflowInfo + // 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 = &WorkflowInfo{ - Name: apiResp.Body.Workflow.Name, - Chain: apiResp.Body.Workflow.Chain, - ChainID: apiResp.Body.Workflow.ChainID, - IsSimulation: apiResp.Body.Workflow.IsSimulation, - RunNumber: apiResp.Body.Workflow.RunNumber, - } + 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 @@ -306,6 +306,10 @@ func (c *ContextMemorySummarizer) Summarize(ctx context.Context, vm *VM, current 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), @@ -324,6 +328,8 @@ func (c *ContextMemorySummarizer) Summarize(ctx context.Context, vm *VM, current Transfers: transfers, Balances: balances, Workflow: workflow, + Runner: buildRunnerFromVM(vm), + Fees: buildFeesFromVM(vm), }, nil } diff --git a/core/taskengine/summarizer_deterministic.go b/core/taskengine/summarizer_deterministic.go index 27659ae4..5cea1c4f 100644 --- a/core/taskengine/summarizer_deterministic.go +++ b/core/taskengine/summarizer_deterministic.go @@ -5,6 +5,7 @@ import ( "html" "math/big" "net/url" + "sort" "strconv" "strings" "time" @@ -81,6 +82,65 @@ type Summary struct { 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 // "gas" | "external_api" | "wallet_creation" + 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 } // BuildBranchAndSkippedSummary builds a deterministic summary (text and HTML) @@ -1387,6 +1447,458 @@ func ComposeSummary(vm *VM, currentStepName string) Summary { 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). + if nativeEth.Sign() > 0 { + entry := &TokenTotal{ + Amount: trimTrailingZeros(nativeEth.Text('f', 9)), + Unit: nativeSymbol, + } + 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(), } } diff --git a/core/taskengine/summarizer_format_email.go b/core/taskengine/summarizer_format_email.go index bec6552f..ba9278f6 100644 --- a/core/taskengine/summarizer_format_email.go +++ b/core/taskengine/summarizer_format_email.go @@ -256,7 +256,7 @@ func formatBackticksForChannel(s string, channel string) string { func buildAnalysisHtmlFromStructured(s Summary) string { var sb strings.Builder - // Section 1: What Triggered This Workflow + // Section: What Triggered This Workflow if s.Trigger != "" { sb.WriteString(`
") @@ -317,6 +324,52 @@ func buildAnalysisHtmlFromStructured(s Summary) string { return sb.String() } +// buildFeesSectionHTML renders the Cost section from Summary.Fees.Total — +// same multi-token format as Telegram, just wrapped in HTML. Simulations +// render only the static placeholder. Returns "" when there's nothing to show. +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 `
⛽ (cost estimated at deploy)
` + + `⛽ `) + sb.WriteString(strings.Join(parts, ", ")) + sb.WriteString("
-wrapped (hex
+ // addresses don't trigger Telegram auto-linking; the wrap adds noise).
+ hasRunner := s.Runner != nil && s.Runner.SmartWallet != ""
+ costLine := formatTelegramCostLine(s)
+ if network != "" || s.TriggeredAt != "" || hasRunner || costLine != "" {
sb.WriteString("\n")
- if network != "" {
- sb.WriteString("Network: ")
- sb.WriteString(html.EscapeString(network))
- 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
@@ -221,6 +246,42 @@ func formatSubjectWithBoldName(subject string) string {
return sb.String()
}
+// formatTelegramCostLine renders a single Cost line from Summary.Fees.Total.
+// Format: "⛽ Cost: 0.000003 ETH ($0.01), 1.2 USDC ($1.20)" — native
+// token first, comma-separated, USD parenthetical per token. Unpriceable
+// tokens render as "$?". For simulations the line collapses to the static
+// "⛽ (cost estimated at deploy)" placeholder. Returns "" when there's
+// nothing to render.
+func formatTelegramCostLine(s Summary) string {
+ if s.Workflow != nil && s.Workflow.IsSimulation {
+ return "⛽ (cost estimated at deploy)\n"
+ }
+ if s.Fees == nil || len(s.Fees.Total) == 0 {
+ return ""
+ }
+ parts := make([]string, 0, len(s.Fees.Total))
+ for _, t := range s.Fees.Total {
+ if t == nil || t.Amount == "" || t.Amount == "0" {
+ continue
+ }
+ // USD-unit entries are the platform fee — render as "$X platform fee"
+ // (the dollar amount is already canonical; no need for the parenthetical).
+ if t.Unit == "USD" {
+ parts = append(parts, fmt.Sprintf("$%s platform fee", html.EscapeString(t.Amount)))
+ continue
+ }
+ usd := "$?"
+ if t.USD != "" {
+ usd = "$" + t.USD
+ }
+ parts = append(parts, fmt.Sprintf("%s %s (%s)", html.EscapeString(t.Amount), html.EscapeString(t.Unit), usd))
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return "⛽ Cost: " + strings.Join(parts, ", ") + "\n"
+}
+
func formatTelegramExampleMessage(workflowName, chainName string) string {
var sb strings.Builder
diff --git a/core/taskengine/summarizer_format_test.go b/core/taskengine/summarizer_format_test.go
index 19d7bd54..cf3007e9 100644
--- a/core/taskengine/summarizer_format_test.go
+++ b/core/taskengine/summarizer_format_test.go
@@ -3,6 +3,7 @@ package taskengine
import (
"context"
"fmt"
+ "math/big"
"os"
"strings"
"testing"
@@ -1010,6 +1011,323 @@ func TestFormatTelegramFromStructured_Annotation(t *testing.T) {
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"},
+ {Amount: "0.02", Unit: "USD", USD: "0.02"}, // platform fee — renders as "$0.02 platform fee"
+ },
+ },
+ }
+
+ 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)
+ }
+
+ // Cost line: native gas first, then platform fee as a separate "$X platform fee".
+ if !strings.Contains(out, "⛽ Cost: 0.000003 ETH ($0.01), $0.02 platform fee") {
+ t.Errorf("missing combined gas+platform Cost line in:\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, "(cost estimated at deploy)") {
+ t.Errorf("deployed run should not show simulation placeholder, got:\n%s", out)
+ }
+
+ // Cost line follows Runner directly inside the metadata block (no blank
+ // line between them).
+ if !strings.Contains(out, "Runner: 0x8Ee38...7017 on Sepolia\n⛽ Cost: ") {
+ t.Errorf("Cost 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. Renders as "$0.02 platform fee"
+// with no gas-equivalent ETH line that could be misread as gas.
+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, "⛽ Cost: $0.02 platform fee") {
+ t.Errorf("expected platform-fee-only Cost line, 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 still shows as the canonical $X.
+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: ""}, // unpriced — renders "$?"
+ {Amount: "0.02", Unit: "USD", USD: "0.02"},
+ },
+ },
+ }
+ out := FormatForMessageChannels(summary, "telegram", nil)
+ if !strings.Contains(out, "⛽ Cost: 0.000003 ETH ($?), $0.02 platform fee") {
+ t.Errorf("expected unpriced ETH + platform fee, got:\n%s", out)
+ }
+}
+
+// TestFormatTelegramFromStructured_MultiToken_USDPlaceholder verifies the
+// multi-token comma-separated render and the "$?" placeholder for unpriceable
+// entries. Mirror of the format spec in the PRD's Rendering section.
+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"},
+ {Amount: "1.2", Unit: "USDC", USD: "1.20"},
+ {Amount: "0.005", Unit: "PEPE", USD: ""}, // unpriceable
+ },
+ },
+ }
+
+ out := FormatForMessageChannels(summary, "telegram", nil)
+ want := "⛽ Cost: 0.01 ETH ($25.00), 1.2 USDC ($1.20), 0.005 PEPE ($?)"
+ if !strings.Contains(out, want) {
+ t.Errorf("missing multi-token Cost line %q in:\n%s", want, 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 "(cost estimated at 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, "⛽ (cost estimated at 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 {
@@ -1392,7 +1710,8 @@ func TestComposeSummary_SimulateTaskFromClientPayload(t *testing.T) {
expectedTelegramContents := []string{
"✅ Simulation: Test settings.name successfully completed",
- "Network: Sepolia",
+ "Runner: ", // chain qualifier folded onto Runner line — see formatTelegramFromStructured
+ " on Sepolia",
"Time:",
"Trigger: (Simulated) Scheduled task ran on Sepolia",
"Executed:",
@@ -1404,9 +1723,14 @@ func TestComposeSummary_SimulateTaskFromClientPayload(t *testing.T) {
}
}
- // Telegram should NOT contain annotation for simulate_task
- if strings.Contains(telegram, "") {
- t.Errorf("Telegram output should not contain italic annotation for simulate_task\nFull output:\n%s", 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, "(cost estimated at deploy)") {
+ t.Errorf("Telegram should not contain italic annotation other than the cost placeholder, got:\n%s", telegram)
}
t.Logf("Subject: %s", summary.Subject)
diff --git a/core/taskengine/tenderly_client.go b/core/taskengine/tenderly_client.go
index b7e08752..039dfb65 100644
--- a/core/taskengine/tenderly_client.go
+++ b/core/taskengine/tenderly_client.go
@@ -677,12 +677,13 @@ func (tc *TenderlyClient) SimulateContractWrite(ctx context.Context, contractAdd
}
}
+ sentGasPrice := GetDefaultGasPrice(uint64(chainID))
body := map[string]interface{}{
"network_id": fmt.Sprintf("%d", chainID),
"from": fromAddress,
"to": contractAddress,
"gas": 8000000,
- "gas_price": 0,
+ "gas_price": sentGasPrice,
"value": apiValue,
"input": callData,
"simulation_type": "full",
@@ -996,32 +997,30 @@ func (tc *TenderlyClient) SimulateContractWrite(ctx context.Context, contractAdd
}
}
- // Calculate total gas cost if both gas_used and gas_price are available
- if gasUsed != "" && gasPrice != "" {
- // Convert strings to big.Int for accurate calculation
+ // Compute total gas cost when the simulation produced gas data. The
+ // simulate request always sends a non-zero gas_price (sentGasPrice),
+ // so a zero/missing gas_price in the response is a real anomaly —
+ // surface it as an error rather than silently substituting a default.
+ if gasUsed != "" {
gasUsedBig, gasUsedOk := new(big.Int).SetString(gasUsed, 10)
+ if !gasUsedOk {
+ return nil, fmt.Errorf("tenderly returned unparseable gas_used=%q (chain %d)", gasUsed, chainID)
+ }
gasPriceBig, gasPriceOk := new(big.Int).SetString(gasPrice, 10)
- if gasUsedOk && gasPriceOk {
- totalGasCost := new(big.Int).Mul(gasUsedBig, gasPriceBig)
- result.GasUsed = gasUsed
- result.GasPrice = gasPrice
- result.TotalGasCost = totalGasCost.String()
- tc.logger.Info("✅ Extracted gas information from Tenderly",
- "gas_used", gasUsed,
- "gas_price", gasPrice,
- "total_gas_cost", result.TotalGasCost)
- } else {
- tc.logger.Warn("Failed to parse gas values as big integers",
- "gas_used", gasUsed,
- "gas_price", gasPrice)
- // Store individual values even if calculation failed
- result.GasUsed = gasUsed
- result.GasPrice = gasPrice
+ if !gasPriceOk || gasPriceBig.Sign() == 0 {
+ return nil, fmt.Errorf(
+ "tenderly returned gas_price=%q despite request sending %d (chain %d); refusing silent fallback",
+ gasPrice, sentGasPrice, chainID,
+ )
}
- } else if gasUsed != "" {
- // Only gas used is available
+ totalGasCost := new(big.Int).Mul(gasUsedBig, gasPriceBig)
result.GasUsed = gasUsed
- tc.logger.Info("⚠️ Only gas_used available from Tenderly", "gas_used", gasUsed)
+ result.GasPrice = gasPrice
+ result.TotalGasCost = totalGasCost.String()
+ tc.logger.Info("✅ Extracted gas information from Tenderly",
+ "gas_used", gasUsed,
+ "gas_price", gasPrice,
+ "total_gas_cost", result.TotalGasCost)
} else {
tc.logger.Warn("⚠️ No gas information found in Tenderly response")
}
diff --git a/core/taskengine/vm_runner_eth_transfer.go b/core/taskengine/vm_runner_eth_transfer.go
index dcdf1192..02a8eb39 100644
--- a/core/taskengine/vm_runner_eth_transfer.go
+++ b/core/taskengine/vm_runner_eth_transfer.go
@@ -195,9 +195,26 @@ func (p *ETHTransferProcessor) Execute(stepID string, node *avsproto.ETHTransfer
// Get the sender (smart wallet) address for the transfer object
fromAddress := getAASenderString(p.vm)
+ // Estimated gas for the simulated transfer; populates the same fields a real
+ // receipt would, so downstream cogs/fees handling treats sim and deployed
+ // runs identically.
+ var simChainID uint64
+ if p.smartWalletConfig != nil {
+ simChainID = uint64(p.smartWalletConfig.ChainID)
+ }
+ gasUsedBig := new(big.Int).SetUint64(StandardGasCost)
+ gasPriceBig := new(big.Int).SetUint64(GetDefaultGasPrice(simChainID))
+ totalGasCost := new(big.Int).Mul(gasUsedBig, gasPriceBig)
+ executionLog.GasUsed = gasUsedBig.String()
+ executionLog.GasPrice = gasPriceBig.String()
+ executionLog.TotalGasCost = totalGasCost.String()
+
// Build result object for metadata (only transactionHash - success/isSimulated are in response/executionContext)
resultObj := map[string]interface{}{
"transactionHash": txHash,
+ "gasUsed": executionLog.GasUsed,
+ "gasPrice": executionLog.GasPrice,
+ "totalGasCost": executionLog.TotalGasCost,
}
// Create output data: only transfer object in data field (matches ERC20 format)
diff --git a/core/taskengine/vm_runner_eth_transfer_test.go b/core/taskengine/vm_runner_eth_transfer_test.go
index 752dedbb..653eb645 100644
--- a/core/taskengine/vm_runner_eth_transfer_test.go
+++ b/core/taskengine/vm_runner_eth_transfer_test.go
@@ -477,3 +477,40 @@ func TestETHTransferProcessor_Execute_OversizedHandlebarsTemplate(t *testing.T)
require.NotNil(t, executionLog)
})
}
+
+func TestETHTransferProcessor_Simulation_PopulatesGasFields(t *testing.T) {
+ vm := NewVM()
+ vm.WithLogger(testutil.GetLogger())
+
+ testUserAddress := testutil.TestUser1().Address
+ processor := NewETHTransferProcessor(vm, nil, nil, &testUserAddress)
+
+ node := &avsproto.ETHTransferNode{
+ Config: &avsproto.ETHTransferNode_Config{
+ Destination: "0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6",
+ Amount: "1000000000000000000",
+ },
+ }
+ taskNode := &avsproto.TaskNode{
+ Id: "test-step",
+ Name: "TestETHTransfer",
+ Type: avsproto.NodeType_NODE_TYPE_ETH_TRANSFER,
+ TaskType: &avsproto.TaskNode_EthTransfer{EthTransfer: node},
+ }
+ processor.CommonProcessor.SetTaskNode(taskNode)
+ vm.TaskNodes = map[string]*avsproto.TaskNode{"test-step": taskNode}
+
+ executionLog, err := processor.Execute("test-step", node)
+ require.NoError(t, err)
+ require.NotNil(t, executionLog)
+ require.True(t, executionLog.Success)
+
+ // Simulation path lands here when smartWalletConfig is nil. Chain falls back
+ // to GetDefaultGasPrice(0) == DefaultGasPrice (0.5 gwei).
+ wantGasUsed := "21000"
+ wantGasPrice := "500000000" // DefaultGasPrice = 0.5 gwei
+ wantTotal := "10500000000000"
+ assert.Equal(t, wantGasUsed, executionLog.GasUsed, "GasUsed should be StandardGasCost")
+ assert.Equal(t, wantGasPrice, executionLog.GasPrice, "GasPrice should fall back to DefaultGasPrice for unknown chain")
+ assert.Equal(t, wantTotal, executionLog.TotalGasCost, "TotalGasCost should be GasUsed × GasPrice")
+}
diff --git a/core/taskengine/vm_runner_rest.go b/core/taskengine/vm_runner_rest.go
index f81b61d7..aaad62c0 100644
--- a/core/taskengine/vm_runner_rest.go
+++ b/core/taskengine/vm_runner_rest.go
@@ -10,7 +10,6 @@ import (
"time"
avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf"
- "github.com/ethereum/go-ethereum/common"
"github.com/go-resty/resty/v2"
"google.golang.org/protobuf/types/known/structpb"
)
@@ -715,36 +714,15 @@ func (r *RestProcessor) Execute(stepID string, node *avsproto.RestAPINode) (*avs
// Get dynamic template data from summary (subject/title/analysis...)
dynamicData := s.SendGridDynamicData()
- // Enrich with runner/eoaAddress for template usage.
- // Read from vm.task directly.
- smartWallet := ""
- ownerEOA := ""
- if r.vm.task != nil && r.vm.task.Task != nil {
- smartWallet = r.vm.task.SmartWalletAddress
- ownerEOA = r.vm.task.Owner
+ // Single source of truth: the runner block returned by context-memory,
+ // shared with the Telegram render. SendGrid template variables keep
+ // their existing format — `runner` is full hex, `eoaAddress` is shortened.
+ if s.Runner != nil {
+ dynamicData["runner"] = s.Runner.SmartWallet
+ dynamicData["eoaAddress"] = shortHex(s.Runner.OwnerEOA)
}
- // Fallback to aa_sender or settings if task not available
- r.vm.mu.Lock()
- if smartWallet == "" {
- if aaSender, ok := r.vm.vars["aa_sender"].(string); ok && aaSender != "" {
- smartWallet = aaSender
- }
- }
- if smartWallet == "" {
- if settings, ok := r.vm.vars["settings"].(map[string]interface{}); ok {
- if runner, ok := settings["runner"].(string); ok && strings.TrimSpace(runner) != "" {
- smartWallet = runner
- }
- }
- }
- if ownerEOA == "" && r.vm.TaskOwner != (common.Address{}) {
- ownerEOA = r.vm.TaskOwner.Hex()
- }
- r.vm.mu.Unlock()
// Get execution index from VM field (set by executor)
executionIndex := r.vm.ExecutionIndex
- dynamicData["runner"] = smartWallet
- dynamicData["eoaAddress"] = shortHex(ownerEOA)
dynamicData["year"] = fmt.Sprintf("%d", time.Now().Year()) // Current year for email footer
// Compute skipped nodes (by name) = workflow nodes - executed step names
diff --git a/docs/changes/0001-execution-status-redesign.md b/docs/changes/20260413-execution-status-redesign.md
similarity index 100%
rename from docs/changes/0001-execution-status-redesign.md
rename to docs/changes/20260413-execution-status-redesign.md
diff --git a/docs/changes/0002-uniswap-simulation-state-propagation.md b/docs/changes/20260416-uniswap-simulation-state-propagation.md
similarity index 100%
rename from docs/changes/0002-uniswap-simulation-state-propagation.md
rename to docs/changes/20260416-uniswap-simulation-state-propagation.md
diff --git a/docs/changes/20260502-notification-cost-line-and-runner.md b/docs/changes/20260502-notification-cost-line-and-runner.md
new file mode 100644
index 00000000..e24fee28
--- /dev/null
+++ b/docs/changes/20260502-notification-cost-line-and-runner.md
@@ -0,0 +1,91 @@
+# Notification Cost Line and Runner — Multi-Token Rendering
+
+- **Date**: 2026-05-02
+- **Status**: Implemented (PR #529, final commit `937d1fd`)
+- **Branch**: `staging`
+- **Related**:
+ - Context-memory PRD: [`context-memory/docs/changes/20260501-summary-runner-and-fees-sections.md`](../../../context-memory/docs/changes/20260501-summary-runner-and-fees-sections.md) — rendering format spec (kept after rollback to aggregator-local)
+ - Aggregator fee model: [`docs/FEE_ESTIMATION.md`](../FEE_ESTIMATION.md)
+ - Proto: [`protobuf/avs.proto`](../../protobuf/avs.proto) — `Execution` (711-713), step gas (737-739), `Fee` / `NodeCOGS` / `ValueFee` (1402-1463)
+ - Commits on PR #529: `cba1ac8` (rollback to aggregator-local), `53f01ed` (multi-token line + price service), `9bac118` (per-token value-fee + stablecoin shortcut), `937d1fd` (TokenEnrichmentService for arbitrary ERC20s)
+
+## Problem
+
+Notifications (Telegram, email) lacked two things a user reads next after "did it work":
+
+1. **Who actually ran this.** The smart wallet and owner EOA were known to the aggregator (`vm.task.SmartWalletAddress`, `vm.task.Owner`) but never surfaced — Telegram/email formatters either omitted them or stitched them in via duplicate VM-state lookups.
+2. **What it cost.** Fees are a first-class concept in the aggregator (execution fee in USD, per-node gas in WEI, value-fee % per workflow), but notifications showed nothing — at most a per-step gas line buried in email.
+
+A separate gap on simulation runs: the aggregator was sending `gas_price: 0` to Tenderly, so simulated `total_gas_cost` always came back zero, and `cogs[]` for sim summaries was empty. Even if we'd surfaced cost, the numbers would've been useless.
+
+## Decision
+
+### Final architecture (after one round of rollback)
+
+**Aggregator owns Runner and Fees end-to-end. Context-memory's `/api/summarize` is unchanged.**
+
+An earlier iteration of this work routed Runner and Fees through context-memory (request payload + response echo) — but those fields were pure passthroughs adding nothing but round-trip latency and API-shape complexity. The aggregator already has every input it needs locally; the API call exists for natural-language fields (subject, executions[], errors), not for data the aggregator can compute itself.
+
+So the data path is:
+
+```
+VM state ──► buildRunnerFromVM ──► Summary.Runner
+ ──► buildFeesFromVM ──► Summary.Fees ──► formatters render
+```
+
+`ComposeSummary` (deterministic) and `ContextMemorySummarizer.Summarize` (when context-memory is available) both call the same helpers; `Summary.Workflow.IsSimulation` is sourced from `vm.IsSimulation` rather than the API response so the formatter's simulation branch never silently misclassifies.
+
+### Cost line format
+
+Single-line, multi-token, native unit first with USD parenthetical per token:
+
+| Case | Render |
+|---|---|
+| ETH-only deployed | `⛽ Cost: 0.000003 ETH ($0.01)` |
+| ETH gas + USDC value-fee | `⛽ Cost: 0.000003 ETH ($0.01), 1.2 USDC ($1.20)` |
+| Unpriceable ERC20 | `⛽ Cost: 0.000003 ETH ($0.01), 0.005 PEPE ($?)` |
+| Read-only deployed (no on-chain steps) | (line omitted) |
+| Simulation | `⛽ (cost estimated at deploy)` — static, no numbers |
+
+Telegram drops bullets, gas-units, and value-fee detail; email matches. Breakdowns live on the dashboard. Simulation collapses to the placeholder because sim gas prices are conservative chain defaults rather than real network conditions — any specific number would mislead.
+
+### Price service: aggregator-only, no fabricated fallback
+
+`PriceService` interface gains `GetERC20PriceUSD(chainID, contract) (*big.Float, error)`. Three-tier hierarchy at the call site (`tokenBucket.toTokenTotal`):
+
+1. **`Stablecoins` map** ([`blockchain_constants.go`](../../core/taskengine/blockchain_constants.go)) — 12 fully-reserved or strongly-collateralized stablecoins (USDC, USDT, DAI, USDS, PYUSD, sDAI, FDUSD, TUSD, GUSD, LUSD, RLUSD, USDG) on the four supported chains. Treated as exactly $1.00; no network call. Algorithmic/synthetic ones (USDe, USDD, FRAX) deliberately excluded — a depeg event must surface, not be papered over.
+2. **`MoralisService.GetERC20PriceUSD`** — for everything else.
+3. **`$?` placeholder** — when neither path yields a price.
+
+`FallbackPriceService` ($2,500/ETH hardcoded) was deleted. When the aggregator's `MoralisApiKey` is empty, the price service stays nil; renderers print `$?` and the aggregator logs "configure Moralis" at startup rather than emitting silently-wrong USD numbers. (Moralis is an aggregator concern; operator nodes don't deal with prices or fee rendering — see [`memory/project_aggregator_vs_operator.md`](../../../../.claude/projects/-Users-mikasa-Code-EigenLayer-AVS/memory/project_aggregator_vs_operator.md).)
+
+### Transfer extraction
+
+`extractOutgoingTransfers` walks `vm.ExecutionLogs` for `eth_transfer`, single `contract_write`, and `loop` step outputs (each loop iteration's child output is examined), filters for `from == smartWallet`, and returns `outgoingTransfer` records. `resolveTokenInfo` resolves `(symbol, decimals)` via Stablecoins → `TokenEnrichmentService.GetTokenMetadata` (same source `/api/summarize` uses) → `("?", 18)` fallback. Per-token aggregation in `buildTotalsFromVM` applies the workflow's value-fee tier percentage to each token's outgoing total; native-ETH transfers fold into the native bucket; ERC20s become separate entries; entries that round to zero at the token's display precision are omitted.
+
+### Simulation-cogs fix (related)
+
+To make simulation cogs[] non-empty in the first place, the Tenderly client now sends `gas_price: GetDefaultGasPrice(chainID)` (per-chain conservative defaults — 5 gwei mainnet, 0.5 gwei Sepolia, 0.05 gwei Base, 0.01 gwei Base Sepolia) rather than `0`. The response parser refuses to silently fall back if the echoed `gas_price` is zero — it returns an error so the bug surfaces upstream rather than producing zeroed cogs. ETH-transfer simulation paths similarly populate `Step.GasUsed/GasPrice/TotalGasCost` from `StandardGasCost × GetDefaultGasPrice(chainID)` so cogs[] aggregates correctly across loop iterations.
+
+## Alternatives considered
+
+- **Compute USD totals on the API server (context-memory).** Rejected: the aggregator already has the price service for `EstimateFees()`. Sending pre-formatted strings or rounded USD numbers from the API locks every channel into the same wording and creates two systems that must agree on which steps are on-chain. The aggregator stays the source of truth.
+- **Hardcode `$2,500/ETH` fallback for ergonomics.** Rejected: silently fabricated USD numbers when the price service is unavailable mask a real configuration issue. `$?` is honest and points operators at "configure Moralis."
+- **Show per-step gas bullets and value-fee subtitle in notifications.** Rejected on simplicity grounds — notifications stay scannable; the dashboard owns the breakdown.
+- **Include algorithmic stablecoins (USDe, USDD, FRAX) in the $1.00 map.** Rejected: incorrect ≈$1.00 assumption miscomputes fees on a depeg, which is exactly when accurate accounting matters. They go through the price service like any other ERC20.
+- **Compute per-iteration cogs entries for loop steps.** Rejected for v1: the existing loop helper rolls child gas into a parent step, and shipping the per-iteration breakdown affects billing reconciliation — bigger decision than a render improvement.
+
+## Verification
+
+- `go test ./core/taskengine/ -count=1` green (full suite covers formatters, fee builders, simulation cogs path, multi-token render, stablecoin lookup, percent-of-raw math).
+- New focused tests:
+ - `TestLookupStablecoin` — chain-keyed map coverage.
+ - `TestPercentOfRaw` — value-fee percentage math against raw amounts.
+ - `TestTokenBucketToTokenTotal` — stablecoin shortcut, zero-rounding omission, missing price service path.
+ - `TestFormatTelegramFromStructured_RunnerAndFees` and `TestFormatTelegramFromStructured_MultiToken_USDPlaceholder` — end-to-end render assertions.
+- Production verification: the next deployed run on Sepolia after `cba1ac8` showed the Telegram block ordering with Runner-and-Network folded onto one line; the simulation summary correctly rendered `⛽ (cost estimated at deploy)` after `4f784c9` (`vm.IsSimulation` flowing through to the formatter).
+- The aggregator's existing tests pass with the price service possibly nil: executor (`executor.go:321,478`), fee estimator (`fee_estimator.go:195`), and the new `buildTotalsFromVM` all guard against `nil` rather than panicking.
+
+## Cross-repo coordination
+
+The context-memory PRD ([`context-memory/docs/changes/20260501-summary-runner-and-fees-sections.md`](../../../context-memory/docs/changes/20260501-summary-runner-and-fees-sections.md)) was rewritten to be a rendering-format spec only — the API stays unchanged, so no context-memory code change is required for this work. The PRD's Decision section retains the Cost-line format table because that's the contract between the aggregator and any future renderer (web dashboard, mobile, etc.).
diff --git a/docs/changes/README.md b/docs/changes/README.md
index 771b5a89..622b593d 100644
--- a/docs/changes/README.md
+++ b/docs/changes/README.md
@@ -4,8 +4,6 @@ Long-form notes for changes worth remembering after the PR merges. Format and ed
Filename: `YYYYMMDD-kebab-case-title.md`. Each entry opens with a header block (Date, Status, Branch, Related) and a body adapted to the change (typically Problem / Decision / Alternatives / Verification). See existing entries for examples.
-Earlier entries (`0001-…`, `0002-…`) used a sequential four-digit prefix; those filenames are referenced from merged commits and stay as-is. New entries use the date prefix.
-
## Project-specific notes
_None yet. Add things here that only apply to this repo: dashboards we cross-link to, tags or prefixes we use, runbooks worth pointing at._