Skip to content
Merged
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
139 changes: 139 additions & 0 deletions tools/fastswap-miles/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,145 @@ import (
"github.com/ethereum/go-ethereum/common"
)

func TestDecideBidCheckOutcome(t *testing.T) {
tests := []struct {
name string
userPaysGas bool
inFastRPC bool
hasBlockTS bool
txAge time.Duration
want bidCheckOutcome
}{
// Permit path: always treated as in-fastrpc, goes through bid-indexer grace
{
name: "permit path, bid indexer lag, young row -> retry",
userPaysGas: false,
inFastRPC: false, // even when fastrpc lookup returns false
hasBlockTS: true,
txAge: 5 * time.Minute,
want: bidCheckRetry,
},
{
name: "permit path, bid never indexed, old row -> proceed",
userPaysGas: false,
inFastRPC: false,
hasBlockTS: true,
txAge: 30 * time.Minute,
want: bidCheckProceed,
},
{
name: "permit path with fastrpc hit and old row -> proceed",
userPaysGas: false,
inFastRPC: true,
hasBlockTS: true,
txAge: 30 * time.Minute,
want: bidCheckProceed,
},
{
name: "permit path, ancient row, not in fastrpc -> proceed (never orphan permit)",
userPaysGas: false,
inFastRPC: false,
hasBlockTS: true,
txAge: 48 * time.Hour,
want: bidCheckProceed,
},
// ETH path, in fastrpc: goes through bid-indexer grace
{
name: "eth path in fastrpc, young row -> retry",
userPaysGas: true,
inFastRPC: true,
hasBlockTS: true,
txAge: 10 * time.Minute,
want: bidCheckRetry,
},
{
name: "eth path in fastrpc, old row -> proceed",
userPaysGas: true,
inFastRPC: true,
hasBlockTS: true,
txAge: 30 * time.Minute,
want: bidCheckProceed,
},
{
name: "eth path in fastrpc, ancient row -> proceed",
userPaysGas: true,
inFastRPC: true,
hasBlockTS: true,
txAge: 72 * time.Hour,
want: bidCheckProceed,
},
// ETH path, not in fastrpc: 24h orphan retry window
{
name: "eth path not in fastrpc, young row -> retry",
userPaysGas: true,
inFastRPC: false,
hasBlockTS: true,
txAge: 1 * time.Hour,
want: bidCheckRetry,
},
{
name: "eth path not in fastrpc, just under 24h -> retry",
userPaysGas: true,
inFastRPC: false,
hasBlockTS: true,
txAge: 23*time.Hour + 59*time.Minute,
want: bidCheckRetry,
},
{
name: "eth path not in fastrpc, just over 24h -> orphan",
userPaysGas: true,
inFastRPC: false,
hasBlockTS: true,
txAge: 24*time.Hour + 1*time.Minute,
want: bidCheckOrphan,
},
{
name: "eth path not in fastrpc, very old -> orphan",
userPaysGas: true,
inFastRPC: false,
hasBlockTS: true,
txAge: 7 * 24 * time.Hour,
want: bidCheckOrphan,
},
// Invalid block timestamp fallback behavior (matches pre-refactor code):
// in-fastrpc case retries (indeterminate age), not-in-fastrpc case orphans.
{
name: "permit path, invalid blockTS -> retry (indeterminate)",
userPaysGas: false,
inFastRPC: false,
hasBlockTS: false,
txAge: 0,
want: bidCheckRetry,
},
{
name: "eth path in fastrpc, invalid blockTS -> retry",
userPaysGas: true,
inFastRPC: true,
hasBlockTS: false,
txAge: 0,
want: bidCheckRetry,
},
{
name: "eth path not in fastrpc, invalid blockTS -> orphan",
userPaysGas: true,
inFastRPC: false,
hasBlockTS: false,
txAge: 0,
want: bidCheckOrphan,
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := decideBidCheckOutcome(tc.userPaysGas, tc.inFastRPC, tc.hasBlockTS, tc.txAge)
if got != tc.want {
t.Errorf("decideBidCheckOutcome(userPaysGas=%v, inFastRPC=%v, hasBlockTS=%v, txAge=%v) = %v, want %v",
tc.userPaysGas, tc.inFastRPC, tc.hasBlockTS, tc.txAge, got, tc.want)
}
})
}
}

