diff --git a/tools/fastswap-miles/cost_estimator.go b/tools/fastswap-miles/cost_estimator.go index 4808642e2..26a0e1b81 100644 --- a/tools/fastswap-miles/cost_estimator.go +++ b/tools/fastswap-miles/cost_estimator.go @@ -8,8 +8,18 @@ import ( "strings" "sync" "time" + + "github.com/ethereum/go-ethereum/common" ) +// Percentile of recent fastswap bid_cost used as the per-sweep bid proxy +// (the sweep tx is itself a fastswap). p75 = under-promise. +const sweepBidGlobalPercentile = 0.75 + +// Fallback bid used when the percentile query returns NULL (cold start / +// no processed rows yet). Post-fix realized p75 ≈ 4e-5 ETH. +const sweepBidFallbackEth = 4e-5 + // costEstimateLookbackDays is the rolling window over which per-token sweep // overhead percentiles are computed. const costEstimateLookbackDays = 14 @@ -34,9 +44,11 @@ const costEstimateLastResort = 0.001 // ETH // upfront miles awarding. Estimates are refreshed periodically from // fastswap_miles realized sweep data. type costEstimate struct { - // PerRowOverhead is the estimated sweep overhead per user row, in ETH. - // This is the value subtracted in the miles formula in lieu of realized - // pro-rata sweep gas. + // PerRowOverhead is the estimated sweep overhead per user row, in ETH — + // the sum of pro-rata sweep gas and pro-rata sweep bid. This is the value + // subtracted in the miles formula in lieu of realized values for the + // (still-pending) sweep that will eventually convert this row's surplus + // tokens to ETH. See Refresh for the two components. PerRowOverhead float64 // Source describes how this estimate was computed (for observability). @@ -57,16 +69,24 @@ type costEstimator struct { db *sql.DB logger *slog.Logger + // Lowercased hex of the executor address and WETH address. Used to + // identify executor sweep rows (user_address = executor, output = WETH) + // when computing the per-token sweep bid contribution. + executorAddr string + wethAddr string + mu sync.RWMutex estimates map[string]costEstimate // key: lowercased token hex lastFresh time.Time } -func newCostEstimator(db *sql.DB, logger *slog.Logger) *costEstimator { +func newCostEstimator(db *sql.DB, logger *slog.Logger, executorAddr, wethAddr common.Address) *costEstimator { return &costEstimator{ - db: db, - logger: logger, - estimates: make(map[string]costEstimate), + db: db, + logger: logger, + executorAddr: strings.ToLower(executorAddr.Hex()), + wethAddr: strings.ToLower(wethAddr.Hex()), + estimates: make(map[string]costEstimate), } } @@ -87,8 +107,10 @@ func (c *costEstimator) Get(token string) costEstimate { } // Refresh recomputes per-token estimates from realized fastswap_miles data -// over the configured lookback window. This is the only method that touches -// the database; intended to be called periodically by a background goroutine. +// over the configured lookback window. PerRowOverhead is the sum of two +// terms: per-token p25/p75 of pro-rata sweep gas (existing) plus per-token +// (n_sweeps × global_bid_p75 / n_user_rows) for the sweep tx's own bid. +// Both scale together with batch size. func (c *costEstimator) Refresh(ctx context.Context) error { // Filter to ETH-input rows so per_row_oh isolates pure sweep_overhead. // ERC20-input rows have user_gas baked into (surplus_eth - net_profit_eth), @@ -149,6 +171,22 @@ GROUP BY output_token`, costEstimateLookbackDays)) return fmt.Errorf("iterate cost estimates: %w", err) } + // Fold per-token sweep bid contribution into PerRowOverhead. A failure + // here is logged but non-fatal — the gas-only overhead is still a usable + // estimate and the alternative would be skipping the refresh entirely. + bidByToken, err := c.computePerTokenSweepBidEth(ctx) + if err != nil { + c.logger.Warn("sweep bid contribution refresh failed; falling back to gas-only overhead", + slog.Any("error", err)) + } else { + for token, est := range fresh { + if bid, ok := bidByToken[token]; ok && bid > 0 { + est.PerRowOverhead += bid + fresh[token] = est + } + } + } + c.mu.Lock() c.estimates = fresh c.lastFresh = time.Now() @@ -156,10 +194,69 @@ GROUP BY output_token`, costEstimateLookbackDays)) c.logger.Info("cost estimates refreshed", slog.Int("tokens", len(fresh)), + slog.Int("tokens_with_sweep_bid", len(bidByToken)), slog.Duration("window", costEstimateLookbackDays*24*time.Hour)) return nil } +// computePerTokenSweepBidEth returns per-row sweep bid contribution (ETH) +// keyed by lowercased output_token. Single round trip: joins per-token +// executor sweep counts × per-token user-row counts × global bid p75 (with +// fallback when no processed rows exist). +func (c *costEstimator) computePerTokenSweepBidEth(ctx context.Context) (map[string]float64, error) { + query := fmt.Sprintf(` +SELECT s.token, s.n_sweeps, u.n_users, COALESCE(b.p, %f) AS bid_p75 +FROM ( + SELECT LOWER(input_token) AS token, COUNT(*) AS n_sweeps + FROM mevcommit_57173.fastswap_miles + WHERE LOWER(user_address) = ? + AND swap_type = 'eth_weth' + AND LOWER(output_token) = ? + AND block_timestamp >= NOW() - INTERVAL %d DAY + GROUP BY input_token +) s +JOIN ( + SELECT LOWER(output_token) AS token, COUNT(*) AS n_users + FROM mevcommit_57173.fastswap_miles + WHERE swap_type = 'erc20' + AND LOWER(user_address) != ? + AND block_timestamp >= NOW() - INTERVAL %d DAY + GROUP BY output_token +) u ON u.token = s.token +CROSS JOIN ( + SELECT percentile_approx(CAST(bid_cost AS DOUBLE)/1e18, %f) AS p + FROM mevcommit_57173.fastswap_miles + WHERE processed = 1 + AND bid_cost IS NOT NULL + AND CAST(bid_cost AS DOUBLE) > 0 + AND block_timestamp >= NOW() - INTERVAL %d DAY +) b +`, sweepBidFallbackEth, costEstimateLookbackDays, costEstimateLookbackDays, + sweepBidGlobalPercentile, costEstimateLookbackDays) + + rows, err := c.db.QueryContext(ctx, query, c.executorAddr, c.wethAddr, c.executorAddr) + if err != nil { + return nil, fmt.Errorf("query per-token sweep bid: %w", err) + } + defer func() { _ = rows.Close() }() + + out := make(map[string]float64) + for rows.Next() { + var token string + var nSweeps, nUsers int + var bidEth float64 + if err := rows.Scan(&token, &nSweeps, &nUsers, &bidEth); err != nil { + c.logger.Warn("scan per-token sweep bid failed", slog.Any("error", err)) + continue + } + if nSweeps <= 0 || nUsers <= 0 || bidEth <= 0 { + continue + } + out[token] = float64(nSweeps) * bidEth / float64(nUsers) + } + return out, rows.Err() +} + // Run starts a background loop that refreshes estimates on the configured // interval. Returns when the context is cancelled. Performs an immediate // initial refresh on startup so estimates are warm before the first miles diff --git a/tools/fastswap-miles/cost_estimator_test.go b/tools/fastswap-miles/cost_estimator_test.go index 10048650f..dc18d0c56 100644 --- a/tools/fastswap-miles/cost_estimator_test.go +++ b/tools/fastswap-miles/cost_estimator_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" "time" + + "github.com/ethereum/go-ethereum/common" ) func newTestEstimator() *costEstimator { @@ -16,7 +18,7 @@ func newTestEstimator() *costEstimator { func TestCostEstimator_Get_NoData_FallsBackToLastResort(t *testing.T) { // Exercise the real constructor so it's not unused. - c := newCostEstimator(nil, slog.Default()) + c := newCostEstimator(nil, slog.Default(), common.Address{}, common.Address{}) got := c.Get("0xdeadbeef") if got.Source != "default_no_data" { t.Errorf("source = %q, want default_no_data", got.Source) @@ -91,3 +93,19 @@ func TestCostEstimateLastResort_Reasonable(t *testing.T) { t.Errorf("costEstimateLastResort = %v, expected within [1e-4, 1e-2] sanity range", costEstimateLastResort) } } + +func TestCostEstimator_Constructor_LowercasesAddresses(t *testing.T) { + // Sweep-bid lookup queries compare against LOWER(user_address) and + // LOWER(output_token), so the addresses stored on the estimator MUST be + // lowercased. A mixed-case stored value would silently match zero rows + // and the sweep bid term would never fire. + exec := common.HexToAddress("0x959DAD78D5B68986a43cD270134A2704a990aa68") + weth := common.HexToAddress("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2") + c := newCostEstimator(nil, slog.Default(), exec, weth) + if c.executorAddr != strings.ToLower(exec.Hex()) { + t.Errorf("executorAddr = %q, want %q", c.executorAddr, strings.ToLower(exec.Hex())) + } + if c.wethAddr != strings.ToLower(weth.Hex()) { + t.Errorf("wethAddr = %q, want %q", c.wethAddr, strings.ToLower(weth.Hex())) + } +} diff --git a/tools/fastswap-miles/main.go b/tools/fastswap-miles/main.go index 3d807d5e5..881f94436 100644 --- a/tools/fastswap-miles/main.go +++ b/tools/fastswap-miles/main.go @@ -246,7 +246,7 @@ func main() { return fmt.Errorf("newPriceOracle: %w", err) } cfg.PriceOracle = priceOracle - cfg.CostEstimator = newCostEstimator(db, logger) + cfg.CostEstimator = newCostEstimator(db, logger, executorAddr, weth) cfg.GasBuffer = newGasBuffer(db, logger) cfg.SweepClock = newSweepClock() reconciliation := newReconciliationMonitor(db, logger,