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
2 changes: 1 addition & 1 deletion docs/3F-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,4 +330,4 @@ Tracked TODOs and known gaps — each a scoped follow-up; none block release.

**Testing:**
- **Integration coverage.** `bridgefacilitator` unit coverage is ~16% — pure logic (EIP-712 golden+parity, default-strategy capacity/caps, config) is covered; the HTTP/on-chain paths (apiclient, chainreader, redeemer, Run loop) need an httptest-backed API mock + a simulated/forked chain backend.
- **Solver-agnostic metrics seam.** `solver.Deps.Metrics` (the `Registerer()` extension point) is wired but no solver registers collectors yet; add bridge-facilitator metrics (offers sent/won, exposure, locked vs realized, redemptions) and they'll verify the seam.
- **Solver metrics.** The shared `solver.Deps.Metrics` seam is live: RFQ and OEV already register collectors, and `bridgefacilitator` now exports discovery / offer-cache / offer-submit / redeem-flow counters plus a live-offers gauge. Richer economics metrics (offers won, locked vs realized, exposure by adapter) remain a follow-up.
143 changes: 143 additions & 0 deletions internal/solvers/bridgefacilitator/metrics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package bridgefacilitator

import (
"github.com/go-errors/errors"
"github.com/prometheus/client_golang/prometheus"
)

// metrics are the 3F solver's collectors, registered on the shared Prometheus registry (served at
// the framework's /metrics). All methods are nil-safe so the solver runs unmetered when no registry
// is provided.
type metrics struct {
discoverPasses prometheus.Counter
auctionsDiscovered prometheus.Counter
offersSubmitted prometheus.Counter
offerSubmitFailures prometheus.Counter
cacheLoaded prometheus.Counter
cacheRebuildFailures prometheus.Counter
liveOffers prometheus.Gauge
redeemableRequests prometheus.Counter
redeemedRequests prometheus.Counter
redeemScanFailures prometheus.Counter
redeemTxFailures prometheus.Counter
}

func newMetrics(reg prometheus.Registerer) (*metrics, error) {
m := &metrics{
discoverPasses: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_discover_passes_total",
Help: "Successful 3F auction discovery passes.",
}),
auctionsDiscovered: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_auctions_discovered_total",
Help: "Auctions returned by the 3F API across successful discovery passes.",
}),
offersSubmitted: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_offers_submitted_total",
Help: "3F offers successfully submitted.",
}),
offerSubmitFailures: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_offer_submit_failures_total",
Help: "3F offer submission attempts that failed at the API boundary.",
}),
cacheLoaded: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_offer_cache_loaded_total",
Help: "Live offers loaded from the 3F API into the local dedup cache.",
}),
cacheRebuildFailures: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_offer_cache_rebuild_failures_total",
Help: "Per-adapter 3F offer-cache rebuild failures.",
}),
liveOffers: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "threef_live_offers",
Help: "Current number of live offers in the local dedup cache.",
}),
redeemableRequests: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_redeemable_requests_total",
Help: "Redeemable 3F requests found across redeem scans before batch capping.",
}),
redeemedRequests: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_redeemed_requests_total",
Help: "3F requests successfully finalized on-chain.",
}),
redeemScanFailures: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_redeem_scan_failures_total",
Help: "Redeem scans that failed before building a finalize transaction.",
}),
redeemTxFailures: prometheus.NewCounter(prometheus.CounterOpts{
Name: "threef_redeem_tx_failures_total",
Help: "Finalize-request transaction attempts that failed.",
}),
}
for _, c := range []prometheus.Collector{
m.discoverPasses, m.auctionsDiscovered, m.offersSubmitted, m.offerSubmitFailures,
m.cacheLoaded, m.cacheRebuildFailures, m.liveOffers, m.redeemableRequests,
m.redeemedRequests, m.redeemScanFailures, m.redeemTxFailures,
} {
if err := reg.Register(c); err != nil {
return nil, errors.Errorf("bridgefacilitator: register metric: %w", err)
}
}
return m, nil
}

func (m *metrics) observeDiscover(auctions int) {
if m != nil {
m.discoverPasses.Inc()
m.auctionsDiscovered.Add(float64(auctions))
}
}

func (m *metrics) incOfferSubmitted() {
if m != nil {
m.offersSubmitted.Inc()
}
}

func (m *metrics) incOfferSubmitFailed() {
if m != nil {
m.offerSubmitFailures.Inc()
}
}

