Skip to content

feat: notification Runner + multi-token Cost; simulation cogs fix#530

Merged
chrisli30 merged 11 commits into
mainfrom
staging
May 3, 2026
Merged

feat: notification Runner + multi-token Cost; simulation cogs fix#530
chrisli30 merged 11 commits into
mainfrom
staging

Conversation

@chrisli30
Copy link
Copy Markdown
Member

  • refactor: derive migration backup path from db_path, drop dead BackupConfig

backup_dir was only consumed by the migration-time backup. The nested
BackupConfig (Enabled/IntervalMinutes/BackupDir) was never read — periodic
backups are not wired up anywhere. Remove the YAML field, the
/tmp/ap-avs-backup default, and the dead struct. The migrator now writes
to <db_path>_backup automatically, keeping backups co-located with the DB
instead of falling back to /tmp.

  • fix: wrap Telegram subject prefix inside block

The Simulation:/Run #N:/Run Node: prefix now renders inside the code
block alongside the workflow name, restoring the pre-rename behavior
where the whole "Simulation: WorkflowName" chunk was code-wrapped.

  • docs: added instructions for ./docs/changes

  • docs: fix backup-path hyphen/underscore mismatch in example yaml

The comment said <db_path>-backup but the code derives <db_path>_backup.
Caught by both Copilot and Claude review on PR #529.

  • feat: ship per-execution fee breakdown to context-memory

Add per-step gas (gasUsed/gasPrice/totalGasCost) on the step digest and a
top-level fees block (executionFee + cogs[] + valueFee) on the summarize
request, mirroring the Execution proto's three-component fee model. The
fees block is built via the same buildExecutionFee/buildCOGSFromSteps/
buildValueFee helpers the executor uses for Execution.Fee, so summarize
fees match persisted execution fees exactly.

NewContextMemorySummarizerFromAggregatorConfig now constructs the
summarizer directly so it can pass c.FeeRates through. The public
NewContextMemorySummarizer(baseURL, authToken) is unchanged for tests.

  • fix: populate cogs[] on simulation runs via per-chain gas defaults

Tenderly simulations were previously sent with gas_price=0, so the echoed
response had total_gas_cost=0, which buildCOGSFromSteps then skipped — and
context-memory saw fees.cogs=[] on every simulate_task request. Per-step
gasUsed/gasPrice/totalGasCost were also absent, violating the spec that
on-chain steps always carry them.

Changes:

  • Add DefaultGasPriceByChain (mainnet 5 gwei, Sepolia 0.5, Base 0.05,
    Base Sepolia 0.01) + GetDefaultGasPrice helper.

  • Send GetDefaultGasPrice(chainID) in Tenderly simulate requests.

  • Throw with diagnostic context when the response carries gas_used but
    zero/unparseable gas_price; drop the silent "only gas_used" branch.

  • Populate executionLog.GasUsed/GasPrice/TotalGasCost in the eth_transfer
    simulation branch (StandardGasCost × per-chain default).

  • feat: render Runner + Cost from context-memory response

Consume the new body.runner / body.fees fields the context-memory API now
returns (PRD: docs/changes/20260501-summary-runner-and-fees-sections.md):

  • Decode runner and fees from /api/summarize and surface as
    Summary.Runner / Summary.Fees.
  • Telegram: render Runner address inside the Network/Time block and a
    one-line Cost summary right below. Owner intentionally omitted on
    Telegram (channel-space). Simulations show "⛽ (cost estimated at
    deploy)" instead of fake-precision numbers, since sim gas prices are
    conservative chain defaults rather than real network conditions.
  • Email: SendGrid template's existing {{runner}} / {{eoaAddress}} now
    source from Summary.Runner directly — single source of truth shared
    with Telegram, no parallel VM-state derivation. analysisHtml gains a
    Cost section with per-step gas bullets + value-fee line for deployed
    runs; simulations get the same deploy-time placeholder.

