Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 106 additions & 9 deletions tools/fastswap-miles/cost_estimator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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),
}
}

Expand All @@ -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),
Expand Down Expand Up @@ -149,17 +171,92 @@ 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()
c.mu.Unlock()

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
Expand Down
20 changes: 19 additions & 1 deletion tools/fastswap-miles/cost_estimator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"strings"
"testing"
"time"

"github.com/ethereum/go-ethereum/common"
)

func newTestEstimator() *costEstimator {
Expand All @@ -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)
Expand Down Expand Up @@ -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()))
}
}
2 changes: 1 addition & 1 deletion tools/fastswap-miles/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading