Skip to content

Commit f41c263

Browse files
chrisli30will-dz
andauthored
feat: notification Runner + multi-token Cost; simulation cogs fix (#530)
Co-authored-by: Will Zimmerman <will@avaprotocol.org>
1 parent 5aea707 commit f41c263

22 files changed

Lines changed: 1413 additions & 135 deletions

aggregator/rpc_server.go

Lines changed: 5 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -55,49 +55,6 @@ type RpcServer struct {
5555
chainID *big.Int
5656
}
5757

58-
// FallbackPriceService provides hardcoded fallback prices when Moralis is unavailable
59-
type FallbackPriceService struct{}
60-
61-
func newFallbackPriceService() *FallbackPriceService {
62-
return &FallbackPriceService{}
63-
}
64-
65-
func (fps *FallbackPriceService) GetNativeTokenPriceUSD(chainID int64) (*big.Float, error) {
66-
// Only include chains that the aggregator actually supports: Ethereum and Base
67-
fallbackPrices := map[int64]float64{
68-
1: 2500.0, // Ethereum Mainnet
69-
11155111: 2500.0, // Ethereum Sepolia
70-
8453: 2500.0, // Base Mainnet
71-
84532: 2500.0, // Base Sepolia
72-
}
73-
74-
if price, exists := fallbackPrices[chainID]; exists {
75-
return big.NewFloat(price), nil
76-
}
77-
return big.NewFloat(2500.0), nil // Default ETH price
78-
}
79-
80-
func (fps *FallbackPriceService) GetNativeTokenSymbol(chainID int64) string {
81-
// Only include chains that the aggregator actually supports: Ethereum and Base
82-
// All supported chains use ETH as the native token
83-
tokenSymbols := map[int64]string{
84-
1: "ETH", // Ethereum Mainnet
85-
11155111: "ETH", // Ethereum Sepolia
86-
8453: "ETH", // Base Mainnet
87-
84532: "ETH", // Base Sepolia
88-
}
89-
90-
if symbol, exists := tokenSymbols[chainID]; exists {
91-
return symbol
92-
}
93-
return "ETH"
94-
}
95-
96-
// FallbackPriceInfo provides information about fallback pricing for logging
97-
func (fps *FallbackPriceService) FallbackPriceInfo() string {
98-
return "using conservative ETH price of $2500"
99-
}
100-
10158
// Get nonce of an existing smart wallet of a given owner
10259
func (r *RpcServer) GetWallet(ctx context.Context, payload *avsproto.GetWalletReq) (*avsproto.GetWalletResp, error) {
10360
user, err := r.verifyAuth(ctx)
@@ -1191,23 +1148,15 @@ func (r *RpcServer) EstimateFees(ctx context.Context, req *avsproto.EstimateFees
11911148
return nil, status.Errorf(codes.InvalidArgument, "expire_at must be after created_at")
11921149
}
11931150

1194-
// Create price service (Moralis if API key available, otherwise fallback)
1151+
// Price service is required for USD-equivalent fee numbers. When Moralis
1152+
// isn't configured, callers receive cogs (WEI) and executionFee (USD) as
1153+
// raw values without USD-equivalent conversions — and notifications render
1154+
// "$?" rather than a fabricated number.
11951155
var priceService taskengine.PriceService
11961156
if r.config.MoralisApiKey != "" {
11971157
priceService = services.GetMoralisService(r.config.MoralisApiKey, r.config.Logger)
11981158
} else {
1199-
priceService = newFallbackPriceService()
1200-
var fallbackPriceInfo string
1201-
// Try to extract fallback price info for logging
1202-
type fallbackPricer interface {
1203-
FallbackPriceInfo() string
1204-
}
1205-
if fp, ok := priceService.(fallbackPricer); ok {
1206-
fallbackPriceInfo = fp.FallbackPriceInfo()
1207-
} else {
1208-
fallbackPriceInfo = "unknown fallback price"
1209-
}
1210-
r.config.Logger.Warn(fmt.Sprintf("No Moralis API key configured, using fallback price service for fee estimation (%s)", fallbackPriceInfo))
1159+
r.config.Logger.Warn("No Moralis API key configured; fee estimates will lack USD-equivalent conversions and notifications will render $? for token totals")
12111160
}
12121161

12131162
// Create fee estimator - use configuration-aware version if fee rates are configured

aggregator/task_engine.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,17 @@ func (agg *Aggregator) startTaskEngine(ctx context.Context) {
9797
agg.logger,
9898
)
9999

100-
// Create price service for fee conversion (USD → ETH)
100+
// Price service for fee conversion (USD → ETH and ERC20 lookups). When
101+
// Moralis isn't configured, it stays nil and callers gracefully degrade
102+
// (notifications render "$?" for unknown prices).
101103
var priceService taskengine.PriceService
102104
if agg.config.MoralisApiKey != "" {
103105
priceService = services.GetMoralisService(agg.config.MoralisApiKey, agg.logger)
104106
} else {
105-
priceService = newFallbackPriceService()
107+
agg.logger.Warn("No Moralis API key configured; USD-equivalent fee numbers will be unavailable")
106108
}
107109

108-
// Store price service on engine for use in simulation path
110+
// Store price service on engine (nil-safe — engine and summarizer handle absence).
109111
agg.engine.SetPriceService(priceService)
110112

111113
// Create executor with engine reference for atomic execution indexing

core/services/moralis_service.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,53 @@ func (ms *MoralisService) GetNativeTokenSymbol(chainID int64) string {
193193
return "ETH" // Default fallback
194194
}
195195

196+
// GetERC20PriceUSD fetches the USD price for an ERC20 contract on the given
197+
// chain. Returns an error when the price isn't available — callers render
198+
// "$?" rather than fabricate a number. No internal $1.00 / fallback.
199+
//
200+
// NOTE: Stablecoin shortcut (taskengine.LookupStablecoinSymbol → $1.00) is
201+
// applied at the caller layer in taskengine, not here, to keep the
202+
// services/taskengine dependency direction correct.
203+
func (ms *MoralisService) GetERC20PriceUSD(chainID int64, contractAddress string) (*big.Float, error) {
204+
if contractAddress == "" {
205+
return nil, fmt.Errorf("contract address required")
206+
}
207+
208+
moralisChain := ms.chainIDToMoralisChain(chainID)
209+
if moralisChain == "" {
210+
return nil, fmt.Errorf("unsupported chain ID: %d", chainID)
211+
}
212+
213+
cacheKey := fmt.Sprintf("erc20_%d_%s", chainID, contractAddress)
214+
if cached := ms.getCachedPrice(cacheKey); cached != nil {
215+
return cached.Price, nil
216+
}
217+
218+
if ms.apiKey == "" {
219+
return nil, fmt.Errorf("moralis API key not configured")
220+
}
221+
222+
url := fmt.Sprintf("https://deep-index.moralis.io/api/v2.2/erc20/%s/price", contractAddress)
223+
resp, err := ms.httpClient.R().
224+
SetQueryParams(map[string]string{"chain": moralisChain}).
225+
SetHeader("X-API-Key", ms.apiKey).
226+
SetResult(&MoralisTokenPriceResponse{}).
227+
Get(url)
228+
if err != nil {
229+
return nil, fmt.Errorf("moralis ERC20 price request failed: %w", err)
230+
}
231+
if resp.StatusCode() != 200 {
232+
return nil, fmt.Errorf("moralis ERC20 price returned status %d: %s", resp.StatusCode(), resp.String())
233+
}
234+
result := resp.Result().(*MoralisTokenPriceResponse)
235+
if result.UsdPrice <= 0 {
236+
return nil, fmt.Errorf("invalid ERC20 price from Moralis: %f", result.UsdPrice)
237+
}
238+
price := big.NewFloat(result.UsdPrice)
239+
ms.setCachedPrice(cacheKey, price, "")
240+
return price, nil
241+
}
242+
196243
// GetPriceDataAge returns the age of cached price data in seconds
197244
func (ms *MoralisService) GetPriceDataAge(chainID int64) int64 {
198245
cacheKey := fmt.Sprintf("chain_%d", chainID)

core/taskengine/blockchain_constants.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package taskengine
22

3+
import "strings"
4+
35
// Ethereum gas cost constants
46
const (
57
// StandardGasCost represents the standard gas cost for a simple Ethereum transaction (21000 gas)
@@ -25,6 +27,94 @@ const (
2527
DefaultGasPriceHex = "0x1dcd6500"
2628
)
2729

30+
// DefaultGasPriceByChain provides per-chain conservative simulation gas prices (wei).
31+
// Chosen to lean slightly high so simulated cogs don't undershoot real costs.
32+
// Real-time gas pricing is a separate follow-up.
33+
var DefaultGasPriceByChain = map[uint64]uint64{
34+
1: 5_000_000_000, // Ethereum Mainnet — 5 gwei
35+
11155111: 500_000_000, // Ethereum Sepolia — 0.5 gwei
36+
8453: 50_000_000, // Base — 0.05 gwei
37+
84532: 10_000_000, // Base Sepolia — 0.01 gwei
38+
}
39+
40+
// GetDefaultGasPrice returns the per-chain default simulation gas price (wei),
41+
// falling back to DefaultGasPrice (0.5 gwei) for unknown chains.
42+
func GetDefaultGasPrice(chainID uint64) uint64 {
43+
if v, ok := DefaultGasPriceByChain[chainID]; ok {
44+
return v
45+
}
46+
return DefaultGasPrice
47+
}
48+
49+
// StablecoinInfo carries the display symbol and ERC20 decimals for a stablecoin
50+
// hard-coded as $1.00. Decimals are required to format raw token amounts.
51+
type StablecoinInfo struct {
52+
Symbol string
53+
Decimals uint32
54+
}
55+
56+
// Stablecoins maps chain ID → lowercased contract address → StablecoinInfo
57+
// for fully-reserved or strongly-collateralized USD stablecoins. Lookups
58+
// treat each listed address as exactly $1.00 USD without a price-service
59+
// network hop — covers the bulk of real-world value-fee calculation cases.
60+
// Tokens not in this map fall through to PriceService.GetERC20PriceUSD; on
61+
// miss the renderer prints the "$?" placeholder.
62+
//
63+
// Inclusion criteria (high bar — incorrect ≈$1.00 assumptions miscompute fees):
64+
// - Fully reserved by audited issuer (Circle, Paxos, Tether, PayPal, Ripple,
65+
// First Digital, TrueUSD, Gemini), OR
66+
// - Overcollateralized by crypto with strong peg history (DAI, USDS, LUSD,
67+
// sDAI which redeems 1:1 against DAI).
68+
//
69+
// Algorithmic / synthetic / new-untested stablecoins (USDe, USDD, USD1, USDF,
70+
// FRAX) are deliberately excluded — they go through the price service like any
71+
// other ERC20 so a depeg event surfaces correctly.
72+
var Stablecoins = map[uint64]map[string]StablecoinInfo{
73+
// Ethereum Mainnet
74+
1: {
75+
"0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": {"USDC", 6},
76+
"0xdac17f958d2ee523a2206206994597c13d831ec7": {"USDT", 6},
77+
"0x6b175474e89094c44da98b954eedeac495271d0f": {"DAI", 18},
78+
"0xdc035d45d973e3ec169d2276ddab16f1e407384f": {"USDS", 18},
79+
"0x6c3ea9036406852006290770bedfcaba0e23a0e8": {"PYUSD", 6},
80+
"0x83f20f44975d03b1b09e64809b757c47f942beea": {"sDAI", 18},
81+
"0xc5f0f7b66764f6ec8c8dff7ba683102295e16409": {"FDUSD", 18},
82+
"0x0000000000085d4780b73119b644ae5ecd22b376": {"TUSD", 18},
83+
"0x056fd409e1d7a124bd7017459dfea2f387b6d5cd": {"GUSD", 2},
84+
"0x5f98805a4e8be255a32880fdec7f6728c6568ba0": {"LUSD", 18},
85+
"0x8292bb45bf1ee4d140127049757c2e0ff06317ed": {"RLUSD", 18},
86+
"0xe343167631d89b6ffc58b88d6b7fb0228795491d": {"USDG", 6},
87+
},
88+
// Base Mainnet
89+
8453: {
90+
"0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": {"USDC", 6}, // Circle native
91+
"0xd9aaec86b65d86f6a7b5b1b0c42ffa531710b6ca": {"USDC", 6}, // bridged (legacy)
92+
"0xfde4c96c8593536e31f229ea8f37b2ada2699bb2": {"USDT", 6},
93+
"0x50c5725949a6f0c72e6c4a641f24049a917db0cb": {"DAI", 18},
94+
},
95+
// Ethereum Sepolia (testnet)
96+
11155111: {
97+
"0x1c7d4b196cb0c7b01d743fbc6116a902379c7238": {"USDC", 6}, // Circle test deployment
98+
"0xaa8e23fb1079ea71e0a56f48a2aa51851d8433d0": {"USDT", 6},
99+
},
100+
// Base Sepolia (testnet)
101+
84532: {
102+
"0x036cbd53842c5426634e7929541ec2318f3dcf7e": {"USDC", 6}, // Circle test deployment
103+
},
104+
}
105+
106+
// LookupStablecoin returns symbol+decimals for a stablecoin contract, or
107+
// (StablecoinInfo{}, false) if the address isn't in the chain's hard-coded
108+
// $1.00 list. Address matching is case-insensitive.
109+
func LookupStablecoin(chainID uint64, contractAddress string) (StablecoinInfo, bool) {
110+
chainMap, ok := Stablecoins[chainID]
111+
if !ok {
112+
return StablecoinInfo{}, false
113+
}
114+
info, ok := chainMap[strings.ToLower(contractAddress)]
115+
return info, ok
116+
}
117+
28118
// Contract method constants
29119
const (
30120
// UnknownMethodName represents a placeholder for contract method names that need to be resolved from ABI

core/taskengine/blockchain_constants_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,60 @@ func BenchmarkGetChainSearchRanges(b *testing.B) {
150150
_ = GetChainSearchRanges(chainID)
151151
}
152152
}
153+
154+
func TestGetDefaultGasPrice(t *testing.T) {
155+
cases := []struct {
156+
name string
157+
chainID uint64
158+
want uint64
159+
}{
160+
{"Ethereum Mainnet", 1, 5_000_000_000},
161+
{"Ethereum Sepolia", 11155111, 500_000_000},
162+
{"Base", 8453, 50_000_000},
163+
{"Base Sepolia", 84532, 10_000_000},
164+
{"Unknown chain falls back to DefaultGasPrice", 99999, DefaultGasPrice},
165+
{"Zero chain falls back to DefaultGasPrice", 0, DefaultGasPrice},
166+
}
167+
for _, tc := range cases {
168+
t.Run(tc.name, func(t *testing.T) {
169+
if got := GetDefaultGasPrice(tc.chainID); got != tc.want {
170+
t.Errorf("GetDefaultGasPrice(%d) = %d, want %d", tc.chainID, got, tc.want)
171+
}
172+
})
173+
}
174+
}
175+
176+
func TestLookupStablecoin(t *testing.T) {
177+
cases := []struct {
178+
name string
179+
chainID uint64
180+
address string
181+
wantOk bool
182+
wantSym string
183+
wantDec uint32
184+
}{
185+
{"USDC mainnet", 1, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", true, "USDC", 6},
186+
{"USDC mainnet uppercase", 1, "0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48", true, "USDC", 6},
187+
{"DAI mainnet", 1, "0x6b175474e89094c44da98b954eedeac495271d0f", true, "DAI", 18},
188+
{"PYUSD mainnet", 1, "0x6c3ea9036406852006290770bedfcaba0e23a0e8", true, "PYUSD", 6},
189+
{"GUSD has 2 decimals", 1, "0x056fd409e1d7a124bd7017459dfea2f387b6d5cd", true, "GUSD", 2},
190+
{"USDC base", 8453, "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", true, "USDC", 6},
191+
{"USDC sepolia (Circle test)", 11155111, "0x1c7d4b196cb0c7b01d743fbc6116a902379c7238", true, "USDC", 6},
192+
{"unknown ERC20 mainnet", 1, "0x0000000000000000000000000000000000000001", false, "", 0},
193+
{"unknown chain", 99999, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", false, "", 0},
194+
}
195+
for _, tc := range cases {
196+
t.Run(tc.name, func(t *testing.T) {
197+
info, ok := LookupStablecoin(tc.chainID, tc.address)
198+
if ok != tc.wantOk {
199+
t.Errorf("LookupStablecoin(%d, %s) ok = %v, want %v", tc.chainID, tc.address, ok, tc.wantOk)
200+
}
201+
if info.Symbol != tc.wantSym {
202+
t.Errorf("symbol = %q, want %q", info.Symbol, tc.wantSym)
203+
}
204+
if info.Decimals != tc.wantDec {
205+
t.Errorf("decimals = %d, want %d", info.Decimals, tc.wantDec)
206+
}
207+
})
208+
}
209+
}

core/taskengine/engine.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,10 @@ func New(db storage.Storage, config *config.Config, queue *apqueue.Queue, logger
295295
logger: logger,
296296
}
297297

298+
// Wire global fee rates so Summary.Fees population (in both ComposeSummary
299+
// and the context-memory summarizer) uses the aggregator's configured rates.
300+
SetFeeRates(config.FeeRates)
301+
298302
// Initialize AI summarizer (global) from aggregator config
299303
// Only context-memory API is supported - all email content generation is delegated to context-memory
300304
// The aggregator acts as a pass-through for the context-memory response to SendGrid
@@ -374,6 +378,10 @@ func (n *Engine) GetTenderlyClient() *TenderlyClient {
374378

375379
func (n *Engine) SetPriceService(priceService PriceService) {
376380
n.priceService = priceService
381+
// Also wire as the package-level price service so Summary.Fees population
382+
// (in both ComposeSummary and ContextMemorySummarizer.Summarize) can compute
383+
// native-token totals from USD platform fees and value-fee legs.
384+
SetPriceService(priceService)
377385
}
378386

379387
func (n *Engine) Stop() {

core/taskengine/fee_estimator.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@ type FeeEstimator struct {
3434
type PriceService interface {
3535
GetNativeTokenPriceUSD(chainID int64) (*big.Float, error)
3636
GetNativeTokenSymbol(chainID int64) string
37+
// GetERC20PriceUSD returns the USD price for an ERC20 contract on the
38+
// given chain. Implementations should short-circuit hard-coded stablecoins
39+
// (LookupStablecoinSymbol) before hitting the network. Returns an error
40+
// when the price isn't available — callers render "$?" rather than
41+
// fabricate a number.
42+
GetERC20PriceUSD(chainID int64, contractAddress string) (*big.Float, error)
3743
}
3844

3945
// FeeRates holds the fee configuration for the estimator.
@@ -192,7 +198,12 @@ func (fe *FeeEstimator) EstimateFees(ctx context.Context, req *avsproto.Estimate
192198
// Step 3: Value fee — workflow-level classification
193199
valueFee := fe.classifyWorkflowValue(req)
194200

195-
nativeTokenSymbol := fe.priceService.GetNativeTokenSymbol(chainID)
201+
nativeTokenSymbol := "ETH"
202+
if fe.priceService != nil {
203+
if sym := fe.priceService.GetNativeTokenSymbol(chainID); sym != "" {
204+
nativeTokenSymbol = sym
205+
}
206+
}
196207

197208
fe.logger.Info("✅ Fee estimation completed",
198209
"execution_fee_usd", fe.feeRates.ExecutionFeeUSD,

core/taskengine/fee_estimator_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package taskengine
22

33
import (
44
"context"
5+
"fmt"
56
"math/big"
67
"testing"
78

@@ -28,6 +29,11 @@ func (mock *mockPriceService) GetNativeTokenSymbol(chainID int64) string {
2829
return "ETH"
2930
}
3031

32+
func (mock *mockPriceService) GetERC20PriceUSD(chainID int64, contractAddress string) (*big.Float, error) {
33+
// Test mock — no ERC20 price lookups needed for fee-estimator tests.
34+
return nil, fmt.Errorf("ERC20 price lookup not supported in tests")
35+
}
36+
3137
func TestFeeEstimator_ChainIDDetection(t *testing.T) {
3238
logger, err := sdklogging.NewZapLogger(sdklogging.Development)
3339
require.NoError(t, err)

0 commit comments

Comments
 (0)