Drops the dead VM-state runner-fallback chain (vm.task / aa_sender /
settings.runner) in vm_runner_rest.go in favor of reading s.Runner.

  • fix: aggregator owns IsSimulation, not context-memory response

The Telegram formatter's simulation branch reads s.Workflow.IsSimulation,
but Summary.Workflow was populated only when context-memory's response
included a body.workflow block. Simulation responses don't, so the flag
defaulted to false and the formatter took the deployed-run path —
producing a malformed Cost: + $0.02 platform fee line when fee fields
were partially present.

Always populate Summary.Workflow.IsSimulation from vm.IsSimulation (the
aggregator already knows whether it ran a simulation). The API response
can still enrich workflow Name/Chain/ChainID/RunNumber, but the
simulation flag is no longer dependent on response shape.

  • fix: drop "Cost" header on simulation email; placeholder is self-explanatory

The h3 "Cost" heading above "⛽ (cost estimated at deploy)" was redundant
visual noise — the placeholder line conveys the same information without
the header.

  • refactor: drop Runner/Fees from context-memory API; populate from VM locally

Runner and Fees data was being shipped to context-memory in /api/summarize
requests and echoed back in responses, but the aggregator already has
every input it needs (vm.task.SmartWalletAddress, vm.task.Owner,
vm.ExecutionLogs, config.FeeRates) — context-memory was a pure
passthrough adding only round-trip latency and API-shape complexity.

This commit removes that round-trip:

  • Request: drop steps[].gasUsed/gasPrice/totalGasCost and the top-level
    fees block. Step digest reverts to its pre-Runner/Fees shape.
  • Response: stop decoding body.runner / body.fees. Those types are
    removed; ContextMemorySummarizer.feeRates field is removed.
  • Add buildRunnerFromVM and buildFeesFromVM helpers (alongside the
    existing builders in summarizer_deterministic.go) that read VM state
    directly.
  • Both ComposeSummary (deterministic path) and Summarize (context-memory
    path) populate Summary.Runner and Summary.Fees via these helpers.
  • Engine startup wires globalFeeRates via SetFeeRates(config.FeeRates)
    so both paths see the same configured rates.

Net behavior is identical for notification rendering (formatters still
read Summary.Runner and Summary.Fees); the data path is shorter and the
context-memory API stays focused on natural-language summary fields.

  • feat: multi-token Cost line + chain price service for notifications

Notifications now render the Cost as a single comma-separated line with
the native unit first and USD parenthetical:

⛽ Cost: 0.000003 ETH ($0.01)
⛽ Cost: 0.01 ETH ($25.00), 1.2 USDC ($1.20)

Tokens that can't be priced render as "$?" — never block the notification.
For simulation runs the line collapses to the static placeholder
"⛽ (cost estimated at deploy)" since simulated gas uses conservative
chain defaults rather than real network conditions.

Drops the per-step bullets, gas-units detail, and value-fee subtitle
that the email Cost section used to render — breakdowns live on the
dashboard, not in notifications.

The chain price service (Moralis with $2,500 ETH fallback) is reused
from the existing fee-estimation path. Engine startup wires both
SetFeeRates and SetPriceService at package level so ComposeSummary
(deterministic) and ContextMemorySummarizer.Summarize share one source
of truth.

V1 ships the native-token total leg only (gas + executionFee converted
to ETH). The Total []*TokenTotal shape supports per-token value-fee
legs for V2 once transfer extraction across loop iterations lands —
the renderer needs no changes.

  • feat: per-token value-fee legs in Cost line; ERC20 stablecoin shortcut

V2 of the multi-token Cost line. Walks transfer outputs across the smart
wallet's execution logs (including loop iterations), groups by token,
multiplies by the workflow's value-fee tier percentage, and emits one
TokenTotal per token paid. Native-ETH transfers fold into the existing
native entry. Renders as a single comma-separated line:

⛽ Cost: 0.000003 ETH ($0.01), 1.2 USDC ($1.20)