func (m *metrics) addCacheLoaded(live int) {
if m != nil {
m.cacheLoaded.Add(float64(live))
}
}

func (m *metrics) incCacheRebuildFailed() {
if m != nil {
m.cacheRebuildFailures.Inc()
}
}

func (m *metrics) setLiveOffers(live int) {
if m != nil {
m.liveOffers.Set(float64(live))
}
}

func (m *metrics) addRedeemable(found int) {
if m != nil {
m.redeemableRequests.Add(float64(found))
}
}

func (m *metrics) addRedeemed(count int) {
if m != nil {
m.redeemedRequests.Add(float64(count))
}
}

func (m *metrics) incRedeemScanFailed() {
if m != nil {
m.redeemScanFailures.Inc()
}
}

func (m *metrics) incRedeemTxFailed() {
if m != nil {
m.redeemTxFailures.Inc()
}
}
75 changes: 75 additions & 0 deletions internal/solvers/bridgefacilitator/metrics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package bridgefacilitator

import (
"testing"

"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/testutil"
)

func TestMetrics_RecordLifecycle(t *testing.T) {
reg := prometheus.NewRegistry()
m, err := newMetrics(reg)
if err != nil {
t.Fatalf("newMetrics: %v", err)
}

m.observeDiscover(3)
m.incOfferSubmitted()
m.incOfferSubmitFailed()
m.addCacheLoaded(2)
m.incCacheRebuildFailed()
m.setLiveOffers(4)
m.addRedeemable(5)
m.addRedeemed(3)
m.incRedeemScanFailed()
m.incRedeemTxFailed()

if got := testutil.ToFloat64(m.discoverPasses); got != 1 {
t.Fatalf("threef_discover_passes_total = %v, want 1", got)
}
if got := testutil.ToFloat64(m.auctionsDiscovered); got != 3 {
t.Fatalf("threef_auctions_discovered_total = %v, want 3", got)
}
if got := testutil.ToFloat64(m.offersSubmitted); got != 1 {
t.Fatalf("threef_offers_submitted_total = %v, want 1", got)
}
if got := testutil.ToFloat64(m.offerSubmitFailures); got != 1 {
t.Fatalf("threef_offer_submit_failures_total = %v, want 1", got)
}
if got := testutil.ToFloat64(m.cacheLoaded); got != 2 {
t.Fatalf("threef_offer_cache_loaded_total = %v, want 2", got)
}
if got := testutil.ToFloat64(m.cacheRebuildFailures); got != 1 {
t.Fatalf("threef_offer_cache_rebuild_failures_total = %v, want 1", got)
}
if got := testutil.ToFloat64(m.liveOffers); got != 4 {
t.Fatalf("threef_live_offers = %v, want 4", got)
}
if got := testutil.ToFloat64(m.redeemableRequests); got != 5 {
t.Fatalf("threef_redeemable_requests_total = %v, want 5", got)
}
if got := testutil.ToFloat64(m.redeemedRequests); got != 3 {
t.Fatalf("threef_redeemed_requests_total = %v, want 3", got)
}
if got := testutil.ToFloat64(m.redeemScanFailures); got != 1 {
t.Fatalf("threef_redeem_scan_failures_total = %v, want 1", got)
}
if got := testutil.ToFloat64(m.redeemTxFailures); got != 1 {
t.Fatalf("threef_redeem_tx_failures_total = %v, want 1", got)
}
}

func TestMetrics_NilSafe(t *testing.T) {
var m *metrics
m.observeDiscover(1)
m.incOfferSubmitted()
m.incOfferSubmitFailed()
m.addCacheLoaded(1)
m.incCacheRebuildFailed()
m.setLiveOffers(1)
m.addRedeemable(1)
m.addRedeemed(1)
m.incRedeemScanFailed()
m.incRedeemTxFailed()
}
11 changes: 11 additions & 0 deletions internal/solvers/bridgefacilitator/offercache.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ func (t *offerTracker) liveCoverage(auctionID int64, now time.Time) *big.Int {
return total
}

// liveCount reports how many offers are still unexpired as of now.
func (t *offerTracker) liveCount(now time.Time) int {
live := 0
for _, st := range t.offers {
if st.expiry.After(now) {
live++
}
}
return live
}