func TestWeiToEth(t *testing.T) {
tests := []struct {
name string
Expand Down
122 changes: 96 additions & 26 deletions tools/fastswap-miles/miles.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,59 @@ import (
"github.com/primev/mev-commit/x/keysigner"
)

// orphanRetryWindow is how long we keep retrying a row whose L1 tx hasn't
// shown up in the fastrpc DB yet. ETH-path swaps are user-submitted so the
// fastrpc indexer can lag behind L1 by an unbounded amount; we treat a row as
// a definitive orphan (0 miles) only after it has been missing this long.
const orphanRetryWindow = 24 * time.Hour

// bidIndexerGrace is how long we wait for the mev-commit bid indexer to catch
// up before processing a row with bidCost=0.
const bidIndexerGrace = 15 * time.Minute

// bidCheckOutcome describes what to do with a row whose bid lookup returned 0.
type bidCheckOutcome int

const (
// bidCheckProceed means fall through and compute miles with whatever bid
// cost value we have (typically 0 because the indexer is behind).
bidCheckProceed bidCheckOutcome = iota
// bidCheckRetry means leave the row pending and reevaluate next cycle.
bidCheckRetry
// bidCheckOrphan means mark the row processed with 0 miles and move on —
// the tx did not go through fastrpc so no bid was ever placed.
bidCheckOrphan
)

// decideBidCheckOutcome encodes how we handle a row whose bid lookup returned 0.
//
// - Permit-path rows (userPaysGas=false) are always executor-submitted via
// fastrpc by construction. A missing fastrpc row can only mean indexer lag,
// never a non-fastrpc submission, so they follow the bid-indexer grace path
// regardless of whether fastrpc has caught up yet.
// - ETH-path rows that ARE in fastrpc follow the same grace path.
// - ETH-path rows that are NOT in fastrpc retry for orphanRetryWindow before
// being marked as definitive orphans (the user genuinely bypassed fastrpc).
//
// hasBlockTS / txAge come from the row's block_timestamp column. When the
// timestamp is invalid we fall back to bidCheckRetry in the in-fastrpc case
// (indeterminate age; err on the side of retrying) and to bidCheckOrphan in
// the not-in-fastrpc case (matches prior behavior).
func decideBidCheckOutcome(userPaysGas, inFastRPC, hasBlockTS bool, txAge time.Duration) bidCheckOutcome {
txInFastRPC := !userPaysGas || inFastRPC
if txInFastRPC {
if hasBlockTS && txAge > bidIndexerGrace {
return bidCheckProceed
}
return bidCheckRetry
}
// ETH path, not in fastrpc
if hasBlockTS && txAge < orphanRetryWindow {
return bidCheckRetry
}
return bidCheckOrphan
}

// serviceConfig holds references shared across the miles processing pipeline.
type serviceConfig struct {
Logger *slog.Logger
Expand Down Expand Up @@ -116,19 +169,28 @@ WHERE processed = false
bidCostWei := getBidCost(bidMap, r.txHash)

if bidCostWei.Sign() == 0 {
if fastRPCSet[strings.ToLower(r.txHash)] {
if r.blockTS.Valid && time.Since(r.blockTS.Time) > 15*time.Minute {
cfg.Logger.Info("tx in FastRPC but bid never indexed, processing with 0 bid cost",
slog.String("tx", r.txHash), slog.String("user", r.user))
// fall through to normal miles calculation with bidCostWei = 0
} else {
cfg.Logger.Info("tx in FastRPC but bid not indexed yet, will retry",
slog.String("tx", r.txHash), slog.String("user", r.user))
continue
}
} else {
cfg.Logger.Info("tx not in FastRPC, skipping with 0 miles",
txAge := time.Duration(0)
if r.blockTS.Valid {
txAge = time.Since(r.blockTS.Time)
}
inFastRPC := fastRPCSet[strings.ToLower(r.txHash)]

switch decideBidCheckOutcome(userPaysGas, inFastRPC, r.blockTS.Valid, txAge) {
case bidCheckProceed:
cfg.Logger.Info("tx in FastRPC but bid never indexed, processing with 0 bid cost",
slog.String("tx", r.txHash), slog.String("user", r.user))
// fall through with bidCostWei = 0
case bidCheckRetry:
cfg.Logger.Info("tx bid lookup pending, will retry next cycle",
slog.String("tx", r.txHash), slog.String("user", r.user),
slog.Bool("in_fastrpc", inFastRPC),
slog.Bool("user_pays_gas", userPaysGas),
slog.Duration("age", txAge))
continue
case bidCheckOrphan:
cfg.Logger.Info("tx not in FastRPC after retry window, skipping with 0 miles",
slog.String("tx", r.txHash), slog.String("user", r.user),
slog.Duration("age", txAge))
if !cfg.DryRun {
markProcessed(cfg.DB, r.txHash, weiToEth(surplusWei), 0, 0, "0")
}
Expand Down Expand Up @@ -269,21 +331,31 @@ WHERE processed = false
var readyBidCosts []*big.Int

for _, r := range batch.Txs {
userPaysGas := strings.EqualFold(r.inputToken, zeroAddr.Hex())
bidCostWei := getBidCost(erc20BidMap, r.txHash)
if bidCostWei.Sign() == 0 {
if erc20FastRPCSet[strings.ToLower(r.txHash)] {
if r.blockTS.Valid && time.Since(r.blockTS.Time) > 15*time.Minute {
cfg.Logger.Info("erc20 tx in FastRPC but bid never indexed, processing with 0 bid cost",
slog.String("tx", r.txHash), slog.String("user", r.user))
// fall through with bidCostWei = 0
} else {
cfg.Logger.Info("erc20 tx in FastRPC but bid not indexed yet, will retry",
slog.String("tx", r.txHash), slog.String("user", r.user))
continue
}
} else {
cfg.Logger.Info("erc20 tx not in FastRPC, skipping with 0 miles",
txAge := time.Duration(0)
if r.blockTS.Valid {
txAge = time.Since(r.blockTS.Time)
}
inFastRPC := erc20FastRPCSet[strings.ToLower(r.txHash)]

switch decideBidCheckOutcome(userPaysGas, inFastRPC, r.blockTS.Valid, txAge) {
case bidCheckProceed:
cfg.Logger.Info("erc20 tx in FastRPC but bid never indexed, processing with 0 bid cost",
slog.String("tx", r.txHash), slog.String("user", r.user))
// fall through with bidCostWei = 0
case bidCheckRetry:
cfg.Logger.Info("erc20 tx bid lookup pending, will retry next cycle",
slog.String("tx", r.txHash), slog.String("user", r.user),
slog.Bool("in_fastrpc", inFastRPC),
slog.Bool("user_pays_gas", userPaysGas),
slog.Duration("age", txAge))
continue
case bidCheckOrphan:
cfg.Logger.Info("erc20 tx not in FastRPC after retry window, skipping with 0 miles",
slog.String("tx", r.txHash), slog.String("user", r.user),
slog.Duration("age", txAge))
surplusWei, _ := new(big.Int).SetString(r.surplus, 10)
if !cfg.DryRun {
markProcessed(cfg.DB, r.txHash, weiToEth(surplusWei), 0, 0, "0")
Expand All @@ -293,8 +365,6 @@ WHERE processed = false
}
}

userPaysGas := strings.EqualFold(r.inputToken, zeroAddr.Hex())

gasCostWei := big.NewInt(0)
if !userPaysGas && r.gasCost.Valid && r.gasCost.String != "" {
if gc, ok := new(big.Int).SetString(r.gasCost.String, 10); ok {
Expand Down
Loading