USD pricing layers, no hardcoded fallbacks:

  • Stablecoins (USDC/USDT/DAI/USDS/PYUSD/sDAI/FDUSD/TUSD/GUSD/LUSD/RLUSD/USDG)
    short-circuit to $1.00 via a chain-keyed Stablecoins map in
    blockchain_constants.go. Algorithmic/synthetic stablecoins deliberately
    excluded so depeg events surface correctly.
  • Other ERC20s use PriceService.GetERC20PriceUSD (new method on the
    interface), implemented on MoralisService.
  • When unavailable, USD field stays empty and renderer prints "$?".
  • FallbackPriceService ($2,500 ETH hardcoded) removed; nil priceService
    is handled gracefully throughout (executor, fee estimator, summarizer).

Tests: LookupStablecoin map coverage; percentOfRaw value-fee math;
tokenBucket.toTokenTotal stablecoin shortcut + zero-rounding + missing
price service.

  • feat: resolve ERC20 token info via TokenEnrichmentService for transfer extraction

For non-stablecoin ERC20 transfers, the per-token Cost line was rendering
the symbol as "?" and defaulting to 18 decimals — fine for tokens that
happen to use 18 decimals, but wrong for any other (USDT lookalikes,
project tokens with 8/9/12 decimals, etc.).

Wire transfer extraction through resolveTokenInfo() which checks:

  1. Stablecoins map (fast path, no network)
  2. TokenEnrichmentService.GetTokenMetadata (same source the
    /api/summarize request uses, so decimals match what context-memory
    sees)
  3. Fall back to ("?", 18) as a last resort

Now a workflow that transfers PEPE renders the correct decimal count
for PEPE without needing to be in the Stablecoins map.

  • docs: change-doc for notification Cost line + Runner; date-format renames

Adds 20260502-notification-cost-line-and-runner.md covering the staging-
branch work that landed Runner+Cost in Telegram/email notifications,
removed FallbackPriceService, added the Stablecoins map, extended
PriceService with GetERC20PriceUSD via Moralis, and wired transfer
extraction (incl. loop iterations) for per-token value-fee legs.
Final commit on the stack: 937d1fd.

Renames the two earlier sequential-numbered entries to use the date
prefix per the canonical change-doc convention:

  • 0001-execution-status-redesign → 20260413-...
  • 0002-uniswap-simulation-state-propagation → 20260416-...

README updated to drop the "earlier entries stay as-is" carve-out
since all entries now share one filename format.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates notification fee/runner rendering, tightens simulation gas handling, and cleans up related configuration/docs. It fits into the aggregator/taskengine notification pipeline by moving more summary data derivation into local VM state, extending pricing support for multi-token cost lines, and aligning documentation/configuration with the new behavior.

Changes:

  • Adds Runner/Cost rendering for Telegram/email, including multi-token totals, stablecoin shortcuts, and ERC20 price lookups via Moralis.
  • Improves simulation fee data by assigning per-chain default gas prices and propagating gas fields into simulated execution logs.
  • Cleans up ancillary config/docs by removing dead backup config, updating example YAML/comments, and standardizing docs/changes entries.

Reviewed changes