// pruneExpired drops entries whose offer has already expired, keeping the map bounded over a long run.
func (t *offerTracker) pruneExpired(now time.Time) {
for k, st := range t.offers {
Expand Down
4 changes: 4 additions & 0 deletions internal/solvers/bridgefacilitator/redeemer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import (
func (s *Solver) redeemReady(ctx context.Context, target Target) {
ready, err := s.reader.readyToRedeem(ctx, target.Adapter)
if err != nil {
s.metrics.incRedeemScanFailed()
s.log.Error(err, "redeem: scan ready requests", "adapter", target.Adapter.Hex())
return
}
s.metrics.addRedeemable(len(ready))
s.log.V(1).Info("redeem scan", "adapter", target.Adapter.Hex(), "ready", len(ready))
if len(ready) == 0 {
return
Expand All @@ -39,8 +41,10 @@ func (s *Solver) redeemReady(ctx context.Context, target Target) {
Label: "redeem",
})
if res.Err != nil {
s.metrics.incRedeemTxFailed()
s.log.Error(res.Err, "redeem: tx failed", "requests", len(ready))
return
}
s.metrics.addRedeemed(len(ready))
s.log.Info("finalized ready requests", "count", len(ready), "tx", res.Hash.Hex())
}
16 changes: 16 additions & 0 deletions internal/solvers/bridgefacilitator/solver.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ type Solver struct {
api *apiClient
reader *reader
strategy types.Strategy
metrics *metrics
log logr.Logger
signerAddr common.Address // the solver's own EIP-1271 signer address, set in factory
nonceSeq atomic.Uint64
Expand All @@ -59,13 +60,20 @@ func factory(raw yaml.Node, deps solver.Deps) (solver.Solver, error) {
if err != nil {
return nil, err
}
var mx *metrics
if deps.Metrics != nil {
if mx, err = newMetrics(deps.Metrics.Registerer()); err != nil {
return nil, err
}
}

s := &Solver{
cfg: cfg,
deps: deps,
api: api,
reader: newReader(deps.Chain),
strategy: offerStrategy,
metrics: mx,
log: deps.Log.WithName(Name),
signerAddr: deps.Signer.Address(),
offers: newOfferTracker(),
Expand Down Expand Up @@ -133,6 +141,7 @@ func (s *Solver) rebuildOfferCache(ctx context.Context) {
for _, t := range s.cfg.Targets {
offers, err := s.api.listOffers(ctx, t.Adapter)
if err != nil {
s.metrics.incCacheRebuildFailed()
s.log.Error(err, "rebuild offer cache: list offers", "adapter", t.Adapter.Hex())
continue
}
Expand All @@ -154,6 +163,8 @@ func (s *Solver) rebuildOfferCache(ctx context.Context) {
live++
}
}
s.metrics.addCacheLoaded(live)
s.metrics.setLiveOffers(live)
s.log.Info("loaded existing offers into dedup cache", "live", live)
}

Expand All @@ -171,6 +182,7 @@ func (s *Solver) discoverAndOffer(ctx context.Context) {
s.log.Error(err, "discover: list auctions")
return
}
s.metrics.observeDiscover(len(auctions))
s.log.V(1).Info("discovered auctions", "count", len(auctions))

offerings := make([]*adapterOffering, 0, len(s.cfg.Targets))
Expand All @@ -192,6 +204,7 @@ func (s *Solver) discoverAndOffer(ctx context.Context) {

now := time.Now()
s.offers.pruneExpired(now) // keep the dedup map bounded
s.metrics.setLiveOffers(s.offers.liveCount(now))
input := buildStrategyInput(auctions, offerings, s.offers, now)
if len(input.Auctions) == 0 {
return // no open, offerable auctions this pass
Expand All @@ -214,13 +227,16 @@ func (s *Solver) discoverAndOffer(ctx context.Context) {
continue
}
if subErr := s.api.createOffer(ctx, dto); subErr != nil {
s.metrics.incOfferSubmitFailed()
s.log.Error(subErr, "offer: submit", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex())
continue
}

if exp, perr := parseUnixTime(dto.Expiration); perr == nil {
s.offers.record(offer.Maker, offer.AuctionID, exp, offer.Principal)
s.metrics.setLiveOffers(s.offers.liveCount(time.Now()))
}
s.metrics.incOfferSubmitted()
s.log.Info("offer submitted", "auctionId", offer.AuctionID, "adapter", offer.Maker.Hex(),
"request", offer.Request.Hex(), "principal", offer.Principal.String(), "expectedReturn", dto.ExpectedReturn)
}
Expand Down