Copilot reviewed 22 out of 24 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
docs/changes/README.md Adds repo-specific guidance for change-doc naming/format.
docs/changes/20260502-notification-cost-line-and-runner.md Documents the new notification Runner/Cost architecture and rollout.
docs/changes/20260416-uniswap-simulation-state-propagation.md Renamed historical change doc to the date-based convention.
docs/changes/20260413-execution-status-redesign.md Renamed historical change doc to the date-based convention.
core/taskengine/vm_runner_rest.go Switches SendGrid runner fields to use Summary.Runner.
core/taskengine/vm_runner_eth_transfer_test.go Adds simulation gas-field coverage for ETH transfer steps.
core/taskengine/vm_runner_eth_transfer.go Populates simulated ETH-transfer gas/cost metadata.
core/taskengine/tenderly_client.go Sends default gas price to Tenderly and hard-fails anomalous gas echoes.
core/taskengine/summarizer_format_test.go Expands formatter/unit coverage for subject wrapping, runner, and cost lines.
core/taskengine/summarizer_format_telegram.go Renders Runner/Cost metadata and updates Telegram subject code wrapping.
core/taskengine/summarizer_format_email.go Adds/adjusts email Cost rendering and simulation placeholder behavior.
core/taskengine/summarizer_deterministic.go Introduces runner/fee models and computes notification totals from VM state.
core/taskengine/summarizer_context_memory.go Keeps IsSimulation aggregator-owned and populates local Runner/Fees.
core/taskengine/summarizer.go Adds global fee/price-service wiring and direct summarizer construction.
core/taskengine/fee_estimator_test.go Updates mock price service for ERC20 pricing support.
core/taskengine/fee_estimator.go Extends PriceService and nil-guards native token symbol lookup.
core/taskengine/engine.go Wires fee rates and price service into summary generation.
core/taskengine/blockchain_constants_test.go Tests default gas prices and stablecoin lookup tables.
core/taskengine/blockchain_constants.go Adds per-chain default gas prices and stablecoin metadata map.
core/services/moralis_service.go Implements ERC20 USD pricing through Moralis.
core/config/config.go Removes dead backup config fields and derives backup path from db_path.
config/aggregator.example.yaml Updates backup-path comment and removes dead backup_dir example.
aggregator/task_engine.go Stops using fallback pricing and logs nil-price-service degradation.
aggregator/rpc_server.go Removes fallback price service and updates fee-estimate logging/path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +41 to +58
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
Comment on lines +1542 to +1552
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)
if fees.ExecutionFee != nil && fees.ExecutionFee.Amount != "" && nativePriceUSD != nil {
Comment on lines +1552 to +1555
if fees.ExecutionFee != nil && fees.ExecutionFee.Amount != "" && nativePriceUSD != nil {
if usd, ok := new(big.Float).SetString(fees.ExecutionFee.Amount); ok {
nativeEth.Add(nativeEth, new(big.Float).Quo(usd, nativePriceUSD))
}
will-dz added 11 commits May 2, 2026 21:46
Add per-step gas (gasUsed/gasPrice/totalGasCost) on the step digest and a
top-level fees block (executionFee + cogs[] + valueFee) on the summarize
request, mirroring the Execution proto's three-component fee model. The
fees block is built via the same buildExecutionFee/buildCOGSFromSteps/
buildValueFee helpers the executor uses for Execution.Fee, so summarize
fees match persisted execution fees exactly.

NewContextMemorySummarizerFromAggregatorConfig now constructs the
summarizer directly so it can pass c.FeeRates through. The public
NewContextMemorySummarizer(baseURL, authToken) is unchanged for tests.
Tenderly simulations were previously sent with gas_price=0, so the echoed
response had total_gas_cost=0, which buildCOGSFromSteps then skipped — and
context-memory saw fees.cogs=[] on every simulate_task request. Per-step
gasUsed/gasPrice/totalGasCost were also absent, violating the spec that
on-chain steps always carry them.

Changes:
- Add DefaultGasPriceByChain (mainnet 5 gwei, Sepolia 0.5, Base 0.05,
  Base Sepolia 0.01) + GetDefaultGasPrice helper.
- Send GetDefaultGasPrice(chainID) in Tenderly simulate requests.
- Throw with diagnostic context when the response carries gas_used but
  zero/unparseable gas_price; drop the silent "only gas_used" branch.
- Populate executionLog.GasUsed/GasPrice/TotalGasCost in the eth_transfer
  simulation branch (StandardGasCost × per-chain default).
Consume the new body.runner / body.fees fields the context-memory API now
returns (PRD: docs/changes/20260501-summary-runner-and-fees-sections.md):

- Decode `runner` and `fees` from /api/summarize and surface as
  Summary.Runner / Summary.Fees.
- Telegram: render Runner address inside the Network/Time block and a
  one-line Cost summary right below. Owner intentionally omitted on
  Telegram (channel-space). Simulations show "⛽ (cost estimated at
  deploy)" instead of fake-precision numbers, since sim gas prices are
  conservative chain defaults rather than real network conditions.
- Email: SendGrid template's existing {{runner}} / {{eoaAddress}} now
  source from Summary.Runner directly — single source of truth shared
  with Telegram, no parallel VM-state derivation. analysisHtml gains a
  Cost section with per-step gas bullets + value-fee line for deployed
  runs; simulations get the same deploy-time placeholder.

Drops the dead VM-state runner-fallback chain (vm.task / aa_sender /
settings.runner) in vm_runner_rest.go in favor of reading s.Runner.
The Telegram formatter's simulation branch reads s.Workflow.IsSimulation,
but Summary.Workflow was populated only when context-memory's response
included a body.workflow block. Simulation responses don't, so the flag
defaulted to false and the formatter took the deployed-run path —
producing a malformed `Cost: + $0.02 platform fee` line when fee fields
were partially present.

Always populate Summary.Workflow.IsSimulation from vm.IsSimulation (the
aggregator already knows whether it ran a simulation). The API response
can still enrich workflow Name/Chain/ChainID/RunNumber, but the
simulation flag is no longer dependent on response shape.
…anatory

The h3 "Cost" heading above "⛽ (cost estimated at deploy)" was redundant
visual noise — the placeholder line conveys the same information without
the header.
…locally

Runner and Fees data was being shipped to context-memory in /api/summarize
requests and echoed back in responses, but the aggregator already has
every input it needs (vm.task.SmartWalletAddress, vm.task.Owner,
vm.ExecutionLogs, config.FeeRates) — context-memory was a pure
passthrough adding only round-trip latency and API-shape complexity.

This commit removes that round-trip:

- Request: drop steps[].gasUsed/gasPrice/totalGasCost and the top-level
  fees block. Step digest reverts to its pre-Runner/Fees shape.
- Response: stop decoding body.runner / body.fees. Those types are
  removed; ContextMemorySummarizer.feeRates field is removed.
- Add buildRunnerFromVM and buildFeesFromVM helpers (alongside the
  existing builders in summarizer_deterministic.go) that read VM state
  directly.
- Both ComposeSummary (deterministic path) and Summarize (context-memory
  path) populate Summary.Runner and Summary.Fees via these helpers.
- Engine startup wires globalFeeRates via SetFeeRates(config.FeeRates)
  so both paths see the same configured rates.

Net behavior is identical for notification rendering (formatters still
read Summary.Runner and Summary.Fees); the data path is shorter and the
context-memory API stays focused on natural-language summary fields.
Notifications now render the Cost as a single comma-separated line with
the native unit first and USD parenthetical:

  ⛽ Cost: 0.000003 ETH ($0.01)
  ⛽ Cost: 0.01 ETH ($25.00), 1.2 USDC ($1.20)

Tokens that can't be priced render as "$?" — never block the notification.
For simulation runs the line collapses to the static placeholder
"⛽ (cost estimated at deploy)" since simulated gas uses conservative
chain defaults rather than real network conditions.

Drops the per-step bullets, gas-units detail, and value-fee subtitle
that the email Cost section used to render — breakdowns live on the
dashboard, not in notifications.

The chain price service (Moralis with $2,500 ETH fallback) is reused
from the existing fee-estimation path. Engine startup wires both
SetFeeRates and SetPriceService at package level so ComposeSummary
(deterministic) and ContextMemorySummarizer.Summarize share one source
of truth.

V1 ships the native-token total leg only (gas + executionFee converted
to ETH). The Total []*TokenTotal shape supports per-token value-fee
legs for V2 once transfer extraction across loop iterations lands —
the renderer needs no changes.
V2 of the multi-token Cost line. Walks transfer outputs across the smart
wallet's execution logs (including loop iterations), groups by token,
multiplies by the workflow's value-fee tier percentage, and emits one
TokenTotal per token paid. Native-ETH transfers fold into the existing
native entry. Renders as a single comma-separated line:

  ⛽ Cost: 0.000003 ETH ($0.01), 1.2 USDC ($1.20)

USD pricing layers, no hardcoded fallbacks:
- Stablecoins (USDC/USDT/DAI/USDS/PYUSD/sDAI/FDUSD/TUSD/GUSD/LUSD/RLUSD/USDG)
  short-circuit to $1.00 via a chain-keyed Stablecoins map in
  blockchain_constants.go. Algorithmic/synthetic stablecoins deliberately
  excluded so depeg events surface correctly.
- Other ERC20s use PriceService.GetERC20PriceUSD (new method on the
  interface), implemented on MoralisService.
- When unavailable, USD field stays empty and renderer prints "$?".
- FallbackPriceService ($2,500 ETH hardcoded) removed; nil priceService
  is handled gracefully throughout (executor, fee estimator, summarizer).

Tests: LookupStablecoin map coverage; percentOfRaw value-fee math;
tokenBucket.toTokenTotal stablecoin shortcut + zero-rounding + missing
price service.
…r extraction

For non-stablecoin ERC20 transfers, the per-token Cost line was rendering
the symbol as "?" and defaulting to 18 decimals — fine for tokens that
happen to use 18 decimals, but wrong for any other (USDT lookalikes,
project tokens with 8/9/12 decimals, etc.).

Wire transfer extraction through resolveTokenInfo() which checks:
  1. Stablecoins map (fast path, no network)
  2. TokenEnrichmentService.GetTokenMetadata (same source the
     /api/summarize request uses, so decimals match what context-memory
     sees)
  3. Fall back to ("?", 18) as a last resort

Now a workflow that transfers PEPE renders the correct decimal count
for PEPE without needing to be in the Stablecoins map.
…ames

Adds 20260502-notification-cost-line-and-runner.md covering the staging-
branch work that landed Runner+Cost in Telegram/email notifications,
removed FallbackPriceService, added the Stablecoins map, extended
PriceService with GetERC20PriceUSD via Moralis, and wired transfer
extraction (incl. loop iterations) for per-token value-fee legs.
Final commit on the stack: 937d1fd.

Renames the two earlier sequential-numbered entries to use the date
prefix per the canonical change-doc convention:
- 0001-execution-status-redesign → 20260413-...
- 0002-uniswap-simulation-state-propagation → 20260416-...

README updated to drop the "earlier entries stay as-is" carve-out
since all entries now share one filename format.
Two related Copilot review findings:
- Read-only deployed runs were rendering "0.00000X ETH ($0.02)" — the
  flat platform fee converted to ETH-equivalent looked like gas, even
  though no on-chain action ran. Misleading.
- When Moralis isn't configured, the platform fee silently disappeared
  from the rendered Cost line because USD→ETH conversion couldn't
  proceed. The user's total understated by $0.02 with no signal.

Fix: stop folding executionFee into the native-ETH amount. Always emit
it as its own USD-denominated TokenTotal entry; renderer special-cases
Unit=="USD" to emit "$X platform fee".

Resulting renders:
  ⛽ Cost: 0.000003 ETH ($0.01), $0.02 platform fee     (priced + on-chain)
  ⛽ Cost: 0.000003 ETH ($?), $0.02 platform fee        (unpriced)
  ⛽ Cost: $0.02 platform fee                           (read-only)

Attribution stays clear: gas in ETH, value-fee per token, platform fee
in dollars. The platform fee is shown even without Moralis configured,
since its USD value is known directly.

Tests: TestFormatTelegramFromStructured_PlatformFeeOnly (read-only path)
and TestFormatTelegramFromStructured_NoPriceService (unpriced path).
Existing RunnerAndFees test updated to expect the separate platform fee.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants