From 60c98bf8149e53d084027c327276106c57ed3265 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 12:09:23 +0200 Subject: [PATCH 01/44] start ultrasound bid stream --- cmd/service/bidstream.go | 32 +++++++ cmd/service/service.go | 1 + go.mod | 10 +-- go.sum | 19 ++-- services/bidstream/ultrasound-stream.go | 110 ++++++++++++++++++++++++ 5 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 cmd/service/bidstream.go create mode 100644 services/bidstream/ultrasound-stream.go diff --git a/cmd/service/bidstream.go b/cmd/service/bidstream.go new file mode 100644 index 0000000..dc13d74 --- /dev/null +++ b/cmd/service/bidstream.go @@ -0,0 +1,32 @@ +package service + +/** + * https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md + */ + +import ( + "os" + "os/signal" + "syscall" + + "github.com/flashbots/relayscan/services/bidstream" + "github.com/flashbots/relayscan/vars" + "github.com/spf13/cobra" +) + +var bidStreamCmd = &cobra.Command{ + Use: "bidstream", + Short: "Stream bids", + Run: func(cmd *cobra.Command, args []string) { + log.WithField("version", vars.Version).Info("starting bidstream ...") + opts := bidstream.UltrasoundStreamOpts{ + Log: log, + } + bidstream.StartUltrasoundStreamConnection(opts) + + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + <-done + log.Info("bye!") + }, +} diff --git a/cmd/service/service.go b/cmd/service/service.go index d63c07b..1f507ae 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -22,4 +22,5 @@ var ServiceCmd = &cobra.Command{ func init() { ServiceCmd.AddCommand(websiteCmd) ServiceCmd.AddCommand(liveBidsCmd) + ServiceCmd.AddCommand(bidStreamCmd) } diff --git a/go.mod b/go.mod index 51edc7a..31f1c22 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/flashbots/go-utils v0.4.9 github.com/flashbots/mev-boost-relay v1.0.0-alpha4.0.20230519091033-0453fc247553 github.com/gorilla/mux v1.8.0 + github.com/gorilla/websocket v1.5.1 github.com/jmoiron/sqlx v1.3.5 github.com/lib/pq v1.10.9 github.com/metachris/flashbotsrpc v0.5.0 @@ -19,7 +20,7 @@ require ( github.com/stretchr/testify v1.8.3 github.com/tdewolff/minify v2.3.6+incompatible go.uber.org/atomic v1.11.0 - golang.org/x/text v0.9.0 + golang.org/x/text v0.15.0 ) require ( @@ -52,7 +53,6 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect github.com/google/uuid v1.3.0 // indirect - github.com/gorilla/websocket v1.4.2 // indirect github.com/holiman/uint256 v1.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jarcoal/httpmock v1.2.0 // indirect @@ -91,10 +91,10 @@ require ( github.com/yusufpapurcu/wmi v1.2.3 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.24.0 // indirect - golang.org/x/crypto v0.9.0 // indirect + golang.org/x/crypto v0.23.0 // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/sys v0.8.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect google.golang.org/protobuf v1.30.0 // indirect gopkg.in/cenkalti/backoff.v1 v1.1.0 // indirect diff --git a/go.sum b/go.sum index c0fafff..e27457d 100644 --- a/go.sum +++ b/go.sum @@ -352,8 +352,9 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= github.com/graph-gophers/graphql-go v0.0.0-20201113091052-beb923fada29/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/graph-gophers/graphql-go v1.3.0/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= @@ -753,8 +754,8 @@ golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWP golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= -golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -829,8 +830,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -906,8 +907,8 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -921,8 +922,8 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/services/bidstream/ultrasound-stream.go b/services/bidstream/ultrasound-stream.go new file mode 100644 index 0000000..a707a0d --- /dev/null +++ b/services/bidstream/ultrasound-stream.go @@ -0,0 +1,110 @@ +package bidstream + +import ( + "time" + + "github.com/gorilla/websocket" + "github.com/sirupsen/logrus" +) + +const ( + ultrasoundStreamDefaultURL = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" + initialBackoffSec = 5 + maxBackoffSec = 120 +) + +type UltrasoundStreamBid struct { + // pub slot: u64, + // pub block_number: u64, + // pub block_hash: B256, + // pub parent_hash: B256, + // pub builder_pubkey: BlsPublicKey, + // pub fee_recipient: Address, + // pub value: U256, + Timestamp uint64 `json:"timestamp"` + BlockNumber uint64 `json:"block_number"` + BlockHash string `json:"block_hash"` + ParentHash string `json:"parent_hash"` + BuilderPubkey string `json:"builder_pubkey"` + FeeRecipient string `json:"fee_recipient"` + Value string `json:"value"` +} + +type UltrasoundStreamOpts struct { + BidC chan UltrasoundStreamBid + Log *logrus.Entry + URL string // optional override, default: ultrasoundStreamDefaultURL +} + +// StartUltrasoundStreamConnection starts a Websocket or gRPC subscription (depending on URL) in the background +func StartUltrasoundStreamConnection(opts UltrasoundStreamOpts) { + ultrasoundStream := NewUltrasoundStreamConnection(opts) + go ultrasoundStream.Start() +} + +type UltrasoundStreamConnection struct { + log *logrus.Entry + url string + bidC chan UltrasoundStreamBid + backoffSec int +} + +func NewUltrasoundStreamConnection(opts UltrasoundStreamOpts) *UltrasoundStreamConnection { + url := opts.URL + if url == "" { + url = ultrasoundStreamDefaultURL + } + + return &UltrasoundStreamConnection{ + log: opts.Log, + url: url, + backoffSec: initialBackoffSec, + } +} + +func (nc *UltrasoundStreamConnection) Start() { + nc.connect() +} + +func (nc *UltrasoundStreamConnection) reconnect() { + backoffDuration := time.Duration(nc.backoffSec) * time.Second + nc.log.Infof("reconnecting to ultrasound stream in %s sec ...", backoffDuration.String()) + time.Sleep(backoffDuration) + + // increase backoff timeout for next try + nc.backoffSec *= 2 + if nc.backoffSec > maxBackoffSec { + nc.backoffSec = maxBackoffSec + } + + nc.connect() +} + +func (nc *UltrasoundStreamConnection) connect() { + nc.log.WithField("uri", nc.url).Info("connecting...") + + dialer := websocket.DefaultDialer + wsSubscriber, resp, err := dialer.Dial(nc.url, nil) + if err != nil { + nc.log.WithError(err).Error("failed to connect to bloxroute, reconnecting in a bit...") + go nc.reconnect() + return + } + defer wsSubscriber.Close() + defer resp.Body.Close() + + nc.log.Info("ultrasound stream connection successful") + nc.backoffSec = initialBackoffSec // reset backoff timeout + + for { + _, nextNotification, err := wsSubscriber.ReadMessage() + if err != nil { + // Handle websocket errors, by closing and reconnecting. Errors seen previously: + nc.log.WithError(err).Error("ultrasound stream websocket error") + go nc.reconnect() + return + } + + nc.log.WithField("msg", string(nextNotification)).Info("got message") + } +} From 4546fc8160d4b6db72e622f225dac0ba86123961 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 14:13:40 +0200 Subject: [PATCH 02/44] ultrasound stream start --- Makefile | 5 + cmd/service/bidstream.go | 24 ++++- common/ultrasoundbid.go | 21 ++++ common/ultrasoundbid_encoding.go | 124 ++++++++++++++++++++++++ common/ultrasoundbid_test.go | 21 ++++ go.mod | 2 +- services/bidstream/ultrasound-stream.go | 38 ++++---- 7 files changed, 211 insertions(+), 24 deletions(-) create mode 100644 common/ultrasoundbid.go create mode 100644 common/ultrasoundbid_encoding.go create mode 100644 common/ultrasoundbid_test.go diff --git a/Makefile b/Makefile index fadffd8..c5e2809 100644 --- a/Makefile +++ b/Makefile @@ -50,3 +50,8 @@ cover-html: docker-image: DOCKER_BUILDKIT=1 docker build --platform linux/amd64 --build-arg VERSION=${VERSION} . -t relayscan + + +generate-ssz: + rm -f common/ultrasoundbid_encoding.go + sszgen --path common --objs UltrasoundStreamBid diff --git a/cmd/service/bidstream.go b/cmd/service/bidstream.go index dc13d74..3712ebd 100644 --- a/cmd/service/bidstream.go +++ b/cmd/service/bidstream.go @@ -9,8 +9,11 @@ import ( "os/signal" "syscall" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/flashbots/relayscan/common" "github.com/flashbots/relayscan/services/bidstream" "github.com/flashbots/relayscan/vars" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -19,14 +22,29 @@ var bidStreamCmd = &cobra.Command{ Short: "Stream bids", Run: func(cmd *cobra.Command, args []string) { log.WithField("version", vars.Version).Info("starting bidstream ...") + bidC := make(chan common.UltrasoundStreamBid, 100) opts := bidstream.UltrasoundStreamOpts{ - Log: log, + Log: log, + BidC: bidC, } bidstream.StartUltrasoundStreamConnection(opts) done := make(chan os.Signal, 1) signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) - <-done - log.Info("bye!") + + log.Info("Waiting...") + + for { + select { + case bid := <-bidC: + log.WithFields(logrus.Fields{ + "slot": bid.Slot, + "block_hash": hexutil.Encode(bid.BlockHash[:]), + }).Info("received bid") + case <-done: + log.Info("bye ...") + return + } + } }, } diff --git a/common/ultrasoundbid.go b/common/ultrasoundbid.go new file mode 100644 index 0000000..7923c7d --- /dev/null +++ b/common/ultrasoundbid.go @@ -0,0 +1,21 @@ +package common + +// https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md + +type ( + U64 [8]byte + Hash [32]byte + PublicKey [48]byte + Address [20]byte +) + +type UltrasoundStreamBid struct { + Timestamp uint64 `json:"timestamp"` + Slot uint64 `json:"slot"` + BlockNumber uint64 `json:"block_number"` + BlockHash Hash `json:"block_hash" ssz-size:"32"` + ParentHash Hash `json:"parent_hash" ssz-size:"32"` + BuilderPubkey PublicKey `json:"builder_pubkey" ssz-size:"48"` + FeeRecipient Address `json:"fee_recipient" ssz-size:"20"` + Value Hash `json:"value" ssz-size:"32"` +} diff --git a/common/ultrasoundbid_encoding.go b/common/ultrasoundbid_encoding.go new file mode 100644 index 0000000..6857eb6 --- /dev/null +++ b/common/ultrasoundbid_encoding.go @@ -0,0 +1,124 @@ +package common + +import ( + ssz "github.com/ferranbt/fastssz" +) + +// MarshalSSZ ssz marshals the UltrasoundStreamBid object +func (u *UltrasoundStreamBid) MarshalSSZ() ([]byte, error) { + return ssz.MarshalSSZ(u) +} + +// MarshalSSZTo ssz marshals the UltrasoundStreamBid object to a target array +func (u *UltrasoundStreamBid) MarshalSSZTo(buf []byte) (dst []byte, err error) { + dst = buf + + // Field (0) 'Timestamp' + dst = ssz.MarshalUint64(dst, u.Timestamp) + + // Field (1) 'Slot' + dst = ssz.MarshalUint64(dst, u.Slot) + + // Field (2) 'BlockNumber' + dst = ssz.MarshalUint64(dst, u.BlockNumber) + + // Field (3) 'BlockHash' + dst = append(dst, u.BlockHash[:]...) + + // Field (4) 'ParentHash' + dst = append(dst, u.ParentHash[:]...) + + // Field (5) 'BuilderPubkey' + dst = append(dst, u.BuilderPubkey[:]...) + + // Field (6) 'FeeRecipient' + dst = append(dst, u.FeeRecipient[:]...) + + // Field (7) 'Value' + dst = append(dst, u.Value[:]...) + + return +} + +// UnmarshalSSZ ssz unmarshals the UltrasoundStreamBid object +func (u *UltrasoundStreamBid) UnmarshalSSZ(buf []byte) error { + var err error + size := uint64(len(buf)) + if size != 188 { + return ssz.ErrSize + } + + // Field (0) 'Timestamp' + u.Timestamp = ssz.UnmarshallUint64(buf[0:8]) + + // Field (1) 'Slot' + u.Slot = ssz.UnmarshallUint64(buf[8:16]) + + // Field (2) 'BlockNumber' + u.BlockNumber = ssz.UnmarshallUint64(buf[16:24]) + + // Field (3) 'BlockHash' + copy(u.BlockHash[:], buf[24:56]) + + // Field (4) 'ParentHash' + copy(u.ParentHash[:], buf[56:88]) + + // Field (5) 'BuilderPubkey' + copy(u.BuilderPubkey[:], buf[88:136]) + + // Field (6) 'FeeRecipient' + copy(u.FeeRecipient[:], buf[136:156]) + + // Field (7) 'Value' + copy(u.Value[:], buf[156:188]) + + return err +} + +// SizeSSZ returns the ssz encoded size in bytes for the UltrasoundStreamBid object +func (u *UltrasoundStreamBid) SizeSSZ() (size int) { + size = 188 + return +} + +// HashTreeRoot ssz hashes the UltrasoundStreamBid object +func (u *UltrasoundStreamBid) HashTreeRoot() ([32]byte, error) { + return ssz.HashWithDefaultHasher(u) +} + +// HashTreeRootWith ssz hashes the UltrasoundStreamBid object with a hasher +func (u *UltrasoundStreamBid) HashTreeRootWith(hh ssz.HashWalker) (err error) { + indx := hh.Index() + + // Field (0) 'Timestamp' + hh.PutUint64(u.Timestamp) + + // Field (1) 'Slot' + hh.PutUint64(u.Slot) + + // Field (2) 'BlockNumber' + hh.PutUint64(u.BlockNumber) + + // Field (3) 'BlockHash' + hh.PutBytes(u.BlockHash[:]) + + // Field (4) 'ParentHash' + hh.PutBytes(u.ParentHash[:]) + + // Field (5) 'BuilderPubkey' + hh.PutBytes(u.BuilderPubkey[:]) + + // Field (6) 'FeeRecipient' + hh.PutBytes(u.FeeRecipient[:]) + + // Field (7) 'Value' + hh.PutBytes(u.Value[:]) + + hh.Merkleize(indx) + return +} + +// GetTree ssz hashes the UltrasoundStreamBid object +func (u *UltrasoundStreamBid) GetTree() (*ssz.Node, error) { + return ssz.ProofTree(u) +} diff --git a/common/ultrasoundbid_test.go b/common/ultrasoundbid_test.go new file mode 100644 index 0000000..6e053bd --- /dev/null +++ b/common/ultrasoundbid_test.go @@ -0,0 +1,21 @@ +package common + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/require" +) + +func TestUltrasoundBidSSZDecoding(t *testing.T) { + hex := "0x704b87ce8f010000a94b8c0000000000b6043101000000002c02b28fd8fdb45fd6ac43dd04adad1449a35b64247b1ed23a723a1fcf6cac074d0668c9e0912134628c32a54854b952234ebb6c1fdd6b053566ac2d2a09498da03b00ddb78b2c111450a5417a8c368c40f1f140cdf97d95b7fa9565467e0bbbe27877d08e01c69b4e5b02b144e6a265df99a0839818b3f120ebac9b73f82b617dc6a5556c71794b1a9c5400000000000000000000000000000000000000000000000000" + bytes := hexutil.MustDecode(hex) + bid := new(UltrasoundStreamBid) + err := bid.UnmarshalSSZ(bytes) + require.NoError(t, err) + + require.Equal(t, uint64(1717156924272), bid.Timestamp) + require.Equal(t, uint64(9194409), bid.Slot) + require.Equal(t, uint64(19989686), bid.BlockNumber) + require.Equal(t, "0x2c02b28fd8fdb45fd6ac43dd04adad1449a35b64247b1ed23a723a1fcf6cac07", hexutil.Encode(bid.BlockHash[:])) +} diff --git a/go.mod b/go.mod index 31f1c22..62f60a9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.22 require ( github.com/NYTimes/gziphandler v1.1.1 github.com/ethereum/go-ethereum v1.11.6 + github.com/ferranbt/fastssz v0.1.3 github.com/flashbots/go-boost-utils v1.6.0 github.com/flashbots/go-utils v0.4.9 github.com/flashbots/mev-boost-relay v1.0.0-alpha4.0.20230519091033-0453fc247553 @@ -42,7 +43,6 @@ require ( github.com/deckarep/golang-set/v2 v2.1.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/fatih/color v1.15.0 // indirect - github.com/ferranbt/fastssz v0.1.3 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect github.com/getsentry/sentry-go v0.21.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect diff --git a/services/bidstream/ultrasound-stream.go b/services/bidstream/ultrasound-stream.go index a707a0d..19d37e2 100644 --- a/services/bidstream/ultrasound-stream.go +++ b/services/bidstream/ultrasound-stream.go @@ -1,8 +1,11 @@ +// Package bidstream contains code for the ultrasound stream package bidstream import ( "time" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/flashbots/relayscan/common" "github.com/gorilla/websocket" "github.com/sirupsen/logrus" ) @@ -13,25 +16,8 @@ const ( maxBackoffSec = 120 ) -type UltrasoundStreamBid struct { - // pub slot: u64, - // pub block_number: u64, - // pub block_hash: B256, - // pub parent_hash: B256, - // pub builder_pubkey: BlsPublicKey, - // pub fee_recipient: Address, - // pub value: U256, - Timestamp uint64 `json:"timestamp"` - BlockNumber uint64 `json:"block_number"` - BlockHash string `json:"block_hash"` - ParentHash string `json:"parent_hash"` - BuilderPubkey string `json:"builder_pubkey"` - FeeRecipient string `json:"fee_recipient"` - Value string `json:"value"` -} - type UltrasoundStreamOpts struct { - BidC chan UltrasoundStreamBid + BidC chan common.UltrasoundStreamBid Log *logrus.Entry URL string // optional override, default: ultrasoundStreamDefaultURL } @@ -45,7 +31,7 @@ func StartUltrasoundStreamConnection(opts UltrasoundStreamOpts) { type UltrasoundStreamConnection struct { log *logrus.Entry url string - bidC chan UltrasoundStreamBid + bidC chan common.UltrasoundStreamBid backoffSec int } @@ -58,6 +44,7 @@ func NewUltrasoundStreamConnection(opts UltrasoundStreamOpts) *UltrasoundStreamC return &UltrasoundStreamConnection{ log: opts.Log, url: url, + bidC: opts.BidC, backoffSec: initialBackoffSec, } } @@ -96,6 +83,8 @@ func (nc *UltrasoundStreamConnection) connect() { nc.log.Info("ultrasound stream connection successful") nc.backoffSec = initialBackoffSec // reset backoff timeout + bid := new(common.UltrasoundStreamBid) + for { _, nextNotification, err := wsSubscriber.ReadMessage() if err != nil { @@ -105,6 +94,15 @@ func (nc *UltrasoundStreamConnection) connect() { return } - nc.log.WithField("msg", string(nextNotification)).Info("got message") + // nc.log.WithField("msg", hexutil.Encode(nextNotification)).Info("got message from ultrasound stream") + + // Unmarshal SSZ + err = bid.UnmarshalSSZ(nextNotification) + if err != nil { + nc.log.WithError(err).WithField("msg", hexutil.Encode(nextNotification)).Error("failed to unmarshal ultrasound stream message") + continue + } + + nc.bidC <- *bid } } From e4f92c568efda19da400250b3836cb16f1a1b5ae Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 14:46:31 +0200 Subject: [PATCH 03/44] cleanup --- .gitignore | 1 + cmd/service/bidcollect.go | 129 +++++++++++++++++++++++++++++++ cmd/service/bidstream.go | 50 ------------ cmd/service/collect-live-bids.go | 2 +- cmd/service/service.go | 2 +- common/ultrasoundbid.go | 9 ++- common/ultrasoundbid_test.go | 9 +++ common/utils.go | 10 +++ docs/adr1-202405-bidstream.md | 36 +++++++++ 9 files changed, 195 insertions(+), 53 deletions(-) create mode 100644 cmd/service/bidcollect.go delete mode 100644 cmd/service/bidstream.go create mode 100644 docs/adr1-202405-bidstream.md diff --git a/.gitignore b/.gitignore index 36a6b08..4454f36 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ /static_dev/ /relayscan /deploy* +/test.csv \ No newline at end of file diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go new file mode 100644 index 0000000..ba42da4 --- /dev/null +++ b/cmd/service/bidcollect.go @@ -0,0 +1,129 @@ +package service + +/** + * https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md + */ + +import ( + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/flashbots/relayscan/common" + "github.com/flashbots/relayscan/services/bidstream" + "github.com/flashbots/relayscan/vars" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ( + collectUltrasoundStream bool + collectGetHeader bool + collectDataAPI bool + outFileCSV string + + csvHeader = "local_timestamp\ttimestamp\tslot\tblock_number\tblock_hash\tparent_hash\tbuilder_pubkey\tfee_recipient\tvalue\n" +) + +func init() { + bidCollectCmd.Flags().BoolVar(&collectUltrasoundStream, "ultrasound-stream", true, "use ultrasound top-bid stream") + bidCollectCmd.Flags().BoolVar(&collectGetHeader, "get-header", false, "use getHeader API") + bidCollectCmd.Flags().BoolVar(&collectDataAPI, "data-api", false, "use data API") + + // for getHeader + bidCollectCmd.Flags().StringVar(&beaconNodeURI, "beacon-uri", vars.DefaultBeaconURI, "beacon endpoint") + + // for saving to file + bidCollectCmd.Flags().StringVar(&outFileCSV, "out", "", "output file for CSV") +} + +var bidCollectCmd = &cobra.Command{ + Use: "bidcollect", + Short: "Collect bids", + Run: func(cmd *cobra.Command, args []string) { + var err error + var outF *os.File + bidC := make(chan common.UltrasoundStreamBid, 100) + + log.WithField("version", vars.Version).Info("starting bidcollect ...") + + if outFileCSV != "" { + log.Infof("writing to %s", outFileCSV) + outF, err = os.OpenFile(outFileCSV, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + log.WithError(err).WithField("filename", outFileCSV).Fatal("failed to open output file") + } + + fi, err := outF.Stat() + if err != nil { + log.WithError(err).Fatal("failed stat on output file") + } + + if fi.Size() == 0 { + _, err = fmt.Fprint(outF, csvHeader) + if err != nil { + log.WithError(err).Fatal("failed to write header to output file") + } + } + } + + if collectGetHeader { + log.Fatal("not yet implemented") + } + + if collectDataAPI { + log.Fatal("not yet implemented") + } + + if collectUltrasoundStream { + log.Info("using ultrasound stream") + + opts := bidstream.UltrasoundStreamOpts{ + Log: log, + BidC: bidC, + } + bidstream.StartUltrasoundStreamConnection(opts) + } + + done := make(chan os.Signal, 1) + signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) + + log.Info("Waiting...") + + for { + select { + case bid := <-bidC: + processBid(log, outF, bid) + + case <-done: + log.Info("bye ...") + return + } + } + }, +} + +func processBid(log *logrus.Entry, outF *os.File, bid common.UltrasoundStreamBid) { + blockHash := hexutil.Encode(bid.BlockHash[:]) + parentHash := hexutil.Encode(bid.ParentHash[:]) + builderPubkey := hexutil.Encode(bid.BuilderPubkey[:]) + feeRecipient := hexutil.Encode(bid.FeeRecipient[:]) + value := bid.Value.String() + + log.WithFields(logrus.Fields{ + "slot": bid.Slot, + "block_hash": blockHash, + "value": value, + }).Info("received bid") + + if outF != nil { + t := time.Now().UTC() + _, err := fmt.Fprintf(outF, "%d\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\n", t.UnixMilli(), bid.Timestamp, bid.Slot, bid.BlockNumber, blockHash, parentHash, builderPubkey, feeRecipient, value) + if err != nil { + log.WithError(err).Error("couldn't write bid to file") + } + } +} diff --git a/cmd/service/bidstream.go b/cmd/service/bidstream.go deleted file mode 100644 index 3712ebd..0000000 --- a/cmd/service/bidstream.go +++ /dev/null @@ -1,50 +0,0 @@ -package service - -/** - * https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md - */ - -import ( - "os" - "os/signal" - "syscall" - - "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/flashbots/relayscan/common" - "github.com/flashbots/relayscan/services/bidstream" - "github.com/flashbots/relayscan/vars" - "github.com/sirupsen/logrus" - "github.com/spf13/cobra" -) - -var bidStreamCmd = &cobra.Command{ - Use: "bidstream", - Short: "Stream bids", - Run: func(cmd *cobra.Command, args []string) { - log.WithField("version", vars.Version).Info("starting bidstream ...") - bidC := make(chan common.UltrasoundStreamBid, 100) - opts := bidstream.UltrasoundStreamOpts{ - Log: log, - BidC: bidC, - } - bidstream.StartUltrasoundStreamConnection(opts) - - done := make(chan os.Signal, 1) - signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) - - log.Info("Waiting...") - - for { - select { - case bid := <-bidC: - log.WithFields(logrus.Fields{ - "slot": bid.Slot, - "block_hash": hexutil.Encode(bid.BlockHash[:]), - }).Info("received bid") - case <-done: - log.Info("bye ...") - return - } - } - }, -} diff --git a/cmd/service/collect-live-bids.go b/cmd/service/collect-live-bids.go index ed5ae6d..8a376f1 100644 --- a/cmd/service/collect-live-bids.go +++ b/cmd/service/collect-live-bids.go @@ -14,7 +14,7 @@ func init() { var liveBidsCmd = &cobra.Command{ Use: "collect-live-bids", - Short: "On every slot, ask for live bids", + Short: "On every slot, ask for live bids (using getHeader)", Run: func(cmd *cobra.Command, args []string) { // Connect to Postgres db := database.MustConnectPostgres(log, vars.DefaultPostgresDSN) diff --git a/cmd/service/service.go b/cmd/service/service.go index 1f507ae..8d75727 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -22,5 +22,5 @@ var ServiceCmd = &cobra.Command{ func init() { ServiceCmd.AddCommand(websiteCmd) ServiceCmd.AddCommand(liveBidsCmd) - ServiceCmd.AddCommand(bidStreamCmd) + ServiceCmd.AddCommand(bidCollectCmd) } diff --git a/common/ultrasoundbid.go b/common/ultrasoundbid.go index 7923c7d..7a54d7c 100644 --- a/common/ultrasoundbid.go +++ b/common/ultrasoundbid.go @@ -1,5 +1,7 @@ package common +import "math/big" + // https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md type ( @@ -7,8 +9,13 @@ type ( Hash [32]byte PublicKey [48]byte Address [20]byte + U256 [32]byte ) +func (n *U256) String() string { + return new(big.Int).SetBytes(ReverseBytes(n[:])).String() +} + type UltrasoundStreamBid struct { Timestamp uint64 `json:"timestamp"` Slot uint64 `json:"slot"` @@ -17,5 +24,5 @@ type UltrasoundStreamBid struct { ParentHash Hash `json:"parent_hash" ssz-size:"32"` BuilderPubkey PublicKey `json:"builder_pubkey" ssz-size:"48"` FeeRecipient Address `json:"fee_recipient" ssz-size:"20"` - Value Hash `json:"value" ssz-size:"32"` + Value U256 `json:"value" ssz-size:"32"` } diff --git a/common/ultrasoundbid_test.go b/common/ultrasoundbid_test.go index 6e053bd..8314b97 100644 --- a/common/ultrasoundbid_test.go +++ b/common/ultrasoundbid_test.go @@ -1,12 +1,21 @@ package common import ( + "math/big" "testing" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/stretchr/testify/require" ) +func TestValueDecoding(t *testing.T) { + expected := "55539751698389157" + hex := "0xa558e5221c51c500000000000000000000000000000000000000000000000000" + hexBytes := hexutil.MustDecode(hex) + value := new(big.Int).SetBytes(ReverseBytes(hexBytes[:])).String() + require.Equal(t, expected, value) +} + func TestUltrasoundBidSSZDecoding(t *testing.T) { hex := "0x704b87ce8f010000a94b8c0000000000b6043101000000002c02b28fd8fdb45fd6ac43dd04adad1449a35b64247b1ed23a723a1fcf6cac074d0668c9e0912134628c32a54854b952234ebb6c1fdd6b053566ac2d2a09498da03b00ddb78b2c111450a5417a8c368c40f1f140cdf97d95b7fa9565467e0bbbe27877d08e01c69b4e5b02b144e6a265df99a0839818b3f120ebac9b73f82b617dc6a5556c71794b1a9c5400000000000000000000000000000000000000000000000000" bytes := hexutil.MustDecode(hex) diff --git a/common/utils.go b/common/utils.go index 1349fac..581611d 100644 --- a/common/utils.go +++ b/common/utils.go @@ -108,3 +108,13 @@ func MustConnectBeaconNode(log *logrus.Entry, beaconNodeURI string, allowSyncing } return bn, syncStatus.HeadSlot } + +func ReverseBytes(src []byte) []byte { + dst := make([]byte, len(src)) + copy(dst, src) + for i := len(dst)/2 - 1; i >= 0; i-- { + opp := len(dst) - 1 - i + dst[i], dst[opp] = dst[opp], dst[i] + } + return dst +} diff --git a/docs/adr1-202405-bidstream.md b/docs/adr1-202405-bidstream.md new file mode 100644 index 0000000..adc6c4b --- /dev/null +++ b/docs/adr1-202405-bidstream.md @@ -0,0 +1,36 @@ +# ADR for bid stream + +## Goal + +Relayscan should collect bids across relays: + +1. Ultrasound top-bid websocket stream (https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md) +2. getHeader polling +3. data API polling + +It should expose these as: + +1. A websocket/SSE stream +2. Parquet/CSV files + +## Status + +Run: + +``` +go run . service bidcollect --out test.csv +``` + +Done: + +- Ultrasound bid stream works +- Writing to single CSV works + +Next up: + +- dynamic + rotating csv files (like mempool dumpster, for daily files/rollover + combination of multiple collectors) +- getHeader + - some already implemented in [collect-live-bids.go](/cmd/service/collect-live-bids.go)) + - define query times +- data API queries + - relay-specific rate limits \ No newline at end of file From 75349a28bd0383669fd57fa8d78affe713c3adb0 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 15:38:27 +0200 Subject: [PATCH 04/44] cleanup + start DataApiPoller --- cmd/service/bidcollect.go | 9 ++++-- docs/adr1-202405-bidstream.md | 18 ++++++----- services/bidstream/data-api-polling.go | 38 ++++++++++++++++++++++ services/bidstream/ultrasound-stream.go | 42 ++++++++++++------------- 4 files changed, 77 insertions(+), 30 deletions(-) create mode 100644 services/bidstream/data-api-polling.go diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index ba42da4..0bb82a4 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -29,7 +29,7 @@ var ( ) func init() { - bidCollectCmd.Flags().BoolVar(&collectUltrasoundStream, "ultrasound-stream", true, "use ultrasound top-bid stream") + bidCollectCmd.Flags().BoolVar(&collectUltrasoundStream, "ultrasound-stream", false, "use ultrasound top-bid stream") bidCollectCmd.Flags().BoolVar(&collectGetHeader, "get-header", false, "use getHeader API") bidCollectCmd.Flags().BoolVar(&collectDataAPI, "data-api", false, "use data API") @@ -75,7 +75,12 @@ var bidCollectCmd = &cobra.Command{ } if collectDataAPI { - log.Fatal("not yet implemented") + poller := bidstream.NewDataAPIPoller(&bidstream.DataAPIPollerOpts{ + Log: log, + BidC: bidC, + }) + go poller.Start() + // log.Fatal("not yet implemented") } if collectUltrasoundStream { diff --git a/docs/adr1-202405-bidstream.md b/docs/adr1-202405-bidstream.md index adc6c4b..26a993b 100644 --- a/docs/adr1-202405-bidstream.md +++ b/docs/adr1-202405-bidstream.md @@ -18,19 +18,23 @@ It should expose these as: Run: ``` -go run . service bidcollect --out test.csv +go run . service bidcollect --out test.csv --ultrasound-stream ``` Done: -- Ultrasound bid stream works -- Writing to single CSV works +- Ultrasound bid stream +- Writing to single CSV Next up: -- dynamic + rotating csv files (like mempool dumpster, for daily files/rollover + combination of multiple collectors) -- getHeader +- outputs + - CSV: dynamic + rotating csv files (like mempool dumpster, for daily files/rollover + combination of multiple collectors) + - stream +- getHeader polling - some already implemented in [collect-live-bids.go](/cmd/service/collect-live-bids.go)) - define query times -- data API queries - - relay-specific rate limits \ No newline at end of file +- data API polling + - relay-specific rate limits +- Collect which source the data is coming from +- Cache: ignoring duplicates \ No newline at end of file diff --git a/services/bidstream/data-api-polling.go b/services/bidstream/data-api-polling.go new file mode 100644 index 0000000..31d588e --- /dev/null +++ b/services/bidstream/data-api-polling.go @@ -0,0 +1,38 @@ +package bidstream + +import ( + "time" + + "github.com/flashbots/relayscan/common" + "github.com/sirupsen/logrus" +) + +type DataAPIPollerOpts struct { + Log *logrus.Entry + BidC chan common.UltrasoundStreamBid +} + +type DataAPIPoller struct { + Log *logrus.Entry + BidC chan common.UltrasoundStreamBid +} + +func NewDataAPIPoller(opts *DataAPIPollerOpts) *DataAPIPoller { + return &DataAPIPoller{ + Log: opts.Log, + BidC: opts.BidC, + } +} + +func (poller *DataAPIPoller) Start() { + poller.Log.Info("Starting DataAPIPoller ...") + + t := time.Now().UTC() + slot := common.TimeToSlot(t) + + nextSlot := slot + 1 + tNextSlot := common.SlotToTime(nextSlot) + + untilNextSlot := tNextSlot.Sub(t) + poller.Log.Infof("current slot: %d / next slot: %d (%s) / time until: %s", slot, nextSlot, tNextSlot.String(), untilNextSlot.String()) +} diff --git a/services/bidstream/ultrasound-stream.go b/services/bidstream/ultrasound-stream.go index 19d37e2..b608b87 100644 --- a/services/bidstream/ultrasound-stream.go +++ b/services/bidstream/ultrasound-stream.go @@ -17,8 +17,8 @@ const ( ) type UltrasoundStreamOpts struct { - BidC chan common.UltrasoundStreamBid Log *logrus.Entry + BidC chan common.UltrasoundStreamBid URL string // optional override, default: ultrasoundStreamDefaultURL } @@ -49,39 +49,39 @@ func NewUltrasoundStreamConnection(opts UltrasoundStreamOpts) *UltrasoundStreamC } } -func (nc *UltrasoundStreamConnection) Start() { - nc.connect() +func (ustream *UltrasoundStreamConnection) Start() { + ustream.connect() } -func (nc *UltrasoundStreamConnection) reconnect() { - backoffDuration := time.Duration(nc.backoffSec) * time.Second - nc.log.Infof("reconnecting to ultrasound stream in %s sec ...", backoffDuration.String()) +func (ustream *UltrasoundStreamConnection) reconnect() { + backoffDuration := time.Duration(ustream.backoffSec) * time.Second + ustream.log.Infof("reconnecting to ultrasound stream in %s sec ...", backoffDuration.String()) time.Sleep(backoffDuration) // increase backoff timeout for next try - nc.backoffSec *= 2 - if nc.backoffSec > maxBackoffSec { - nc.backoffSec = maxBackoffSec + ustream.backoffSec *= 2 + if ustream.backoffSec > maxBackoffSec { + ustream.backoffSec = maxBackoffSec } - nc.connect() + ustream.connect() } -func (nc *UltrasoundStreamConnection) connect() { - nc.log.WithField("uri", nc.url).Info("connecting...") +func (ustream *UltrasoundStreamConnection) connect() { + ustream.log.WithField("uri", ustream.url).Info("connecting...") dialer := websocket.DefaultDialer - wsSubscriber, resp, err := dialer.Dial(nc.url, nil) + wsSubscriber, resp, err := dialer.Dial(ustream.url, nil) if err != nil { - nc.log.WithError(err).Error("failed to connect to bloxroute, reconnecting in a bit...") - go nc.reconnect() + ustream.log.WithError(err).Error("failed to connect to bloxroute, reconnecting in a bit...") + go ustream.reconnect() return } defer wsSubscriber.Close() defer resp.Body.Close() - nc.log.Info("ultrasound stream connection successful") - nc.backoffSec = initialBackoffSec // reset backoff timeout + ustream.log.Info("ultrasound stream connection successful") + ustream.backoffSec = initialBackoffSec // reset backoff timeout bid := new(common.UltrasoundStreamBid) @@ -89,8 +89,8 @@ func (nc *UltrasoundStreamConnection) connect() { _, nextNotification, err := wsSubscriber.ReadMessage() if err != nil { // Handle websocket errors, by closing and reconnecting. Errors seen previously: - nc.log.WithError(err).Error("ultrasound stream websocket error") - go nc.reconnect() + ustream.log.WithError(err).Error("ultrasound stream websocket error") + go ustream.reconnect() return } @@ -99,10 +99,10 @@ func (nc *UltrasoundStreamConnection) connect() { // Unmarshal SSZ err = bid.UnmarshalSSZ(nextNotification) if err != nil { - nc.log.WithError(err).WithField("msg", hexutil.Encode(nextNotification)).Error("failed to unmarshal ultrasound stream message") + ustream.log.WithError(err).WithField("msg", hexutil.Encode(nextNotification)).Error("failed to unmarshal ultrasound stream message") continue } - nc.bidC <- *bid + ustream.bidC <- *bid } } From b991f088900e9d07f7112ea20a7e4f43c78f371f Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 15:49:38 +0200 Subject: [PATCH 05/44] polling delay --- services/bidstream/data-api-polling.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/services/bidstream/data-api-polling.go b/services/bidstream/data-api-polling.go index 31d588e..348ae36 100644 --- a/services/bidstream/data-api-polling.go +++ b/services/bidstream/data-api-polling.go @@ -27,12 +27,24 @@ func NewDataAPIPoller(opts *DataAPIPollerOpts) *DataAPIPoller { func (poller *DataAPIPoller) Start() { poller.Log.Info("Starting DataAPIPoller ...") - t := time.Now().UTC() - slot := common.TimeToSlot(t) + for { + t := time.Now().UTC() + slot := common.TimeToSlot(t) - nextSlot := slot + 1 - tNextSlot := common.SlotToTime(nextSlot) + nextSlot := slot + 1 + tNextSlot := common.SlotToTime(nextSlot) - untilNextSlot := tNextSlot.Sub(t) - poller.Log.Infof("current slot: %d / next slot: %d (%s) / time until: %s", slot, nextSlot, tNextSlot.String(), untilNextSlot.String()) + untilNextSlot := tNextSlot.Sub(t) + poller.Log.Infof("[data-api poller] current slot: %d / next slot: %d (%s) / time until: %s", slot, nextSlot, tNextSlot.String(), untilNextSlot.String()) + + // wait until t=0ms of next slot + time.Sleep(untilNextSlot) + poller.pollRelaysForBids(nextSlot) + } +} + +func (poller *DataAPIPoller) pollRelaysForBids(slot uint64) { + tSlotStart := common.SlotToTime(slot) + untilSlot := tSlotStart.Sub(time.Now().UTC()) + poller.Log.Infof("[data-api poller] - polling data api for slot %d (t=%s)", slot, untilSlot.String()) } From 87d4c902fbfa26526c0c5f86efb66be767525730 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 16:06:28 +0200 Subject: [PATCH 06/44] cleanup --- docs/adr1-202405-bidstream.md | 6 +++++- services/bidstream/data-api-polling.go | 28 ++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/adr1-202405-bidstream.md b/docs/adr1-202405-bidstream.md index 26a993b..0d5d1e0 100644 --- a/docs/adr1-202405-bidstream.md +++ b/docs/adr1-202405-bidstream.md @@ -18,7 +18,11 @@ It should expose these as: Run: ``` -go run . service bidcollect --out test.csv --ultrasound-stream +# works: Ultrasound top-bid stream + + +# wip: Data API polling +go run . service bidcollect --out test.csv --data-api ``` Done: diff --git a/services/bidstream/data-api-polling.go b/services/bidstream/data-api-polling.go index 348ae36..8ffb0d9 100644 --- a/services/bidstream/data-api-polling.go +++ b/services/bidstream/data-api-polling.go @@ -37,14 +37,34 @@ func (poller *DataAPIPoller) Start() { untilNextSlot := tNextSlot.Sub(t) poller.Log.Infof("[data-api poller] current slot: %d / next slot: %d (%s) / time until: %s", slot, nextSlot, tNextSlot.String(), untilNextSlot.String()) - // wait until t=0ms of next slot + // poll at t-4, t-2, t=0, t=2 + go poller.pollRelaysForBids(nextSlot, -4) + go poller.pollRelaysForBids(nextSlot, -2) + go poller.pollRelaysForBids(nextSlot, 0) + go poller.pollRelaysForBids(nextSlot, 2) + + // wait until next slot time.Sleep(untilNextSlot) - poller.pollRelaysForBids(nextSlot) } } -func (poller *DataAPIPoller) pollRelaysForBids(slot uint64) { +func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { tSlotStart := common.SlotToTime(slot) + tStart := tSlotStart.Add(time.Duration(t) * time.Second) + waitTime := tStart.Sub(time.Now().UTC()) + + poller.Log.Infof("[data-api poller] - prepare polling for slot %d t %d (tSlotStart: %s, tStart: %s, waitTime: %s)", slot, t, tSlotStart.String(), tStart.String(), waitTime.String()) + if waitTime < 0 { + poller.Log.Warnf("[data-api poller] - waitTime is negative: %s", waitTime.String()) + return + } + + // Wait until expected time + time.Sleep(waitTime) + + // Poll for bids now untilSlot := tSlotStart.Sub(time.Now().UTC()) - poller.Log.Infof("[data-api poller] - polling data api for slot %d (t=%s)", slot, untilSlot.String()) + poller.Log.Infof("[data-api poller] - polling for slot %d at %d (tNow=%s)", slot, t, untilSlot.String()) + + poller.Log.Warn("not yet implemented: actually polling the relays for bids") } From 0b5adac5705073adc3f53ec2e453f54311026dbf Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 17:26:30 +0200 Subject: [PATCH 07/44] use relays --- cmd/service/bidcollect.go | 20 +++++++++++++++++--- common/relayentry.go | 9 +++++++++ services/bidstream/data-api-polling.go | 26 ++++++++++++++++++-------- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 0bb82a4..9739f16 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -70,14 +70,28 @@ var bidCollectCmd = &cobra.Command{ } } + var relays []common.RelayEntry + if collectGetHeader || collectDataAPI { + relays, err = common.GetRelays() + if err != nil { + log.WithError(err).Fatal("failed to get relays") + } + + log.Infof("Using %d relays", len(relays)) + for index, relay := range relays { + log.Infof("- relay #%d: %s", index+1, relay.Hostname()) + } + } + if collectGetHeader { log.Fatal("not yet implemented") } if collectDataAPI { poller := bidstream.NewDataAPIPoller(&bidstream.DataAPIPollerOpts{ - Log: log, - BidC: bidC, + Log: log, + BidC: bidC, + Relays: relays, }) go poller.Start() // log.Fatal("not yet implemented") @@ -96,7 +110,7 @@ var bidCollectCmd = &cobra.Command{ done := make(chan os.Signal, 1) signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) - log.Info("Waiting...") + log.Info("Starting bid collection ...") for { select { diff --git a/common/relayentry.go b/common/relayentry.go index 892a8a9..ae395b3 100644 --- a/common/relayentry.go +++ b/common/relayentry.go @@ -61,6 +61,15 @@ func RelayEntriesToStrings(relays []RelayEntry) []string { return ret } +// RelayEntriesToHostnameStrings returns the hostnames of a list of relay entries +func RelayEntriesToHostnameStrings(relays []RelayEntry) []string { + ret := make([]string, len(relays)) + for i, entry := range relays { + ret[i] = entry.Hostname() + } + return ret +} + func GetRelays() ([]RelayEntry, error) { var err error relays := make([]RelayEntry, len(vars.RelayURLs)) diff --git a/services/bidstream/data-api-polling.go b/services/bidstream/data-api-polling.go index 8ffb0d9..f99fd79 100644 --- a/services/bidstream/data-api-polling.go +++ b/services/bidstream/data-api-polling.go @@ -8,24 +8,27 @@ import ( ) type DataAPIPollerOpts struct { - Log *logrus.Entry - BidC chan common.UltrasoundStreamBid + Log *logrus.Entry + BidC chan common.UltrasoundStreamBid + Relays []common.RelayEntry } type DataAPIPoller struct { - Log *logrus.Entry - BidC chan common.UltrasoundStreamBid + Log *logrus.Entry + BidC chan common.UltrasoundStreamBid + Relays []common.RelayEntry } func NewDataAPIPoller(opts *DataAPIPollerOpts) *DataAPIPoller { return &DataAPIPoller{ - Log: opts.Log, - BidC: opts.BidC, + Log: opts.Log, + BidC: opts.BidC, + Relays: opts.Relays, } } func (poller *DataAPIPoller) Start() { - poller.Log.Info("Starting DataAPIPoller ...") + poller.Log.WithField("relays", common.RelayEntriesToHostnameStrings(poller.Relays)).Info("Starting DataAPIPoller ...") for { t := time.Now().UTC() @@ -48,6 +51,7 @@ func (poller *DataAPIPoller) Start() { } } +// pollRelaysForBids will poll data api for given slot with t seconds offset func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { tSlotStart := common.SlotToTime(slot) tStart := tSlotStart.Add(time.Duration(t) * time.Second) @@ -66,5 +70,11 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { untilSlot := tSlotStart.Sub(time.Now().UTC()) poller.Log.Infof("[data-api poller] - polling for slot %d at %d (tNow=%s)", slot, t, untilSlot.String()) - poller.Log.Warn("not yet implemented: actually polling the relays for bids") + for _, relay := range poller.Relays { + go poller._pollRelayForBids(slot, relay) + } +} + +func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEntry) { + poller.Log.Infof("[data-api poller] - polling relay %s for slot %d", relay.Hostname(), slot) } From f80c6da8f2daaf1b38f67aa9723ffa1d67148108 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 19:44:33 +0200 Subject: [PATCH 08/44] actally request data api --- cmd/service/bidcollect.go | 4 ++- common/request.go | 8 +++++- common/utils.go | 12 +++++++++ services/bidstream/data-api-polling.go | 24 +++++++++++++++++- vars/relays.go | 34 ++++++++++++++------------ 5 files changed, 64 insertions(+), 18 deletions(-) diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 9739f16..7e39cfc 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -72,7 +72,9 @@ var bidCollectCmd = &cobra.Command{ var relays []common.RelayEntry if collectGetHeader || collectDataAPI { - relays, err = common.GetRelays() + relay, err := common.NewRelayEntry(vars.RelayUltrasound, false) + relays = []common.RelayEntry{relay} + // relays, err = common.GetRelays() if err != nil { log.WithError(err).Fatal("failed to get relays") } diff --git a/common/request.go b/common/request.go index 289d6b8..06faa80 100644 --- a/common/request.go +++ b/common/request.go @@ -55,7 +55,13 @@ func SendHTTPRequest(ctx context.Context, client http.Client, method, url string return resp.StatusCode, fmt.Errorf("%w: %d / %s", errHTTPErrorResponse, resp.StatusCode, string(bodyBytes)) } - if dst != nil { + if dst == nil { + // still read the body to reuse http connection (see also https://stackoverflow.com/a/17953506) + _, err = io.Copy(io.Discard, resp.Body) + if err != nil { + return resp.StatusCode, fmt.Errorf("could not read response body: %w", err) + } + } else { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { return resp.StatusCode, fmt.Errorf("could not read response body: %w", err) diff --git a/common/utils.go b/common/utils.go index 581611d..cb5acd0 100644 --- a/common/utils.go +++ b/common/utils.go @@ -26,6 +26,18 @@ func GetURI(url *url.URL, path string) string { return u2.String() } +func GetURIWithQuery(url *url.URL, path string, queryArgs map[string]string) string { + u2 := *url + u2.User = nil + u2.Path = path + q := u2.Query() + for key, value := range queryArgs { + q.Add(key, value) + } + u2.RawQuery = q.Encode() + return u2.String() +} + func EthToWei(eth *big.Int) *big.Float { if eth == nil { return big.NewFloat(0) diff --git a/services/bidstream/data-api-polling.go b/services/bidstream/data-api-polling.go index f99fd79..25b9f2d 100644 --- a/services/bidstream/data-api-polling.go +++ b/services/bidstream/data-api-polling.go @@ -1,8 +1,12 @@ package bidstream import ( + "context" + "fmt" + "net/http" "time" + relaycommon "github.com/flashbots/mev-boost-relay/common" "github.com/flashbots/relayscan/common" "github.com/sirupsen/logrus" ) @@ -76,5 +80,23 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { } func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEntry) { - poller.Log.Infof("[data-api poller] - polling relay %s for slot %d", relay.Hostname(), slot) + log := poller.Log.WithField("relay", relay.Hostname()).WithField("slot", slot) + log.Infof("[data-api poller] - polling relay %s for slot %d", relay.Hostname(), slot) + + // build query URL + path := "/relay/v1/data/bidtraces/builder_blocks_received" + url := common.GetURIWithQuery(relay.URL, path, map[string]string{"slot": fmt.Sprintf("%d", slot)}) + log.Infof("[data-api poller] Querying %s", url) + + // start query + var data []relaycommon.BidTraceV2JSON + timeRequestStart := time.Now().UTC() + code, err := common.SendHTTPRequest(context.Background(), *http.DefaultClient, http.MethodGet, url, nil, &data) + timeRequestEnd := time.Now().UTC() + if err != nil { + log.WithError(err).Error("[data-api poller] - failed to get data") + return + } + log = log.WithFields(logrus.Fields{"code": code, "entries": len(data), "duration": timeRequestEnd.Sub(timeRequestStart).String()}) + log.Info("[data-api poller] data API request complete") } diff --git a/vars/relays.go b/vars/relays.go index db0404c..7a52c19 100644 --- a/vars/relays.go +++ b/vars/relays.go @@ -1,17 +1,21 @@ package vars -var RelayURLs = []string{ - "https://0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae@boost-relay.flashbots.net", - "https://0x8b5d2e73e2a3a55c6c87b8b6eb92e0149a125c852751db1422fa951e42a09b82c142c3ea98d0d9930b056a3bc9896b8f@bloxroute.max-profit.blxrbdn.com", - "https://0xb0b07cd0abef743db4260b0ed50619cf6ad4d82064cb4fbec9d3ec530f7c5e6793d9f286c4e082c0244ffb9f2658fe88@bloxroute.regulated.blxrbdn.com", - "https://0xb3ee7afcf27f1f1259ac1787876318c6584ee353097a50ed84f51a1f21a323b3736f271a895c7ce918c038e4265918be@relay.edennetwork.io", - "https://0x98650451ba02064f7b000f5768cf0cf4d4e492317d82871bdc87ef841a0743f69f0f1eea11168503240ac35d101c9135@mainnet-relay.securerpc.com", - "https://0xa1559ace749633b997cb3fdacffb890aeebdb0f5a3b6aaa7eeeaf1a38af0a8fe88b9e4b1f61f236d2e64d95733327a62@relay.ultrasound.money", - "https://0xa7ab7a996c8584251c8f925da3170bdfd6ebc75d50f5ddc4050a6fdc77f2a3b5fce2cc750d0865e05d7228af97d69561@agnostic-relay.net", - "https://0xa15b52576bcbf1072f4a011c0f99f9fb6c66f3e1ff321f11f461d15e31b1cb359caa092c71bbded0bae5b5ea401aab7e@aestus.live", - "https://0x8c7d33605ecef85403f8b7289c8058f440cbb6bf72b055dfe2f3e2c6695b6a1ea5a9cd0eb3a7982927a463feb4c3dae2@relay.wenmerge.com", - "https://0x95a0a6af2566fa7db732020bb2724be61963ac1eb760aa1046365eb443bd4e3cc0fba0265d40a2d81dd94366643e986a@blockspace.frontier.tech", - // "https://0xad0a8bb54565c2211cee576363f3a347089d2f07cf72679d16911d740262694cadb62d7fd7483f27afd714ca0f1b9118@bloxroute.ethical.blxrbdn.com", // deactivated aug 2023: https://twitter.com/bloXrouteLabs/status/1690065892778926080 - // "https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@builder-relay-mainnet.blocknative.com", // deactivated sept. 27, 2023: https://twitter.com/blocknative/status/1706685103286485364 - "https://0x8c4ed5e24fe5c6ae21018437bde147693f68cda427cd1122cf20819c30eda7ed74f72dece09bb313f2a1855595ab677d@titanrelay.xyz", // added 2024-02-22 -} +var ( + RelayFlashbots = "https://0xac6e77dfe25ecd6110b8e780608cce0dab71fdd5ebea22a16c0205200f2f8e2e3ad3b71d3499c54ad14d6c21b41a37ae@boost-relay.flashbots.net" + RelayUltrasound = "https://0xa1559ace749633b997cb3fdacffb890aeebdb0f5a3b6aaa7eeeaf1a38af0a8fe88b9e4b1f61f236d2e64d95733327a62@relay.ultrasound.money" + RelayURLs = []string{ + RelayFlashbots, + RelayUltrasound, + "https://0x8b5d2e73e2a3a55c6c87b8b6eb92e0149a125c852751db1422fa951e42a09b82c142c3ea98d0d9930b056a3bc9896b8f@bloxroute.max-profit.blxrbdn.com", + "https://0xb0b07cd0abef743db4260b0ed50619cf6ad4d82064cb4fbec9d3ec530f7c5e6793d9f286c4e082c0244ffb9f2658fe88@bloxroute.regulated.blxrbdn.com", + "https://0xb3ee7afcf27f1f1259ac1787876318c6584ee353097a50ed84f51a1f21a323b3736f271a895c7ce918c038e4265918be@relay.edennetwork.io", + "https://0x98650451ba02064f7b000f5768cf0cf4d4e492317d82871bdc87ef841a0743f69f0f1eea11168503240ac35d101c9135@mainnet-relay.securerpc.com", + "https://0xa7ab7a996c8584251c8f925da3170bdfd6ebc75d50f5ddc4050a6fdc77f2a3b5fce2cc750d0865e05d7228af97d69561@agnostic-relay.net", + "https://0xa15b52576bcbf1072f4a011c0f99f9fb6c66f3e1ff321f11f461d15e31b1cb359caa092c71bbded0bae5b5ea401aab7e@aestus.live", + "https://0x8c7d33605ecef85403f8b7289c8058f440cbb6bf72b055dfe2f3e2c6695b6a1ea5a9cd0eb3a7982927a463feb4c3dae2@relay.wenmerge.com", + "https://0x95a0a6af2566fa7db732020bb2724be61963ac1eb760aa1046365eb443bd4e3cc0fba0265d40a2d81dd94366643e986a@blockspace.frontier.tech", + // "https://0xad0a8bb54565c2211cee576363f3a347089d2f07cf72679d16911d740262694cadb62d7fd7483f27afd714ca0f1b9118@bloxroute.ethical.blxrbdn.com", // deactivated aug 2023: https://twitter.com/bloXrouteLabs/status/1690065892778926080 + // "https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@builder-relay-mainnet.blocknative.com", // deactivated sept. 27, 2023: https://twitter.com/blocknative/status/1706685103286485364 + "https://0x8c4ed5e24fe5c6ae21018437bde147693f68cda427cd1122cf20819c30eda7ed74f72dece09bb313f2a1855595ab677d@titanrelay.xyz", // added 2024-02-22 + } +) From 2827d2f486746a5d30a75860a1472b2b88efe226 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 21:18:04 +0200 Subject: [PATCH 09/44] commonbid --- cmd/service/bidcollect.go | 127 +++--------------- services/bidcollect/bidcollector.go | 114 ++++++++++++++++ .../data-api-poller.go} | 17 ++- services/bidcollect/types.go | 125 +++++++++++++++++ .../ultrasound-stream.go | 11 +- 5 files changed, 274 insertions(+), 120 deletions(-) create mode 100644 services/bidcollect/bidcollector.go rename services/{bidstream/data-api-polling.go => bidcollect/data-api-poller.go} (88%) create mode 100644 services/bidcollect/types.go rename services/{bidstream => bidcollect}/ultrasound-stream.go (86%) diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 7e39cfc..3395ace 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -5,17 +5,9 @@ package service */ import ( - "fmt" - "os" - "os/signal" - "syscall" - "time" - - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/flashbots/relayscan/common" - "github.com/flashbots/relayscan/services/bidstream" + "github.com/flashbots/relayscan/services/bidcollect" "github.com/flashbots/relayscan/vars" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -24,8 +16,6 @@ var ( collectGetHeader bool collectDataAPI bool outFileCSV string - - csvHeader = "local_timestamp\ttimestamp\tslot\tblock_number\tblock_hash\tparent_hash\tbuilder_pubkey\tfee_recipient\tvalue\n" ) func init() { @@ -44,107 +34,30 @@ var bidCollectCmd = &cobra.Command{ Use: "bidcollect", Short: "Collect bids", Run: func(cmd *cobra.Command, args []string) { - var err error - var outF *os.File - bidC := make(chan common.UltrasoundStreamBid, 100) - - log.WithField("version", vars.Version).Info("starting bidcollect ...") - - if outFileCSV != "" { - log.Infof("writing to %s", outFileCSV) - outF, err = os.OpenFile(outFileCSV, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - log.WithError(err).WithField("filename", outFileCSV).Fatal("failed to open output file") - } - - fi, err := outF.Stat() - if err != nil { - log.WithError(err).Fatal("failed stat on output file") - } - - if fi.Size() == 0 { - _, err = fmt.Fprint(outF, csvHeader) - if err != nil { - log.WithError(err).Fatal("failed to write header to output file") - } - } - } - - var relays []common.RelayEntry - if collectGetHeader || collectDataAPI { - relay, err := common.NewRelayEntry(vars.RelayUltrasound, false) - relays = []common.RelayEntry{relay} - // relays, err = common.GetRelays() - if err != nil { - log.WithError(err).Fatal("failed to get relays") - } - - log.Infof("Using %d relays", len(relays)) - for index, relay := range relays { - log.Infof("- relay #%d: %s", index+1, relay.Hostname()) - } + // Prepare relays + relay, err := common.NewRelayEntry(vars.RelayUltrasound, false) + relays := []common.RelayEntry{relay} + // relays, err = common.GetRelays() + if err != nil { + log.WithError(err).Fatal("failed to get relays") } - if collectGetHeader { - log.Fatal("not yet implemented") + log.Infof("Using %d relays", len(relays)) + for index, relay := range relays { + log.Infof("- relay #%d: %s", index+1, relay.Hostname()) } - if collectDataAPI { - poller := bidstream.NewDataAPIPoller(&bidstream.DataAPIPollerOpts{ - Log: log, - BidC: bidC, - Relays: relays, - }) - go poller.Start() - // log.Fatal("not yet implemented") + opts := bidcollect.BidCollectorOpts{ + Log: log, + Relays: relays, + CollectUltrasoundStream: collectUltrasoundStream, + CollectGetHeader: collectGetHeader, + CollectDataAPI: collectDataAPI, + BeaconNodeURI: beaconNodeURI, + OutFile: outFileCSV, } - if collectUltrasoundStream { - log.Info("using ultrasound stream") - - opts := bidstream.UltrasoundStreamOpts{ - Log: log, - BidC: bidC, - } - bidstream.StartUltrasoundStreamConnection(opts) - } - - done := make(chan os.Signal, 1) - signal.Notify(done, syscall.SIGINT, syscall.SIGTERM) - - log.Info("Starting bid collection ...") - - for { - select { - case bid := <-bidC: - processBid(log, outF, bid) - - case <-done: - log.Info("bye ...") - return - } - } + bidCollector := bidcollect.NewBidCollector(&opts) + bidCollector.MustStart() }, } - -func processBid(log *logrus.Entry, outF *os.File, bid common.UltrasoundStreamBid) { - blockHash := hexutil.Encode(bid.BlockHash[:]) - parentHash := hexutil.Encode(bid.ParentHash[:]) - builderPubkey := hexutil.Encode(bid.BuilderPubkey[:]) - feeRecipient := hexutil.Encode(bid.FeeRecipient[:]) - value := bid.Value.String() - - log.WithFields(logrus.Fields{ - "slot": bid.Slot, - "block_hash": blockHash, - "value": value, - }).Info("received bid") - - if outF != nil { - t := time.Now().UTC() - _, err := fmt.Fprintf(outF, "%d\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\n", t.UnixMilli(), bid.Timestamp, bid.Slot, bid.BlockNumber, blockHash, parentHash, builderPubkey, feeRecipient, value) - if err != nil { - log.WithError(err).Error("couldn't write bid to file") - } - } -} diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go new file mode 100644 index 0000000..9277d02 --- /dev/null +++ b/services/bidcollect/bidcollector.go @@ -0,0 +1,114 @@ +// Package bidcollect contains code for bid collection from various sources. +package bidcollect + +import ( + "fmt" + "os" + "strings" + + "github.com/flashbots/relayscan/common" + "github.com/flashbots/relayscan/vars" + "github.com/sirupsen/logrus" +) + +type BidCollectorOpts struct { + Log *logrus.Entry + + CollectUltrasoundStream bool + CollectGetHeader bool + CollectDataAPI bool + + Relays []common.RelayEntry + BeaconNodeURI string // for getHeader + OutFile string +} + +type BidCollector struct { + opts *BidCollectorOpts + log *logrus.Entry + outF *os.File + + ultrasoundBidC chan common.UltrasoundStreamBid + dataAPIBidC chan DataAPIPollerBidsMsg + // getHeaderBidC chan DataAPIPollerBidsMsg +} + +func NewBidCollector(opts *BidCollectorOpts) *BidCollector { + c := &BidCollector{ + log: opts.Log, + opts: opts, + } + + c.dataAPIBidC = make(chan DataAPIPollerBidsMsg, 100) + c.ultrasoundBidC = make(chan common.UltrasoundStreamBid, 100) + return c +} + +func (c *BidCollector) MustStart() { + var err error + c.log.WithField("version", vars.Version).Info("Starting BidCollector ...") + + // Setup output file + if c.opts.OutFile != "" { + c.log.Infof("writing to %s", c.opts.OutFile) + c.outF, err = os.OpenFile(c.opts.OutFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + c.log.WithError(err).WithField("filename", c.opts.OutFile).Fatal("failed to open output file") + } + + fi, err := c.outF.Stat() + if err != nil { + c.log.WithError(err).Fatal("failed stat on output file") + } + + if fi.Size() == 0 { + _, err = fmt.Fprint(c.outF, strings.Join(CommonBidCSVFields, "\t")+"\n") + if err != nil { + c.log.WithError(err).Fatal("failed to write header to output file") + } + } + } + + if c.opts.CollectGetHeader { + c.log.Fatal("not yet implemented") + } + + if c.opts.CollectDataAPI { + poller := NewDataAPIPoller(&DataAPIPollerOpts{ + Log: c.log, + BidC: c.dataAPIBidC, + Relays: c.opts.Relays, + }) + go poller.Start() + } + + if c.opts.CollectUltrasoundStream { + ultrasoundStream := NewUltrasoundStreamConnection(UltrasoundStreamOpts{ + Log: c.log, + BidC: c.ultrasoundBidC, + }) + go ultrasoundStream.Start() + } + + for { + select { + case bid := <-c.ultrasoundBidC: + commonBid := UltrasoundStreamToCommonBid(&bid) + c.processBid(commonBid) + case bids := <-c.dataAPIBidC: + commonBids := DataAPIToCommonBids(bids) + for _, commonBid := range commonBids { + c.processBid(commonBid) + } + } + } +} + +func (c *BidCollector) processBid(bid *CommonBid) { + if c.outF != nil { + _, err := fmt.Fprint(c.outF, bid.ToCSVLine("\t")+"\n") + if err != nil { + c.log.WithError(err).Error("couldn't write bid to file") + } + } +} diff --git a/services/bidstream/data-api-polling.go b/services/bidcollect/data-api-poller.go similarity index 88% rename from services/bidstream/data-api-polling.go rename to services/bidcollect/data-api-poller.go index 25b9f2d..fb6c2fd 100644 --- a/services/bidstream/data-api-polling.go +++ b/services/bidcollect/data-api-poller.go @@ -1,4 +1,4 @@ -package bidstream +package bidcollect import ( "context" @@ -11,15 +11,21 @@ import ( "github.com/sirupsen/logrus" ) +type DataAPIPollerBidsMsg struct { + Bids []relaycommon.BidTraceV2WithTimestampJSON + Relay common.RelayEntry + ReceivedAt time.Time +} + type DataAPIPollerOpts struct { Log *logrus.Entry - BidC chan common.UltrasoundStreamBid + BidC chan DataAPIPollerBidsMsg Relays []common.RelayEntry } type DataAPIPoller struct { Log *logrus.Entry - BidC chan common.UltrasoundStreamBid + BidC chan DataAPIPollerBidsMsg Relays []common.RelayEntry } @@ -89,7 +95,7 @@ func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEn log.Infof("[data-api poller] Querying %s", url) // start query - var data []relaycommon.BidTraceV2JSON + var data []relaycommon.BidTraceV2WithTimestampJSON timeRequestStart := time.Now().UTC() code, err := common.SendHTTPRequest(context.Background(), *http.DefaultClient, http.MethodGet, url, nil, &data) timeRequestEnd := time.Now().UTC() @@ -99,4 +105,7 @@ func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEn } log = log.WithFields(logrus.Fields{"code": code, "entries": len(data), "duration": timeRequestEnd.Sub(timeRequestStart).String()}) log.Info("[data-api poller] data API request complete") + + // send data to channel + poller.BidC <- DataAPIPollerBidsMsg{Bids: data, Relay: relay, ReceivedAt: time.Now().UTC()} } diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go new file mode 100644 index 0000000..7ffaa72 --- /dev/null +++ b/services/bidcollect/types.go @@ -0,0 +1,125 @@ +package bidcollect + +import ( + "fmt" + "strings" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/flashbots/relayscan/common" +) + +// iota +const ( + CollectGetHeader = iota + CollectDataAPI + CollectUltrasoundStream +) + +var CommonBidCSVFields = []string{ + "source", "received_at", + "timestamp", "slot", "block_number", "block_hash", "parent_hash", "builder_pubkey", "value", + "block_fee_recipient", + "relay", "timestamp_ms", "proposer_pubkey", "proposer_fee_recipient", "optimistic_submission", +} + +type CommonBid struct { + // Collector-internal fields + Source int `json:"source"` + ReceivedAt int64 `json:"received_at"` + + // Common fields + Timestamp int64 `json:"timestamp"` + Slot uint64 `json:"slot"` + BlockNumber uint64 `json:"block_number"` + BlockHash string `json:"block_hash"` + ParentHash string `json:"parent_hash"` + BuilderPubkey string `json:"builder_pubkey"` + Value string `json:"value"` + + // Ultrasound top-bid stream - https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md + BlockFeeRecipient string `json:"block_fee_recipient"` + + // Data API + // - Ultrasound: https://relay-analytics.ultrasound.money/relay/v1/data/bidtraces/builder_blocks_received?slot=9194844 + // - Flashbots: https://boost-relay.flashbots.net/relay/v1/data/bidtraces/builder_blocks_received?slot=8969837 + Relay string `json:"relay"` + TimestampMs int64 `json:"timestamp_ms"` + ProposerPubkey string `json:"proposer_pubkey"` + ProposerFeeRecipient string `json:"proposer_fee_recipient"` + OptimisticSubmission bool `json:"optimistic_submission"` + + // getHeader +} + +func (bid *CommonBid) ToCSVFields() []string { + return []string{ + // Collector-internal fields + fmt.Sprint(bid.Source), fmt.Sprint(bid.ReceivedAt), + + // Common fields + fmt.Sprint(bid.Timestamp), fmt.Sprint(bid.Slot), fmt.Sprint(bid.BlockNumber), bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, bid.Value, + + // Ultrasound top-bid stream + bid.BlockFeeRecipient, + + // Data API + bid.Relay, fmt.Sprint(bid.TimestampMs), bid.ProposerPubkey, bid.ProposerFeeRecipient, boolToString(bid.OptimisticSubmission), + } +} + +func (bid *CommonBid) ToCSVLine(separator string) string { + return strings.Join(bid.ToCSVFields(), separator) +} + +func boolToString(b bool) string { + if b { + return "true" + } + return "false" +} + +func UltrasoundStreamToCommonBid(bid *common.UltrasoundStreamBid) *CommonBid { + blockHash := hexutil.Encode(bid.BlockHash[:]) + parentHash := hexutil.Encode(bid.ParentHash[:]) + builderPubkey := hexutil.Encode(bid.BuilderPubkey[:]) + blockFeeRecipient := hexutil.Encode(bid.FeeRecipient[:]) + + return &CommonBid{ + Source: CollectUltrasoundStream, + ReceivedAt: time.Now().UTC().Unix(), + + Timestamp: int64(bid.Timestamp), + Slot: bid.Slot, + BlockNumber: bid.BlockNumber, + BlockHash: blockHash, + ParentHash: parentHash, + BuilderPubkey: builderPubkey, + Value: bid.Value.String(), + BlockFeeRecipient: blockFeeRecipient, + } +} + +func DataAPIToCommonBids(bids DataAPIPollerBidsMsg) []*CommonBid { + commonBids := make([]*CommonBid, 0, len(bids.Bids)) + for _, bid := range bids.Bids { + commonBids = append(commonBids, &CommonBid{ + Source: CollectDataAPI, + ReceivedAt: bids.ReceivedAt.Unix(), + + Timestamp: bid.Timestamp, + Slot: bid.Slot, + BlockNumber: bid.BlockNumber, + BlockHash: bid.BlockHash, + ParentHash: bid.ParentHash, + BuilderPubkey: bid.BuilderPubkey, + Value: bid.Value, + Relay: bids.Relay.Hostname(), + TimestampMs: bid.TimestampMs, + ProposerPubkey: bid.ProposerPubkey, + ProposerFeeRecipient: bid.ProposerFeeRecipient, + OptimisticSubmission: bid.OptimisticSubmission, + }) + } + return commonBids +} diff --git a/services/bidstream/ultrasound-stream.go b/services/bidcollect/ultrasound-stream.go similarity index 86% rename from services/bidstream/ultrasound-stream.go rename to services/bidcollect/ultrasound-stream.go index b608b87..32e78a8 100644 --- a/services/bidstream/ultrasound-stream.go +++ b/services/bidcollect/ultrasound-stream.go @@ -1,5 +1,4 @@ -// Package bidstream contains code for the ultrasound stream -package bidstream +package bidcollect import ( "time" @@ -22,12 +21,6 @@ type UltrasoundStreamOpts struct { URL string // optional override, default: ultrasoundStreamDefaultURL } -// StartUltrasoundStreamConnection starts a Websocket or gRPC subscription (depending on URL) in the background -func StartUltrasoundStreamConnection(opts UltrasoundStreamOpts) { - ultrasoundStream := NewUltrasoundStreamConnection(opts) - go ultrasoundStream.Start() -} - type UltrasoundStreamConnection struct { log *logrus.Entry url string @@ -68,7 +61,7 @@ func (ustream *UltrasoundStreamConnection) reconnect() { } func (ustream *UltrasoundStreamConnection) connect() { - ustream.log.WithField("uri", ustream.url).Info("connecting...") + ustream.log.WithField("uri", ustream.url).Info("Starting Ultrasound bid stream...") dialer := websocket.DefaultDialer wsSubscriber, resp, err := dialer.Dial(ustream.url, nil) From 63094f14aa2148e9c9b860f9cd5ccd092afd215f Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 31 May 2024 21:28:08 +0200 Subject: [PATCH 10/44] cleanup --- docs/adr1-202405-bidstream.md | 17 +++++++++++------ services/bidcollect/bidcollector.go | 4 ++-- services/bidcollect/types.go | 23 +++++++++++------------ services/bidcollect/ultrasound-stream.go | 24 ++++++++++++++---------- 4 files changed, 38 insertions(+), 30 deletions(-) diff --git a/docs/adr1-202405-bidstream.md b/docs/adr1-202405-bidstream.md index 0d5d1e0..a428f10 100644 --- a/docs/adr1-202405-bidstream.md +++ b/docs/adr1-202405-bidstream.md @@ -18,27 +18,32 @@ It should expose these as: Run: ``` -# works: Ultrasound top-bid stream - +# Ultrasound top-bid stream +go run . service bidcollect --out test.csv --ultrasound-stream -# wip: Data API polling +# Data API polling go run . service bidcollect --out test.csv --data-api ``` Done: - Ultrasound bid stream +- Data API polling - Writing to single CSV Next up: - outputs + - like mempool dumpster, every N seconds print some stats - CSV: dynamic + rotating csv files (like mempool dumpster, for daily files/rollover + combination of multiple collectors) - - stream + - stream (websocket or SSE) + - getHeader polling - some already implemented in [collect-live-bids.go](/cmd/service/collect-live-bids.go)) - define query times + - data API polling + - Cache: ignoring duplicates - relay-specific rate limits -- Collect which source the data is coming from -- Cache: ignoring duplicates \ No newline at end of file + +- Collect which source the data is coming from \ No newline at end of file diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index 9277d02..46c3187 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -28,7 +28,7 @@ type BidCollector struct { log *logrus.Entry outF *os.File - ultrasoundBidC chan common.UltrasoundStreamBid + ultrasoundBidC chan UltrasoundStreamBidsMsg dataAPIBidC chan DataAPIPollerBidsMsg // getHeaderBidC chan DataAPIPollerBidsMsg } @@ -40,7 +40,7 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { } c.dataAPIBidC = make(chan DataAPIPollerBidsMsg, 100) - c.ultrasoundBidC = make(chan common.UltrasoundStreamBid, 100) + c.ultrasoundBidC = make(chan UltrasoundStreamBidsMsg, 100) return c } diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go index 7ffaa72..a8bcdca 100644 --- a/services/bidcollect/types.go +++ b/services/bidcollect/types.go @@ -3,10 +3,8 @@ package bidcollect import ( "fmt" "strings" - "time" "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/flashbots/relayscan/common" ) // iota @@ -79,24 +77,25 @@ func boolToString(b bool) string { return "false" } -func UltrasoundStreamToCommonBid(bid *common.UltrasoundStreamBid) *CommonBid { - blockHash := hexutil.Encode(bid.BlockHash[:]) - parentHash := hexutil.Encode(bid.ParentHash[:]) - builderPubkey := hexutil.Encode(bid.BuilderPubkey[:]) - blockFeeRecipient := hexutil.Encode(bid.FeeRecipient[:]) +func UltrasoundStreamToCommonBid(bid *UltrasoundStreamBidsMsg) *CommonBid { + blockHash := hexutil.Encode(bid.Bid.BlockHash[:]) + parentHash := hexutil.Encode(bid.Bid.ParentHash[:]) + builderPubkey := hexutil.Encode(bid.Bid.BuilderPubkey[:]) + blockFeeRecipient := hexutil.Encode(bid.Bid.FeeRecipient[:]) return &CommonBid{ Source: CollectUltrasoundStream, - ReceivedAt: time.Now().UTC().Unix(), + ReceivedAt: bid.ReceivedAt.Unix(), - Timestamp: int64(bid.Timestamp), - Slot: bid.Slot, - BlockNumber: bid.BlockNumber, + Timestamp: int64(bid.Bid.Timestamp), + Slot: bid.Bid.Slot, + BlockNumber: bid.Bid.BlockNumber, BlockHash: blockHash, ParentHash: parentHash, BuilderPubkey: builderPubkey, - Value: bid.Value.String(), + Value: bid.Bid.Value.String(), BlockFeeRecipient: blockFeeRecipient, + Relay: bid.Relay, } } diff --git a/services/bidcollect/ultrasound-stream.go b/services/bidcollect/ultrasound-stream.go index 32e78a8..9b965ff 100644 --- a/services/bidcollect/ultrasound-stream.go +++ b/services/bidcollect/ultrasound-stream.go @@ -15,28 +15,28 @@ const ( maxBackoffSec = 120 ) +type UltrasoundStreamBidsMsg struct { + Bid common.UltrasoundStreamBid + Relay string + ReceivedAt time.Time +} + type UltrasoundStreamOpts struct { Log *logrus.Entry - BidC chan common.UltrasoundStreamBid - URL string // optional override, default: ultrasoundStreamDefaultURL + BidC chan UltrasoundStreamBidsMsg } type UltrasoundStreamConnection struct { log *logrus.Entry url string - bidC chan common.UltrasoundStreamBid + bidC chan UltrasoundStreamBidsMsg backoffSec int } func NewUltrasoundStreamConnection(opts UltrasoundStreamOpts) *UltrasoundStreamConnection { - url := opts.URL - if url == "" { - url = ultrasoundStreamDefaultURL - } - return &UltrasoundStreamConnection{ log: opts.Log, - url: url, + url: ultrasoundStreamDefaultURL, bidC: opts.BidC, backoffSec: initialBackoffSec, } @@ -96,6 +96,10 @@ func (ustream *UltrasoundStreamConnection) connect() { continue } - ustream.bidC <- *bid + ustream.bidC <- UltrasoundStreamBidsMsg{ + Bid: *bid, + Relay: "relay.ultrasound.money", + ReceivedAt: time.Now().UTC(), + } } } From 345a89220616abf986e569ea77d1cb31c30b250b Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 15:38:48 +0200 Subject: [PATCH 11/44] BidProcessor --- .gitignore | 3 +- cmd/service/bidcollect.go | 6 +- services/bidcollect/bid-processor.go | 203 +++++++++++++++++++++++++++ services/bidcollect/bidcollector.go | 67 ++++----- services/bidcollect/types.go | 24 +++- 5 files changed, 254 insertions(+), 49 deletions(-) create mode 100644 services/bidcollect/bid-processor.go diff --git a/.gitignore b/.gitignore index 4454f36..87cb106 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,5 @@ /static_dev/ /relayscan /deploy* -/test.csv \ No newline at end of file +/test.csv +/csv/ \ No newline at end of file diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 3395ace..680f6c8 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -15,7 +15,7 @@ var ( collectUltrasoundStream bool collectGetHeader bool collectDataAPI bool - outFileCSV string + outDir string ) func init() { @@ -27,7 +27,7 @@ func init() { bidCollectCmd.Flags().StringVar(&beaconNodeURI, "beacon-uri", vars.DefaultBeaconURI, "beacon endpoint") // for saving to file - bidCollectCmd.Flags().StringVar(&outFileCSV, "out", "", "output file for CSV") + bidCollectCmd.Flags().StringVar(&outDir, "out", "csv", "output directory for CSV") } var bidCollectCmd = &cobra.Command{ @@ -54,7 +54,7 @@ var bidCollectCmd = &cobra.Command{ CollectGetHeader: collectGetHeader, CollectDataAPI: collectDataAPI, BeaconNodeURI: beaconNodeURI, - OutFile: outFileCSV, + OutDir: outDir, } bidCollector := bidcollect.NewBidCollector(&opts) diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go new file mode 100644 index 0000000..a729196 --- /dev/null +++ b/services/bidcollect/bid-processor.go @@ -0,0 +1,203 @@ +package bidcollect + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/flashbots/relayscan/common" + "github.com/sirupsen/logrus" +) + +// Goals: +// 1. Dedup bids +// 2. Save bids to CSV +// - One CSV for all bids +// - One CSV for top bids only + +type BidProcessorOpts struct { + Log *logrus.Entry + OutDir string +} + +type OutFiles struct { + FAll *os.File + FTop *os.File +} + +type BidProcessor struct { + opts *BidProcessorOpts + log *logrus.Entry + + outFiles map[int64]*OutFiles // map[slot][bidUniqueKey]Bid + outFilesLock sync.RWMutex + + bidCache map[uint64]map[string]*CommonBid // map[slot][bidUniqueKey]Bid + topBidCache map[uint64]*CommonBid // map[slot]Bid + bidCacheLock sync.RWMutex +} + +func NewBidProcessor(opts *BidProcessorOpts) *BidProcessor { + c := &BidProcessor{ + log: opts.Log, + opts: opts, + outFiles: make(map[int64]*OutFiles), + bidCache: make(map[uint64]map[string]*CommonBid), + topBidCache: make(map[uint64]*CommonBid), + } + + return c +} + +func (c *BidProcessor) Start() { + for { + c.cleanupBids() + time.Sleep(12 * time.Second) + } +} + +func (c *BidProcessor) processBids(bids []*CommonBid) { + c.bidCacheLock.Lock() + defer c.bidCacheLock.Unlock() + + for _, bid := range bids { + if _, ok := c.bidCache[bid.Slot]; !ok { + c.bidCache[bid.Slot] = make(map[string]*CommonBid) + } + + if topBid, ok := c.topBidCache[bid.Slot]; !ok { + c.topBidCache[bid.Slot] = bid + } else { + // if current bid has higher value, use it as new top bid + if bid.ValueAsBigInt().Cmp(topBid.ValueAsBigInt()) == 1 { + c.topBidCache[bid.Slot] = bid + c.exportTopBid(bid) + } + } + + if _, ok := c.bidCache[bid.Slot][bid.UniqueKey()]; !ok { + // yet unknown bid, save it + c.bidCache[bid.Slot][bid.UniqueKey()] = bid + c.exportBid(bid) + } + } +} + +func (c *BidProcessor) cleanupBids() { + c.log.Info("[BidOutput] cleanupBids ...") + currentSlot := common.TimeToSlot(time.Now().UTC()) + maxSlotInCache := currentSlot - 2 + nDeleted := 0 + + c.bidCacheLock.Lock() + defer c.bidCacheLock.Unlock() + for slot := range c.bidCache { + if slot < maxSlotInCache { + delete(c.bidCache, slot) + nDeleted += 1 + } + } + + c.log.Infof("[BidOutput] cleanupBids - deleted %d slots, total cache slots: %d", nDeleted, len(c.bidCache)) +} + +func (c *BidProcessor) exportBid(bid *CommonBid) { + outF, _, err := c.getFiles(bid) + if err != nil { + c.log.WithError(err).Error("get get output file") + } + _, err = fmt.Fprint(outF, bid.ToCSVLine("\t")+"\n") + if err != nil { + c.log.WithError(err).Error("couldn't write bid to file") + } +} + +func (c *BidProcessor) exportTopBid(bid *CommonBid) { + _, outF, err := c.getFiles(bid) + if err != nil { + c.log.WithError(err).Error("get get output file") + } + _, err = fmt.Fprint(outF, bid.ToCSVLine("\t")+"\n") + if err != nil { + c.log.WithError(err).Error("couldn't write bid to file") + } +} + +func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) { + // hourlybucket + sec := int64(60 * 60) + bucketTS := bid.ReceivedAt / sec * sec // timestamp down-round to start of bucket + // t := time.Unix(bucketTS, 0).UTC() + + // files may already be opened + c.outFilesLock.RLock() + outFiles, outFilesOk := c.outFiles[bucketTS] + c.outFilesLock.RUnlock() + + if outFilesOk { + return outFiles.FAll, outFiles.FTop, nil + } + + // Create output files + err = os.MkdirAll(c.opts.OutDir, os.ModePerm) + if err != nil { + return nil, nil, err + } + + // Open ALL BIDS CSV + fnAll := filepath.Join(c.opts.OutDir, c.getFilename("all", bucketTS)) + fAll, err = os.OpenFile(fnAll, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, nil, err + } + fi, err := fAll.Stat() + if err != nil { + c.log.WithError(err).Fatal("failed stat on output file") + } + if fi.Size() == 0 { + _, err = fmt.Fprint(fAll, strings.Join(CommonBidCSVFields, "\t")+"\n") + if err != nil { + c.log.WithError(err).Fatal("failed to write header to output file") + } + } + + // Open TOP BIDS CSV + fnTop := filepath.Join(c.opts.OutDir, c.getFilename("top", bucketTS)) + fTop, err = os.OpenFile(fnTop, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, nil, err + } + fi, err = fTop.Stat() + if err != nil { + c.log.WithError(err).Fatal("failed stat on output file") + } + if fi.Size() == 0 { + _, err = fmt.Fprint(fTop, strings.Join(CommonBidCSVFields, "\t")+"\n") + if err != nil { + c.log.WithError(err).Fatal("failed to write header to output file") + } + } + + outFiles = &OutFiles{ + FAll: fAll, + FTop: fTop, + } + c.outFilesLock.Lock() + c.outFiles[bucketTS] = outFiles + c.outFilesLock.Unlock() + + c.log.Infof("[BidOutput] created new output file: %s", fnAll) + c.log.Infof("[BidOutput] created new output file: %s", fnTop) + return fAll, fTop, nil +} + +func (c *BidProcessor) getFilename(prefix string, timestamp int64) string { + t := time.Unix(timestamp, 0).UTC() + if prefix != "" { + prefix += "_" + } + return fmt.Sprintf("%s%s.csv", prefix, t.Format("2006-01-02_15-04")) +} diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index 46c3187..2bfa50a 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -2,10 +2,6 @@ package bidcollect import ( - "fmt" - "os" - "strings" - "github.com/flashbots/relayscan/common" "github.com/flashbots/relayscan/vars" "github.com/sirupsen/logrus" @@ -20,17 +16,19 @@ type BidCollectorOpts struct { Relays []common.RelayEntry BeaconNodeURI string // for getHeader - OutFile string + OutDir string } type BidCollector struct { opts *BidCollectorOpts log *logrus.Entry - outF *os.File ultrasoundBidC chan UltrasoundStreamBidsMsg dataAPIBidC chan DataAPIPollerBidsMsg // getHeaderBidC chan DataAPIPollerBidsMsg + + processorC chan []CommonBid + processor *BidProcessor } func NewBidCollector(opts *BidCollectorOpts) *BidCollector { @@ -39,35 +37,26 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { opts: opts, } + if c.opts.OutDir == "" { + c.opts.OutDir = "csv" + } + + // inputs c.dataAPIBidC = make(chan DataAPIPollerBidsMsg, 100) c.ultrasoundBidC = make(chan UltrasoundStreamBidsMsg, 100) + + // output + c.processorC = make(chan []CommonBid, 100) + c.processor = NewBidProcessor(&BidProcessorOpts{ + Log: opts.Log, + OutDir: c.opts.OutDir, + }) return c } func (c *BidCollector) MustStart() { - var err error c.log.WithField("version", vars.Version).Info("Starting BidCollector ...") - - // Setup output file - if c.opts.OutFile != "" { - c.log.Infof("writing to %s", c.opts.OutFile) - c.outF, err = os.OpenFile(c.opts.OutFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) - if err != nil { - c.log.WithError(err).WithField("filename", c.opts.OutFile).Fatal("failed to open output file") - } - - fi, err := c.outF.Stat() - if err != nil { - c.log.WithError(err).Fatal("failed stat on output file") - } - - if fi.Size() == 0 { - _, err = fmt.Fprint(c.outF, strings.Join(CommonBidCSVFields, "\t")+"\n") - if err != nil { - c.log.WithError(err).Fatal("failed to write header to output file") - } - } - } + go c.processor.Start() if c.opts.CollectGetHeader { c.log.Fatal("not yet implemented") @@ -94,21 +83,19 @@ func (c *BidCollector) MustStart() { select { case bid := <-c.ultrasoundBidC: commonBid := UltrasoundStreamToCommonBid(&bid) - c.processBid(commonBid) + c.processor.processBids([]*CommonBid{commonBid}) case bids := <-c.dataAPIBidC: commonBids := DataAPIToCommonBids(bids) - for _, commonBid := range commonBids { - c.processBid(commonBid) - } + c.processor.processBids(commonBids) } } } -func (c *BidCollector) processBid(bid *CommonBid) { - if c.outF != nil { - _, err := fmt.Fprint(c.outF, bid.ToCSVLine("\t")+"\n") - if err != nil { - c.log.WithError(err).Error("couldn't write bid to file") - } - } -} +// func (c *BidCollector) processBid(bid *CommonBid) { +// if c.outF != nil { +// _, err := fmt.Fprint(c.outF, bid.ToCSVLine("\t")+"\n") +// if err != nil { +// c.log.WithError(err).Error("couldn't write bid to file") +// } +// } +// } diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go index a8bcdca..1771d89 100644 --- a/services/bidcollect/types.go +++ b/services/bidcollect/types.go @@ -2,6 +2,7 @@ package bidcollect import ( "fmt" + "math/big" "strings" "github.com/ethereum/go-ethereum/common/hexutil" @@ -16,9 +17,10 @@ const ( var CommonBidCSVFields = []string{ "source", "received_at", - "timestamp", "slot", "block_number", "block_hash", "parent_hash", "builder_pubkey", "value", + "timestamp", "timestamp_ms", + "slot", "block_number", "block_hash", "parent_hash", "builder_pubkey", "value", "block_fee_recipient", - "relay", "timestamp_ms", "proposer_pubkey", "proposer_fee_recipient", "optimistic_submission", + "relay", "proposer_pubkey", "proposer_fee_recipient", "optimistic_submission", } type CommonBid struct { @@ -50,19 +52,30 @@ type CommonBid struct { // getHeader } +func (bid *CommonBid) UniqueKey() string { + return fmt.Sprintf("%d-%s-%s-%s-%s", bid.Slot, bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, bid.Value) +} + +func (bid *CommonBid) ValueAsBigInt() *big.Int { + value := new(big.Int) + value.SetString(bid.Value, 10) + return value +} + func (bid *CommonBid) ToCSVFields() []string { return []string{ // Collector-internal fields fmt.Sprint(bid.Source), fmt.Sprint(bid.ReceivedAt), // Common fields - fmt.Sprint(bid.Timestamp), fmt.Sprint(bid.Slot), fmt.Sprint(bid.BlockNumber), bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, bid.Value, + fmt.Sprint(bid.Timestamp), fmt.Sprint(bid.TimestampMs), + fmt.Sprint(bid.Slot), fmt.Sprint(bid.BlockNumber), bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, bid.Value, // Ultrasound top-bid stream bid.BlockFeeRecipient, // Data API - bid.Relay, fmt.Sprint(bid.TimestampMs), bid.ProposerPubkey, bid.ProposerFeeRecipient, boolToString(bid.OptimisticSubmission), + bid.Relay, bid.ProposerPubkey, bid.ProposerFeeRecipient, boolToString(bid.OptimisticSubmission), } } @@ -87,7 +100,8 @@ func UltrasoundStreamToCommonBid(bid *UltrasoundStreamBidsMsg) *CommonBid { Source: CollectUltrasoundStream, ReceivedAt: bid.ReceivedAt.Unix(), - Timestamp: int64(bid.Bid.Timestamp), + Timestamp: int64(bid.Bid.Timestamp) / 1000, + TimestampMs: int64(bid.Bid.Timestamp), Slot: bid.Bid.Slot, BlockNumber: bid.Bid.BlockNumber, BlockHash: blockHash, From 4e2d6b695faaed2be79cadcfb0c7ef83352b3a3e Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 16:05:52 +0200 Subject: [PATCH 12/44] cleanups --- cmd/service/bidcollect.go | 12 +++++--- common/relayentry.go | 6 ++++ common/utils.go | 7 +++++ docs/adr1-202405-bidstream.md | 5 +-- services/bidcollect/bid-processor.go | 22 +++++++++----- services/bidcollect/bidcollector.go | 2 +- services/bidcollect/data-api-poller.go | 42 +++++++++++++++++--------- 7 files changed, 67 insertions(+), 29 deletions(-) diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 680f6c8..39b60e0 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -35,12 +35,14 @@ var bidCollectCmd = &cobra.Command{ Short: "Collect bids", Run: func(cmd *cobra.Command, args []string) { // Prepare relays - relay, err := common.NewRelayEntry(vars.RelayUltrasound, false) - relays := []common.RelayEntry{relay} - // relays, err = common.GetRelays() - if err != nil { - log.WithError(err).Fatal("failed to get relays") + relays := []common.RelayEntry{ + common.MustNewRelayEntry(vars.RelayFlashbots, false), + common.MustNewRelayEntry(vars.RelayUltrasound, false), } + // relays, err = common.GetRelays() + // if err != nil { + // log.WithError(err).Fatal("failed to get relays") + // } log.Infof("Using %d relays", len(relays)) for index, relay := range relays { diff --git a/common/relayentry.go b/common/relayentry.go index ae395b3..aa7f9d8 100644 --- a/common/relayentry.go +++ b/common/relayentry.go @@ -52,6 +52,12 @@ func NewRelayEntry(relayURL string, requireUser bool) (entry RelayEntry, err err return entry, err } +func MustNewRelayEntry(relayURL string, requireUser bool) (entry RelayEntry) { + entry, err := NewRelayEntry(relayURL, requireUser) + Check(err) + return entry +} + // RelayEntriesToStrings returns the string representation of a list of relay entries func RelayEntriesToStrings(relays []RelayEntry) []string { ret := make([]string, len(relays)) diff --git a/common/utils.go b/common/utils.go index cb5acd0..6740cac 100644 --- a/common/utils.go +++ b/common/utils.go @@ -4,6 +4,7 @@ package common import ( "math/big" "net/url" + "runtime" "time" "github.com/ethereum/go-ethereum/params" @@ -130,3 +131,9 @@ func ReverseBytes(src []byte) []byte { } return dst } + +func GetMemMB() uint64 { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return m.Alloc / 1024 / 1024 +} diff --git a/docs/adr1-202405-bidstream.md b/docs/adr1-202405-bidstream.md index a428f10..0c04693 100644 --- a/docs/adr1-202405-bidstream.md +++ b/docs/adr1-202405-bidstream.md @@ -29,7 +29,9 @@ Done: - Ultrasound bid stream - Data API polling -- Writing to single CSV +- Output + - Writing to csv for top and all bids + - Cache for deduplication Next up: @@ -43,7 +45,6 @@ Next up: - define query times - data API polling - - Cache: ignoring duplicates - relay-specific rate limits - Collect which source the data is coming from \ No newline at end of file diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go index a729196..8b13d0d 100644 --- a/services/bidcollect/bid-processor.go +++ b/services/bidcollect/bid-processor.go @@ -54,8 +54,8 @@ func NewBidProcessor(opts *BidProcessorOpts) *BidProcessor { func (c *BidProcessor) Start() { for { - c.cleanupBids() - time.Sleep(12 * time.Second) + time.Sleep(30 * time.Second) + c.housekeeping() } } @@ -86,11 +86,12 @@ func (c *BidProcessor) processBids(bids []*CommonBid) { } } -func (c *BidProcessor) cleanupBids() { - c.log.Info("[BidOutput] cleanupBids ...") +func (c *BidProcessor) housekeeping() { currentSlot := common.TimeToSlot(time.Now().UTC()) maxSlotInCache := currentSlot - 2 + nDeleted := 0 + nBids := 0 c.bidCacheLock.Lock() defer c.bidCacheLock.Unlock() @@ -98,20 +99,25 @@ func (c *BidProcessor) cleanupBids() { if slot < maxSlotInCache { delete(c.bidCache, slot) nDeleted += 1 + } else { + nBids += len(c.bidCache[slot]) } } - c.log.Infof("[BidOutput] cleanupBids - deleted %d slots, total cache slots: %d", nDeleted, len(c.bidCache)) + // todo: delete old files + c.log.Infof("[bid-processor] cleanupBids - deleted slots: %d / total slots: %d / total bids: %d / memUsedMB: %d", nDeleted, len(c.bidCache), nBids, common.GetMemMB()) } func (c *BidProcessor) exportBid(bid *CommonBid) { outF, _, err := c.getFiles(bid) if err != nil { c.log.WithError(err).Error("get get output file") + return } _, err = fmt.Fprint(outF, bid.ToCSVLine("\t")+"\n") if err != nil { c.log.WithError(err).Error("couldn't write bid to file") + return } } @@ -119,10 +125,12 @@ func (c *BidProcessor) exportTopBid(bid *CommonBid) { _, outF, err := c.getFiles(bid) if err != nil { c.log.WithError(err).Error("get get output file") + return } _, err = fmt.Fprint(outF, bid.ToCSVLine("\t")+"\n") if err != nil { c.log.WithError(err).Error("couldn't write bid to file") + return } } @@ -189,8 +197,8 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) c.outFiles[bucketTS] = outFiles c.outFilesLock.Unlock() - c.log.Infof("[BidOutput] created new output file: %s", fnAll) - c.log.Infof("[BidOutput] created new output file: %s", fnTop) + c.log.Infof("[bid-processor] created output file: %s", fnAll) + c.log.Infof("[bid-processor] created output file: %s", fnTop) return fAll, fTop, nil } diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index 2bfa50a..0dece77 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -49,7 +49,7 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { c.processorC = make(chan []CommonBid, 100) c.processor = NewBidProcessor(&BidProcessorOpts{ Log: opts.Log, - OutDir: c.opts.OutDir, + OutDir: "csv", }) return c } diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index fb6c2fd..c4e2d0e 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -40,23 +40,32 @@ func NewDataAPIPoller(opts *DataAPIPollerOpts) *DataAPIPoller { func (poller *DataAPIPoller) Start() { poller.Log.WithField("relays", common.RelayEntriesToHostnameStrings(poller.Relays)).Info("Starting DataAPIPoller ...") + // initially, wait until start of next slot + t := time.Now().UTC() + slot := common.TimeToSlot(t) + nextSlot := slot + 1 + tNextSlot := common.SlotToTime(nextSlot) + untilNextSlot := tNextSlot.Sub(t) + time.Sleep(untilNextSlot) + + // then run polling loop for { + // calculate next slot details t := time.Now().UTC() slot := common.TimeToSlot(t) - nextSlot := slot + 1 tNextSlot := common.SlotToTime(nextSlot) - untilNextSlot := tNextSlot.Sub(t) - poller.Log.Infof("[data-api poller] current slot: %d / next slot: %d (%s) / time until: %s", slot, nextSlot, tNextSlot.String(), untilNextSlot.String()) + poller.Log.Infof("[data-api poller] current slot: %d / next slot: %d (%s)", slot, nextSlot, tNextSlot.String()) - // poll at t-4, t-2, t=0, t=2 + // Schedule polling at t-4, t-2, t=0, t=2 go poller.pollRelaysForBids(nextSlot, -4) go poller.pollRelaysForBids(nextSlot, -2) go poller.pollRelaysForBids(nextSlot, 0) go poller.pollRelaysForBids(nextSlot, 2) // wait until next slot + untilNextSlot := tNextSlot.Sub(t) time.Sleep(untilNextSlot) } } @@ -67,9 +76,9 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { tStart := tSlotStart.Add(time.Duration(t) * time.Second) waitTime := tStart.Sub(time.Now().UTC()) - poller.Log.Infof("[data-api poller] - prepare polling for slot %d t %d (tSlotStart: %s, tStart: %s, waitTime: %s)", slot, t, tSlotStart.String(), tStart.String(), waitTime.String()) + poller.Log.Debugf("[data-api poller] - prepare polling for slot %d t %d (tSlotStart: %s, tStart: %s, waitTime: %s)", slot, t, tSlotStart.String(), tStart.String(), waitTime.String()) if waitTime < 0 { - poller.Log.Warnf("[data-api poller] - waitTime is negative: %s", waitTime.String()) + poller.Log.Debugf("[data-api poller] - waitTime is negative: %s", waitTime.String()) return } @@ -78,21 +87,26 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { // Poll for bids now untilSlot := tSlotStart.Sub(time.Now().UTC()) - poller.Log.Infof("[data-api poller] - polling for slot %d at %d (tNow=%s)", slot, t, untilSlot.String()) + poller.Log.Debugf("[data-api poller] - polling for slot %d at %d (tNow=%s)", slot, t, untilSlot.String()) for _, relay := range poller.Relays { - go poller._pollRelayForBids(slot, relay) + go poller._pollRelayForBids(slot, relay, t) } } -func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEntry) { - log := poller.Log.WithField("relay", relay.Hostname()).WithField("slot", slot) - log.Infof("[data-api poller] - polling relay %s for slot %d", relay.Hostname(), slot) +func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEntry, t int64) { + // log := poller.Log.WithField("relay", relay.Hostname()).WithField("slot", slot) + log := poller.Log.WithFields(logrus.Fields{ + "relay": relay.Hostname(), + "slot": slot, + "t": t, + }) + log.Debugf("[data-api poller] - polling relay %s for slot %d", relay.Hostname(), slot) // build query URL path := "/relay/v1/data/bidtraces/builder_blocks_received" url := common.GetURIWithQuery(relay.URL, path, map[string]string{"slot": fmt.Sprintf("%d", slot)}) - log.Infof("[data-api poller] Querying %s", url) + log.Debugf("[data-api poller] Querying %s", url) // start query var data []relaycommon.BidTraceV2WithTimestampJSON @@ -103,8 +117,8 @@ func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEn log.WithError(err).Error("[data-api poller] - failed to get data") return } - log = log.WithFields(logrus.Fields{"code": code, "entries": len(data), "duration": timeRequestEnd.Sub(timeRequestStart).String()}) - log.Info("[data-api poller] data API request complete") + log = log.WithFields(logrus.Fields{"code": code, "entries": len(data), "durationMs": timeRequestEnd.Sub(timeRequestStart).Milliseconds()}) + log.Info("[data-api poller] request complete") // send data to channel poller.BidC <- DataAPIPollerBidsMsg{Bids: data, Relay: relay, ReceivedAt: time.Now().UTC()} From 40ee72e10aa100b06abcdffc8daab5730c1ca3b5 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 16:12:33 +0200 Subject: [PATCH 13/44] cleanup --- services/bidcollect/data-api-poller.go | 4 ++-- services/bidcollect/types.go | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index c4e2d0e..1c6813f 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -55,8 +55,9 @@ func (poller *DataAPIPoller) Start() { slot := common.TimeToSlot(t) nextSlot := slot + 1 tNextSlot := common.SlotToTime(nextSlot) + untilNextSlot := tNextSlot.Sub(t) - poller.Log.Infof("[data-api poller] current slot: %d / next slot: %d (%s)", slot, nextSlot, tNextSlot.String()) + poller.Log.Infof("[data-api poller] current slot: %d / next slot: %d (%s), waitTime: %s", slot, nextSlot, tNextSlot.String(), untilNextSlot.String()) // Schedule polling at t-4, t-2, t=0, t=2 go poller.pollRelaysForBids(nextSlot, -4) @@ -65,7 +66,6 @@ func (poller *DataAPIPoller) Start() { go poller.pollRelaysForBids(nextSlot, 2) // wait until next slot - untilNextSlot := tNextSlot.Sub(t) time.Sleep(untilNextSlot) } } diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go index 1771d89..9ab73e7 100644 --- a/services/bidcollect/types.go +++ b/services/bidcollect/types.go @@ -104,11 +104,11 @@ func UltrasoundStreamToCommonBid(bid *UltrasoundStreamBidsMsg) *CommonBid { TimestampMs: int64(bid.Bid.Timestamp), Slot: bid.Bid.Slot, BlockNumber: bid.Bid.BlockNumber, - BlockHash: blockHash, - ParentHash: parentHash, - BuilderPubkey: builderPubkey, + BlockHash: strings.ToLower(blockHash), + ParentHash: strings.ToLower(parentHash), + BuilderPubkey: strings.ToLower(builderPubkey), Value: bid.Bid.Value.String(), - BlockFeeRecipient: blockFeeRecipient, + BlockFeeRecipient: strings.ToLower(blockFeeRecipient), Relay: bid.Relay, } } @@ -121,16 +121,16 @@ func DataAPIToCommonBids(bids DataAPIPollerBidsMsg) []*CommonBid { ReceivedAt: bids.ReceivedAt.Unix(), Timestamp: bid.Timestamp, + TimestampMs: bid.TimestampMs, Slot: bid.Slot, BlockNumber: bid.BlockNumber, - BlockHash: bid.BlockHash, - ParentHash: bid.ParentHash, - BuilderPubkey: bid.BuilderPubkey, + BlockHash: strings.ToLower(bid.BlockHash), + ParentHash: strings.ToLower(bid.ParentHash), + BuilderPubkey: strings.ToLower(bid.BuilderPubkey), Value: bid.Value, Relay: bids.Relay.Hostname(), - TimestampMs: bid.TimestampMs, - ProposerPubkey: bid.ProposerPubkey, - ProposerFeeRecipient: bid.ProposerFeeRecipient, + ProposerPubkey: strings.ToLower(bid.ProposerPubkey), + ProposerFeeRecipient: strings.ToLower(bid.ProposerFeeRecipient), OptimisticSubmission: bid.OptimisticSubmission, }) } From c788bc795145a65c436b3b8bfb98c9f0bc1d13eb Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 16:35:25 +0200 Subject: [PATCH 14/44] more cleanup --- docs/adr1-202405-bidstream.md | 6 +-- services/bidcollect/bid-processor.go | 62 +++++++++++++++--------- services/bidcollect/bidcollector.go | 8 ++- services/bidcollect/consts.go | 10 ++++ services/bidcollect/data-api-poller.go | 8 +-- services/bidcollect/types.go | 17 ++++--- services/bidcollect/ultrasound-stream.go | 16 ++---- 7 files changed, 72 insertions(+), 55 deletions(-) create mode 100644 services/bidcollect/consts.go diff --git a/docs/adr1-202405-bidstream.md b/docs/adr1-202405-bidstream.md index 0c04693..5daa524 100644 --- a/docs/adr1-202405-bidstream.md +++ b/docs/adr1-202405-bidstream.md @@ -36,8 +36,6 @@ Done: Next up: - outputs - - like mempool dumpster, every N seconds print some stats - - CSV: dynamic + rotating csv files (like mempool dumpster, for daily files/rollover + combination of multiple collectors) - stream (websocket or SSE) - getHeader polling @@ -45,6 +43,4 @@ Next up: - define query times - data API polling - - relay-specific rate limits - -- Collect which source the data is coming from \ No newline at end of file + - relay-specific rate limits? diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go index 8b13d0d..cc7aba5 100644 --- a/services/bidcollect/bid-processor.go +++ b/services/bidcollect/bid-processor.go @@ -86,28 +86,6 @@ func (c *BidProcessor) processBids(bids []*CommonBid) { } } -func (c *BidProcessor) housekeeping() { - currentSlot := common.TimeToSlot(time.Now().UTC()) - maxSlotInCache := currentSlot - 2 - - nDeleted := 0 - nBids := 0 - - c.bidCacheLock.Lock() - defer c.bidCacheLock.Unlock() - for slot := range c.bidCache { - if slot < maxSlotInCache { - delete(c.bidCache, slot) - nDeleted += 1 - } else { - nBids += len(c.bidCache[slot]) - } - } - - // todo: delete old files - c.log.Infof("[bid-processor] cleanupBids - deleted slots: %d / total slots: %d / total bids: %d / memUsedMB: %d", nDeleted, len(c.bidCache), nBids, common.GetMemMB()) -} - func (c *BidProcessor) exportBid(bid *CommonBid) { outF, _, err := c.getFiles(bid) if err != nil { @@ -136,7 +114,7 @@ func (c *BidProcessor) exportTopBid(bid *CommonBid) { func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) { // hourlybucket - sec := int64(60 * 60) + sec := int64(bucketMinutes * 60) bucketTS := bid.ReceivedAt / sec * sec // timestamp down-round to start of bucket // t := time.Unix(bucketTS, 0).UTC() @@ -209,3 +187,41 @@ func (c *BidProcessor) getFilename(prefix string, timestamp int64) string { } return fmt.Sprintf("%s%s.csv", prefix, t.Format("2006-01-02_15-04")) } + +func (c *BidProcessor) housekeeping() { + currentSlot := common.TimeToSlot(time.Now().UTC()) + maxSlotInCache := currentSlot - 3 + + nDeleted := 0 + nBids := 0 + + c.bidCacheLock.Lock() + defer c.bidCacheLock.Unlock() + for slot := range c.bidCache { + if slot < maxSlotInCache { + delete(c.bidCache, slot) + nDeleted += 1 + } else { + nBids += len(c.bidCache[slot]) + } + } + + // Close and remove old files + now := time.Now().UTC().Unix() + filesBefore := len(c.outFiles) + c.outFilesLock.Lock() + for timestamp, outFiles := range c.outFiles { + usageSec := bucketMinutes * 60 * 2 + if now-timestamp > int64(usageSec) { // remove all handles from 2x usage seconds ago + c.log.Info("closing output files", timestamp) + delete(c.outFiles, timestamp) + _ = outFiles.FAll.Close() + _ = outFiles.FTop.Close() + } + } + nFiles := len(c.outFiles) + filesClosed := len(c.outFiles) - filesBefore + c.outFilesLock.Unlock() + + c.log.Infof("[bid-processor] cleanupBids - deleted slots: %d / total slots: %d / total bids: %d / files closed: %d, current: %d / memUsedMB: %d", nDeleted, len(c.bidCache), nBids, filesClosed, nFiles, common.GetMemMB()) +} diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index 0dece77..34e1f1a 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -27,8 +27,7 @@ type BidCollector struct { dataAPIBidC chan DataAPIPollerBidsMsg // getHeaderBidC chan DataAPIPollerBidsMsg - processorC chan []CommonBid - processor *BidProcessor + processor *BidProcessor } func NewBidCollector(opts *BidCollectorOpts) *BidCollector { @@ -42,11 +41,10 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { } // inputs - c.dataAPIBidC = make(chan DataAPIPollerBidsMsg, 100) - c.ultrasoundBidC = make(chan UltrasoundStreamBidsMsg, 100) + c.dataAPIBidC = make(chan DataAPIPollerBidsMsg, 1000) + c.ultrasoundBidC = make(chan UltrasoundStreamBidsMsg, 1-00) // output - c.processorC = make(chan []CommonBid, 100) c.processor = NewBidProcessor(&BidProcessorOpts{ Log: opts.Log, OutDir: "csv", diff --git a/services/bidcollect/consts.go b/services/bidcollect/consts.go new file mode 100644 index 0000000..57e222e --- /dev/null +++ b/services/bidcollect/consts.go @@ -0,0 +1,10 @@ +package bidcollect + +const ( + ultrasoundStreamDefaultURL = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" + initialBackoffSec = 5 + maxBackoffSec = 120 + + // bucketMinutes is the number of minutes to write into each CSV file (i.e. new file created for every X minutes bucket) + bucketMinutes = 60 +) diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index 1c6813f..ddd84a6 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -76,7 +76,7 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { tStart := tSlotStart.Add(time.Duration(t) * time.Second) waitTime := tStart.Sub(time.Now().UTC()) - poller.Log.Debugf("[data-api poller] - prepare polling for slot %d t %d (tSlotStart: %s, tStart: %s, waitTime: %s)", slot, t, tSlotStart.String(), tStart.String(), waitTime.String()) + // poller.Log.Debugf("[data-api poller] - prepare polling for slot %d t %d (tSlotStart: %s, tStart: %s, waitTime: %s)", slot, t, tSlotStart.String(), tStart.String(), waitTime.String()) if waitTime < 0 { poller.Log.Debugf("[data-api poller] - waitTime is negative: %s", waitTime.String()) return @@ -101,12 +101,12 @@ func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEn "slot": slot, "t": t, }) - log.Debugf("[data-api poller] - polling relay %s for slot %d", relay.Hostname(), slot) + // log.Debugf("[data-api poller] - polling relay %s for slot %d", relay.Hostname(), slot) // build query URL path := "/relay/v1/data/bidtraces/builder_blocks_received" url := common.GetURIWithQuery(relay.URL, path, map[string]string{"slot": fmt.Sprintf("%d", slot)}) - log.Debugf("[data-api poller] Querying %s", url) + // log.Debugf("[data-api poller] Querying %s", url) // start query var data []relaycommon.BidTraceV2WithTimestampJSON @@ -118,7 +118,7 @@ func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEn return } log = log.WithFields(logrus.Fields{"code": code, "entries": len(data), "durationMs": timeRequestEnd.Sub(timeRequestStart).Milliseconds()}) - log.Info("[data-api poller] request complete") + log.Debug("[data-api poller] request complete") // send data to channel poller.BidC <- DataAPIPollerBidsMsg{Bids: data, Relay: relay, ReceivedAt: time.Now().UTC()} diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go index 9ab73e7..22c5467 100644 --- a/services/bidcollect/types.go +++ b/services/bidcollect/types.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/flashbots/relayscan/common" ) // iota @@ -16,16 +17,17 @@ const ( ) var CommonBidCSVFields = []string{ - "source", "received_at", + "source_type", "received_at", "timestamp", "timestamp_ms", - "slot", "block_number", "block_hash", "parent_hash", "builder_pubkey", "value", + "slot", "slot_t_ms", "value", + "block_hash", "parent_hash", "builder_pubkey", "block_number", "block_fee_recipient", "relay", "proposer_pubkey", "proposer_fee_recipient", "optimistic_submission", } type CommonBid struct { // Collector-internal fields - Source int `json:"source"` + SourceType int `json:"source_type"` ReceivedAt int64 `json:"received_at"` // Common fields @@ -65,11 +67,12 @@ func (bid *CommonBid) ValueAsBigInt() *big.Int { func (bid *CommonBid) ToCSVFields() []string { return []string{ // Collector-internal fields - fmt.Sprint(bid.Source), fmt.Sprint(bid.ReceivedAt), + fmt.Sprint(bid.SourceType), fmt.Sprint(bid.ReceivedAt), // Common fields fmt.Sprint(bid.Timestamp), fmt.Sprint(bid.TimestampMs), - fmt.Sprint(bid.Slot), fmt.Sprint(bid.BlockNumber), bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, bid.Value, + fmt.Sprint(bid.Slot), fmt.Sprint(bid.TimestampMs - common.SlotToTime(bid.Slot).UnixMilli()), bid.Value, + bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, fmt.Sprint(bid.BlockNumber), // Ultrasound top-bid stream bid.BlockFeeRecipient, @@ -97,7 +100,7 @@ func UltrasoundStreamToCommonBid(bid *UltrasoundStreamBidsMsg) *CommonBid { blockFeeRecipient := hexutil.Encode(bid.Bid.FeeRecipient[:]) return &CommonBid{ - Source: CollectUltrasoundStream, + SourceType: CollectUltrasoundStream, ReceivedAt: bid.ReceivedAt.Unix(), Timestamp: int64(bid.Bid.Timestamp) / 1000, @@ -117,7 +120,7 @@ func DataAPIToCommonBids(bids DataAPIPollerBidsMsg) []*CommonBid { commonBids := make([]*CommonBid, 0, len(bids.Bids)) for _, bid := range bids.Bids { commonBids = append(commonBids, &CommonBid{ - Source: CollectDataAPI, + SourceType: CollectDataAPI, ReceivedAt: bids.ReceivedAt.Unix(), Timestamp: bid.Timestamp, diff --git a/services/bidcollect/ultrasound-stream.go b/services/bidcollect/ultrasound-stream.go index 9b965ff..97be61c 100644 --- a/services/bidcollect/ultrasound-stream.go +++ b/services/bidcollect/ultrasound-stream.go @@ -9,12 +9,6 @@ import ( "github.com/sirupsen/logrus" ) -const ( - ultrasoundStreamDefaultURL = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" - initialBackoffSec = 5 - maxBackoffSec = 120 -) - type UltrasoundStreamBidsMsg struct { Bid common.UltrasoundStreamBid Relay string @@ -48,7 +42,7 @@ func (ustream *UltrasoundStreamConnection) Start() { func (ustream *UltrasoundStreamConnection) reconnect() { backoffDuration := time.Duration(ustream.backoffSec) * time.Second - ustream.log.Infof("reconnecting to ultrasound stream in %s sec ...", backoffDuration.String()) + ustream.log.Infof("[ultrasounds-stream] reconnecting to ultrasound stream in %s sec ...", backoffDuration.String()) time.Sleep(backoffDuration) // increase backoff timeout for next try @@ -61,19 +55,19 @@ func (ustream *UltrasoundStreamConnection) reconnect() { } func (ustream *UltrasoundStreamConnection) connect() { - ustream.log.WithField("uri", ustream.url).Info("Starting Ultrasound bid stream...") + ustream.log.WithField("uri", ustream.url).Info("[ultrasounds-stream] Starting bid stream...") dialer := websocket.DefaultDialer wsSubscriber, resp, err := dialer.Dial(ustream.url, nil) if err != nil { - ustream.log.WithError(err).Error("failed to connect to bloxroute, reconnecting in a bit...") + ustream.log.WithError(err).Error("[ultrasounds-stream] failed to connect to bloxroute, reconnecting in a bit...") go ustream.reconnect() return } defer wsSubscriber.Close() defer resp.Body.Close() - ustream.log.Info("ultrasound stream connection successful") + ustream.log.Info("[ultrasounds-stream] stream connection successful") ustream.backoffSec = initialBackoffSec // reset backoff timeout bid := new(common.UltrasoundStreamBid) @@ -92,7 +86,7 @@ func (ustream *UltrasoundStreamConnection) connect() { // Unmarshal SSZ err = bid.UnmarshalSSZ(nextNotification) if err != nil { - ustream.log.WithError(err).WithField("msg", hexutil.Encode(nextNotification)).Error("failed to unmarshal ultrasound stream message") + ustream.log.WithError(err).WithField("msg", hexutil.Encode(nextNotification)).Error("[ultrasounds-stream] failed to unmarshal ultrasound stream message") continue } From 6094d8451c4c983181cc3814630b83d5c948b20b Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 17:49:48 +0200 Subject: [PATCH 15/44] outdir --- cmd/service/bidcollect.go | 2 ++ docs/adr1-202405-bidstream.md | 9 +++------ services/bidcollect/bidcollector.go | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 39b60e0..692c977 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -34,6 +34,8 @@ var bidCollectCmd = &cobra.Command{ Use: "bidcollect", Short: "Collect bids", Run: func(cmd *cobra.Command, args []string) { + log.Infof("Bidcollect starting (%s) ...", vars.Version) + // Prepare relays relays := []common.RelayEntry{ common.MustNewRelayEntry(vars.RelayFlashbots, false), diff --git a/docs/adr1-202405-bidstream.md b/docs/adr1-202405-bidstream.md index 5daa524..3f00602 100644 --- a/docs/adr1-202405-bidstream.md +++ b/docs/adr1-202405-bidstream.md @@ -17,12 +17,9 @@ It should expose these as: Run: -``` -# Ultrasound top-bid stream -go run . service bidcollect --out test.csv --ultrasound-stream - -# Data API polling -go run . service bidcollect --out test.csv --data-api +```bash +# Collect bids from ultrasound stream + data API, save to CSV +go run . service bidcollect --out csv --data-api --ultrasound-stream ``` Done: diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index 34e1f1a..fb0b5ea 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -47,7 +47,7 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { // output c.processor = NewBidProcessor(&BidProcessorOpts{ Log: opts.Log, - OutDir: "csv", + OutDir: opts.OutDir, }) return c } From ac090a6a0f5571e6f9bb51753cdbc586bab52f82 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 17:55:33 +0200 Subject: [PATCH 16/44] --all-relays --- cmd/service/bidcollect.go | 11 ++++++----- common/relayentry.go | 6 ++++++ services/bidcollect/data-api-poller.go | 8 ++++---- vars/relays.go | 2 +- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 692c977..5d0f76a 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -15,6 +15,7 @@ var ( collectUltrasoundStream bool collectGetHeader bool collectDataAPI bool + useAllRelays bool outDir string ) @@ -22,9 +23,10 @@ func init() { bidCollectCmd.Flags().BoolVar(&collectUltrasoundStream, "ultrasound-stream", false, "use ultrasound top-bid stream") bidCollectCmd.Flags().BoolVar(&collectGetHeader, "get-header", false, "use getHeader API") bidCollectCmd.Flags().BoolVar(&collectDataAPI, "data-api", false, "use data API") + bidCollectCmd.Flags().BoolVar(&useAllRelays, "all-relays", false, "use all relays") // for getHeader - bidCollectCmd.Flags().StringVar(&beaconNodeURI, "beacon-uri", vars.DefaultBeaconURI, "beacon endpoint") + // bidCollectCmd.Flags().StringVar(&beaconNodeURI, "beacon-uri", vars.DefaultBeaconURI, "beacon endpoint") // for saving to file bidCollectCmd.Flags().StringVar(&outDir, "out", "csv", "output directory for CSV") @@ -41,10 +43,9 @@ var bidCollectCmd = &cobra.Command{ common.MustNewRelayEntry(vars.RelayFlashbots, false), common.MustNewRelayEntry(vars.RelayUltrasound, false), } - // relays, err = common.GetRelays() - // if err != nil { - // log.WithError(err).Fatal("failed to get relays") - // } + if useAllRelays { + relays = common.MustGetRelays() + } log.Infof("Using %d relays", len(relays)) for index, relay := range relays { diff --git a/common/relayentry.go b/common/relayentry.go index aa7f9d8..23fddf8 100644 --- a/common/relayentry.go +++ b/common/relayentry.go @@ -87,3 +87,9 @@ func GetRelays() ([]RelayEntry, error) { } return relays, nil } + +func MustGetRelays() []RelayEntry { + relays, err := GetRelays() + Check(err) + return relays +} diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index ddd84a6..d76e51a 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -78,7 +78,7 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { // poller.Log.Debugf("[data-api poller] - prepare polling for slot %d t %d (tSlotStart: %s, tStart: %s, waitTime: %s)", slot, t, tSlotStart.String(), tStart.String(), waitTime.String()) if waitTime < 0 { - poller.Log.Debugf("[data-api poller] - waitTime is negative: %s", waitTime.String()) + poller.Log.Debugf("[data-api poller] waitTime is negative: %s", waitTime.String()) return } @@ -87,7 +87,7 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { // Poll for bids now untilSlot := tSlotStart.Sub(time.Now().UTC()) - poller.Log.Debugf("[data-api poller] - polling for slot %d at %d (tNow=%s)", slot, t, untilSlot.String()) + poller.Log.Debugf("[data-api poller] polling for slot %d at %d (tNow=%s)", slot, t, untilSlot.String()) for _, relay := range poller.Relays { go poller._pollRelayForBids(slot, relay, t) @@ -101,7 +101,7 @@ func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEn "slot": slot, "t": t, }) - // log.Debugf("[data-api poller] - polling relay %s for slot %d", relay.Hostname(), slot) + // log.Debugf("[data-api poller] polling relay %s for slot %d", relay.Hostname(), slot) // build query URL path := "/relay/v1/data/bidtraces/builder_blocks_received" @@ -114,7 +114,7 @@ func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEn code, err := common.SendHTTPRequest(context.Background(), *http.DefaultClient, http.MethodGet, url, nil, &data) timeRequestEnd := time.Now().UTC() if err != nil { - log.WithError(err).Error("[data-api poller] - failed to get data") + log.WithError(err).Error("[data-api poller] failed to get data") return } log = log.WithFields(logrus.Fields{"code": code, "entries": len(data), "durationMs": timeRequestEnd.Sub(timeRequestStart).Milliseconds()}) diff --git a/vars/relays.go b/vars/relays.go index 7a52c19..a66fd87 100644 --- a/vars/relays.go +++ b/vars/relays.go @@ -13,7 +13,7 @@ var ( "https://0xa7ab7a996c8584251c8f925da3170bdfd6ebc75d50f5ddc4050a6fdc77f2a3b5fce2cc750d0865e05d7228af97d69561@agnostic-relay.net", "https://0xa15b52576bcbf1072f4a011c0f99f9fb6c66f3e1ff321f11f461d15e31b1cb359caa092c71bbded0bae5b5ea401aab7e@aestus.live", "https://0x8c7d33605ecef85403f8b7289c8058f440cbb6bf72b055dfe2f3e2c6695b6a1ea5a9cd0eb3a7982927a463feb4c3dae2@relay.wenmerge.com", - "https://0x95a0a6af2566fa7db732020bb2724be61963ac1eb760aa1046365eb443bd4e3cc0fba0265d40a2d81dd94366643e986a@blockspace.frontier.tech", + // "https://0x95a0a6af2566fa7db732020bb2724be61963ac1eb760aa1046365eb443bd4e3cc0fba0265d40a2d81dd94366643e986a@blockspace.frontier.tech", // data API doesn't work anymore (as of June 1, 2024) // "https://0xad0a8bb54565c2211cee576363f3a347089d2f07cf72679d16911d740262694cadb62d7fd7483f27afd714ca0f1b9118@bloxroute.ethical.blxrbdn.com", // deactivated aug 2023: https://twitter.com/bloXrouteLabs/status/1690065892778926080 // "https://0x9000009807ed12c1f08bf4e81c6da3ba8e3fc3d953898ce0102433094e5f22f21102ec057841fcb81978ed1ea0fa8246@builder-relay-mainnet.blocknative.com", // deactivated sept. 27, 2023: https://twitter.com/blocknative/status/1706685103286485364 "https://0x8c4ed5e24fe5c6ae21018437bde147693f68cda427cd1122cf20819c30eda7ed74f72dece09bb313f2a1855595ab677d@titanrelay.xyz", // added 2024-02-22 From 33db293ec8d3f9c2b8e880ff7ee79eb2d0276469 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 18:43:18 +0200 Subject: [PATCH 17/44] cleanup --- docs/adr1-202405-bidstream.md | 43 ----------------------- docs/adrs/202405-bidcollect.md | 54 +++++++++++++++++++++++++++++ services/bidcollect/bidcollector.go | 2 -- 3 files changed, 54 insertions(+), 45 deletions(-) delete mode 100644 docs/adr1-202405-bidstream.md create mode 100644 docs/adrs/202405-bidcollect.md diff --git a/docs/adr1-202405-bidstream.md b/docs/adr1-202405-bidstream.md deleted file mode 100644 index 3f00602..0000000 --- a/docs/adr1-202405-bidstream.md +++ /dev/null @@ -1,43 +0,0 @@ -# ADR for bid stream - -## Goal - -Relayscan should collect bids across relays: - -1. Ultrasound top-bid websocket stream (https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md) -2. getHeader polling -3. data API polling - -It should expose these as: - -1. A websocket/SSE stream -2. Parquet/CSV files - -## Status - -Run: - -```bash -# Collect bids from ultrasound stream + data API, save to CSV -go run . service bidcollect --out csv --data-api --ultrasound-stream -``` - -Done: - -- Ultrasound bid stream -- Data API polling -- Output - - Writing to csv for top and all bids - - Cache for deduplication - -Next up: - -- outputs - - stream (websocket or SSE) - -- getHeader polling - - some already implemented in [collect-live-bids.go](/cmd/service/collect-live-bids.go)) - - define query times - -- data API polling - - relay-specific rate limits? diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md new file mode 100644 index 0000000..aaf633b --- /dev/null +++ b/docs/adrs/202405-bidcollect.md @@ -0,0 +1,54 @@ +# ADR for bid collection + +## Goal + +Relayscan should collect bids across relays: + +1. Ultrasound top-bid websocket stream (https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md) +2. getHeader polling +3. data API polling + +It should expose these as: + +1. A websocket/SSE stream +2. Parquet/CSV files + +## Status + +Run: + +```bash +# Collect bids from ultrasound stream + data API, save to CSV +go run . service bidcollect --out csv --data-api --ultrasound-stream +``` + +#### Done + +- Ultrasound bid stream +- Data API polling +- Data format +- Output + - Writing to csv for top and all bids + - Cache for deduplication + +#### Next up (must have) + +- Diagram showing the flow of data and the components involved +- File Output + - Use date in output directory + - Combine all individual files into a big file + - Consider gzipped CSV output: https://gist.github.com/mchirico/6147687 (currently, an hour of bids is about 300MB) + - Consider Parquet output files + +#### Could have + +**Data API polling** +- consider improvements to timing +- relay-specific rate limits? + +**Output** +- Stream (websocket or SSE) + +**getHeader polling** +- some already implemented in [collect-live-bids.go](/cmd/service/collect-live-bids.go)) +- define query times diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index fb0b5ea..02d52d2 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -3,7 +3,6 @@ package bidcollect import ( "github.com/flashbots/relayscan/common" - "github.com/flashbots/relayscan/vars" "github.com/sirupsen/logrus" ) @@ -53,7 +52,6 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { } func (c *BidCollector) MustStart() { - c.log.WithField("version", vars.Version).Info("Starting BidCollector ...") go c.processor.Start() if c.opts.CollectGetHeader { From 550612e2103f2278cf75aef20cb35cc0829e4129 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 20:44:23 +0200 Subject: [PATCH 18/44] comment --- services/bidcollect/bid-processor.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go index cc7aba5..8cbb2bf 100644 --- a/services/bidcollect/bid-processor.go +++ b/services/bidcollect/bid-processor.go @@ -68,8 +68,9 @@ func (c *BidProcessor) processBids(bids []*CommonBid) { c.bidCache[bid.Slot] = make(map[string]*CommonBid) } + // Check if bid is new top bid if topBid, ok := c.topBidCache[bid.Slot]; !ok { - c.topBidCache[bid.Slot] = bid + c.topBidCache[bid.Slot] = bid // first one for the slot } else { // if current bid has higher value, use it as new top bid if bid.ValueAsBigInt().Cmp(topBid.ValueAsBigInt()) == 1 { @@ -78,6 +79,7 @@ func (c *BidProcessor) processBids(bids []*CommonBid) { } } + // process regular bids only once per unique key (slot+blockhash+parenthash+builderpubkey+value) if _, ok := c.bidCache[bid.Slot][bid.UniqueKey()]; !ok { // yet unknown bid, save it c.bidCache[bid.Slot][bid.UniqueKey()] = bid From a6b85759bdaf4543bc8505c4d99d9467ecae3bab Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 20:48:51 +0200 Subject: [PATCH 19/44] outdir with date --- docs/adrs/202405-bidcollect.md | 1 - services/bidcollect/bid-processor.go | 9 +++++---- services/bidcollect/data-api-poller.go | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index aaf633b..abec029 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -35,7 +35,6 @@ go run . service bidcollect --out csv --data-api --ultrasound-stream - Diagram showing the flow of data and the components involved - File Output - - Use date in output directory - Combine all individual files into a big file - Consider gzipped CSV output: https://gist.github.com/mchirico/6147687 (currently, an hour of bids is about 300MB) - Consider Parquet output files diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go index 8cbb2bf..8178298 100644 --- a/services/bidcollect/bid-processor.go +++ b/services/bidcollect/bid-processor.go @@ -118,7 +118,7 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) // hourlybucket sec := int64(bucketMinutes * 60) bucketTS := bid.ReceivedAt / sec * sec // timestamp down-round to start of bucket - // t := time.Unix(bucketTS, 0).UTC() + t := time.Unix(bucketTS, 0).UTC() // files may already be opened c.outFilesLock.RLock() @@ -130,13 +130,14 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) } // Create output files - err = os.MkdirAll(c.opts.OutDir, os.ModePerm) + dir := filepath.Join(c.opts.OutDir, t.Format(time.DateOnly)) + err = os.MkdirAll(dir, os.ModePerm) if err != nil { return nil, nil, err } // Open ALL BIDS CSV - fnAll := filepath.Join(c.opts.OutDir, c.getFilename("all", bucketTS)) + fnAll := filepath.Join(dir, c.getFilename("all", bucketTS)) fAll, err = os.OpenFile(fnAll, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return nil, nil, err @@ -153,7 +154,7 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) } // Open TOP BIDS CSV - fnTop := filepath.Join(c.opts.OutDir, c.getFilename("top", bucketTS)) + fnTop := filepath.Join(dir, c.getFilename("top", bucketTS)) fTop, err = os.OpenFile(fnTop, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) if err != nil { return nil, nil, err diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index d76e51a..8625d5e 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -47,6 +47,7 @@ func (poller *DataAPIPoller) Start() { tNextSlot := common.SlotToTime(nextSlot) untilNextSlot := tNextSlot.Sub(t) time.Sleep(untilNextSlot) + poller.Log.Infof("[data-api poller] waiting until start of next slot (%d, %s from now)", nextSlot, untilNextSlot.String()) // then run polling loop for { From 93defa8be9a61a11def7f8737bb3932fc0e7bb3c Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sat, 1 Jun 2024 20:52:15 +0200 Subject: [PATCH 20/44] cleanup --- docs/adrs/202405-bidcollect.md | 6 +++--- services/bidcollect/data-api-poller.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index abec029..07d4139 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -22,7 +22,7 @@ Run: go run . service bidcollect --out csv --data-api --ultrasound-stream ``` -#### Done +### Done - Ultrasound bid stream - Data API polling @@ -31,7 +31,7 @@ go run . service bidcollect --out csv --data-api --ultrasound-stream - Writing to csv for top and all bids - Cache for deduplication -#### Next up (must have) +### Next up (must have) - Diagram showing the flow of data and the components involved - File Output @@ -39,7 +39,7 @@ go run . service bidcollect --out csv --data-api --ultrasound-stream - Consider gzipped CSV output: https://gist.github.com/mchirico/6147687 (currently, an hour of bids is about 300MB) - Consider Parquet output files -#### Could have +### Could have **Data API polling** - consider improvements to timing diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index 8625d5e..375a509 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -88,7 +88,7 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { // Poll for bids now untilSlot := tSlotStart.Sub(time.Now().UTC()) - poller.Log.Debugf("[data-api poller] polling for slot %d at %d (tNow=%s)", slot, t, untilSlot.String()) + poller.Log.Debugf("[data-api poller] polling for slot %d at t=%d (tNow=%s)", slot, t, (untilSlot * -1).String()) for _, relay := range poller.Relays { go poller._pollRelayForBids(slot, relay, t) From c6765590910836964f988fe219d1904d0de255cf Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 10:05:03 +0200 Subject: [PATCH 21/44] simplify a bit --- docs/adrs/202405-bidcollect.md | 6 ++-- services/bidcollect/bid-processor.go | 48 ++++++++++++++------------ services/bidcollect/bidcollector.go | 4 +-- services/bidcollect/consts.go | 9 +++++ services/bidcollect/data-api-poller.go | 3 +- 5 files changed, 42 insertions(+), 28 deletions(-) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 07d4139..35a7ce4 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -34,10 +34,12 @@ go run . service bidcollect --out csv --data-api --ultrasound-stream ### Next up (must have) - Diagram showing the flow of data and the components involved +- Consider methodology of storing "relay" - File Output - Combine all individual files into a big file - Consider gzipped CSV output: https://gist.github.com/mchirico/6147687 (currently, an hour of bids is about 300MB) - Consider Parquet output files + - Upload to S3 + R2 (see also mempool dumpster scripts) ### Could have @@ -45,8 +47,8 @@ go run . service bidcollect --out csv --data-api --ultrasound-stream - consider improvements to timing - relay-specific rate limits? -**Output** -- Stream (websocket or SSE) +**Stream Output** +- Websockets or SSE subscription **getHeader polling** - some already implemented in [collect-live-bids.go](/cmd/service/collect-live-bids.go)) diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go index 8178298..af0fb7d 100644 --- a/services/bidcollect/bid-processor.go +++ b/services/bidcollect/bid-processor.go @@ -63,7 +63,9 @@ func (c *BidProcessor) processBids(bids []*CommonBid) { c.bidCacheLock.Lock() defer c.bidCacheLock.Unlock() + var isTopBid, isNewBid bool for _, bid := range bids { + isNewBid, isTopBid = false, false if _, ok := c.bidCache[bid.Slot]; !ok { c.bidCache[bid.Slot] = make(map[string]*CommonBid) } @@ -71,11 +73,12 @@ func (c *BidProcessor) processBids(bids []*CommonBid) { // Check if bid is new top bid if topBid, ok := c.topBidCache[bid.Slot]; !ok { c.topBidCache[bid.Slot] = bid // first one for the slot + isTopBid = true } else { // if current bid has higher value, use it as new top bid if bid.ValueAsBigInt().Cmp(topBid.ValueAsBigInt()) == 1 { c.topBidCache[bid.Slot] = bid - c.exportTopBid(bid) + isTopBid = true } } @@ -83,34 +86,33 @@ func (c *BidProcessor) processBids(bids []*CommonBid) { if _, ok := c.bidCache[bid.Slot][bid.UniqueKey()]; !ok { // yet unknown bid, save it c.bidCache[bid.Slot][bid.UniqueKey()] = bid - c.exportBid(bid) + isNewBid = true } - } -} -func (c *BidProcessor) exportBid(bid *CommonBid) { - outF, _, err := c.getFiles(bid) - if err != nil { - c.log.WithError(err).Error("get get output file") - return - } - _, err = fmt.Fprint(outF, bid.ToCSVLine("\t")+"\n") - if err != nil { - c.log.WithError(err).Error("couldn't write bid to file") - return + // Write to CSV + c.writeBidToFile(bid, isNewBid, isTopBid) } } -func (c *BidProcessor) exportTopBid(bid *CommonBid) { - _, outF, err := c.getFiles(bid) +func (c *BidProcessor) writeBidToFile(bid *CommonBid, isNewBid, isTopBid bool) { + fAll, fTop, err := c.getFiles(bid) if err != nil { c.log.WithError(err).Error("get get output file") return } - _, err = fmt.Fprint(outF, bid.ToCSVLine("\t")+"\n") - if err != nil { - c.log.WithError(err).Error("couldn't write bid to file") - return + if isNewBid { + _, err = fmt.Fprint(fAll, bid.ToCSVLine(csvSeparator)+"\n") + if err != nil { + c.log.WithError(err).Error("couldn't write bid to file") + return + } + } + if isTopBid { + _, err = fmt.Fprint(fTop, bid.ToCSVLine(csvSeparator)+"\n") + if err != nil { + c.log.WithError(err).Error("couldn't write bid to file") + return + } } } @@ -129,7 +131,7 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) return outFiles.FAll, outFiles.FTop, nil } - // Create output files + // Create output directory dir := filepath.Join(c.opts.OutDir, t.Format(time.DateOnly)) err = os.MkdirAll(dir, os.ModePerm) if err != nil { @@ -147,7 +149,7 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) c.log.WithError(err).Fatal("failed stat on output file") } if fi.Size() == 0 { - _, err = fmt.Fprint(fAll, strings.Join(CommonBidCSVFields, "\t")+"\n") + _, err = fmt.Fprint(fAll, strings.Join(CommonBidCSVFields, csvSeparator)+"\n") if err != nil { c.log.WithError(err).Fatal("failed to write header to output file") } @@ -164,7 +166,7 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) c.log.WithError(err).Fatal("failed stat on output file") } if fi.Size() == 0 { - _, err = fmt.Fprint(fTop, strings.Join(CommonBidCSVFields, "\t")+"\n") + _, err = fmt.Fprint(fTop, strings.Join(CommonBidCSVFields, csvSeparator)+"\n") if err != nil { c.log.WithError(err).Fatal("failed to write header to output file") } diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index 02d52d2..917a99e 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -40,8 +40,8 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { } // inputs - c.dataAPIBidC = make(chan DataAPIPollerBidsMsg, 1000) - c.ultrasoundBidC = make(chan UltrasoundStreamBidsMsg, 1-00) + c.dataAPIBidC = make(chan DataAPIPollerBidsMsg, bidCollectorInputChannelSize) + c.ultrasoundBidC = make(chan UltrasoundStreamBidsMsg, bidCollectorInputChannelSize) // output c.processor = NewBidProcessor(&BidProcessorOpts{ diff --git a/services/bidcollect/consts.go b/services/bidcollect/consts.go index 57e222e..7cfff5e 100644 --- a/services/bidcollect/consts.go +++ b/services/bidcollect/consts.go @@ -1,5 +1,9 @@ package bidcollect +import ( + relaycommon "github.com/flashbots/mev-boost-relay/common" +) + const ( ultrasoundStreamDefaultURL = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" initialBackoffSec = 5 @@ -7,4 +11,9 @@ const ( // bucketMinutes is the number of minutes to write into each CSV file (i.e. new file created for every X minutes bucket) bucketMinutes = 60 + + // channel size for bid collector inputs + bidCollectorInputChannelSize = 1000 ) + +var csvSeparator = relaycommon.GetEnv("CSV_SEP", "\t") diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index 375a509..cf8eddb 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -46,8 +46,9 @@ func (poller *DataAPIPoller) Start() { nextSlot := slot + 1 tNextSlot := common.SlotToTime(nextSlot) untilNextSlot := tNextSlot.Sub(t) - time.Sleep(untilNextSlot) + poller.Log.Infof("[data-api poller] waiting until start of next slot (%d, %s from now)", nextSlot, untilNextSlot.String()) + time.Sleep(untilNextSlot) // then run polling loop for { From 3a3dcb24167078f66952ad41034124360ba6bbec Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 11:14:47 +0200 Subject: [PATCH 22/44] data-api poller: better offset timing --- docs/adrs/202405-bidcollect.md | 1 + services/bidcollect/data-api-poller.go | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 35a7ce4..9af6d42 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -35,6 +35,7 @@ go run . service bidcollect --out csv --data-api --ultrasound-stream - Diagram showing the flow of data and the components involved - Consider methodology of storing "relay" +- Double-check that bids are complete but without duplicates - File Output - Combine all individual files into a big file - Consider gzipped CSV output: https://gist.github.com/mchirico/6147687 (currently, an hour of bids is about 300MB) diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index cf8eddb..e07f0e1 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -62,10 +62,11 @@ func (poller *DataAPIPoller) Start() { poller.Log.Infof("[data-api poller] current slot: %d / next slot: %d (%s), waitTime: %s", slot, nextSlot, tNextSlot.String(), untilNextSlot.String()) // Schedule polling at t-4, t-2, t=0, t=2 - go poller.pollRelaysForBids(nextSlot, -4) - go poller.pollRelaysForBids(nextSlot, -2) - go poller.pollRelaysForBids(nextSlot, 0) - go poller.pollRelaysForBids(nextSlot, 2) + go poller.pollRelaysForBids(nextSlot, -4*time.Second) + go poller.pollRelaysForBids(nextSlot, -2*time.Second) + go poller.pollRelaysForBids(nextSlot, -500*time.Millisecond) + go poller.pollRelaysForBids(nextSlot, 500*time.Millisecond) + go poller.pollRelaysForBids(nextSlot, 2*time.Second) // wait until next slot time.Sleep(untilNextSlot) @@ -73,9 +74,9 @@ func (poller *DataAPIPoller) Start() { } // pollRelaysForBids will poll data api for given slot with t seconds offset -func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { +func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, tOffset time.Duration) { tSlotStart := common.SlotToTime(slot) - tStart := tSlotStart.Add(time.Duration(t) * time.Second) + tStart := tSlotStart.Add(tOffset) waitTime := tStart.Sub(time.Now().UTC()) // poller.Log.Debugf("[data-api poller] - prepare polling for slot %d t %d (tSlotStart: %s, tStart: %s, waitTime: %s)", slot, t, tSlotStart.String(), tStart.String(), waitTime.String()) @@ -89,19 +90,19 @@ func (poller *DataAPIPoller) pollRelaysForBids(slot uint64, t int64) { // Poll for bids now untilSlot := tSlotStart.Sub(time.Now().UTC()) - poller.Log.Debugf("[data-api poller] polling for slot %d at t=%d (tNow=%s)", slot, t, (untilSlot * -1).String()) + poller.Log.Debugf("[data-api poller] polling for slot %d at t=%s (tNow=%s)", slot, tOffset.String(), (untilSlot * -1).String()) for _, relay := range poller.Relays { - go poller._pollRelayForBids(slot, relay, t) + go poller._pollRelayForBids(slot, relay, tOffset) } } -func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEntry, t int64) { +func (poller *DataAPIPoller) _pollRelayForBids(slot uint64, relay common.RelayEntry, t time.Duration) { // log := poller.Log.WithField("relay", relay.Hostname()).WithField("slot", slot) log := poller.Log.WithFields(logrus.Fields{ "relay": relay.Hostname(), "slot": slot, - "t": t, + "t": t.String(), }) // log.Debugf("[data-api poller] polling relay %s for slot %d", relay.Hostname(), slot) From e515732ec7d08b7fb91afa5ff9e2346003e65b8a Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 11:39:14 +0200 Subject: [PATCH 23/44] bids-combine.sh --- scripts/bids-combine.sh | 62 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100755 scripts/bids-combine.sh diff --git a/scripts/bids-combine.sh b/scripts/bids-combine.sh new file mode 100755 index 0000000..77ca792 --- /dev/null +++ b/scripts/bids-combine.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# +# Combine bid CSVs (from bidcollect) into a single CSV +# +set -e + +# require directory as first argument +if [ -z "$1" ]; then + echo "Usage: $0 " + exit 1 +fi + +cd $1 +date=$(basename $1) +echo $date +echo "" + +# ALL BIDS +fn_out="all_${date}.csv" +fn_out_zip="${fn_out}.zip" +fn_out_gz="${fn_out}.gz" +rm -f $fn_out $fn_out_zip $fn_out_gz + +echo "Combining all bids..." +first="1" +for fn in $(\ls all*); do + echo "- ${fn}" + if [ $first == "1" ]; then + head -n 1 $fn > $fn_out + first="0" + fi + tail -n +2 $fn >> $fn_out +done +zip ${fn_out_zip} $fn_out +echo "Wrote ${fn_out_zip}" +gzip $fn_out +echo "Wrote ${fn_out_gz}" +rm -f $fn_out + +echo "" + +# TOP BIDS +echo "Combining top bids..." +fn_out="top_${date}.csv" +fn_out_zip="${fn_out}.zip" +fn_out_gz="${fn_out}.gz" +rm -f $fn_out $fn_out_zip $fn_out_gz + +first="1" +for fn in $(\ls top*); do + echo "- ${fn}" + if [ $first == "1" ]; then + head -n 1 $fn > $fn_out + first="0" + fi + tail -n +2 $fn >> $fn_out +done +zip ${fn_out_zip} $fn_out +echo "Wrote ${fn_out_zip}" +gzip $fn_out +echo "Wrote ${fn_out_gz}" +rm -f $fn_out From 9223b41a37f2451b45c705f25834e926768c8df7 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 11:52:33 +0200 Subject: [PATCH 24/44] tsv file ending --- docs/adrs/202405-bidcollect.md | 28 +++++++++++++--------------- services/bidcollect/bid-processor.go | 2 +- services/bidcollect/consts.go | 5 ++++- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 9af6d42..a559e2a 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -10,8 +10,8 @@ Relayscan should collect bids across relays: It should expose these as: -1. A websocket/SSE stream -2. Parquet/CSV files +1. Parquet/CSV files +2. A websocket/SSE stream ## Status @@ -25,32 +25,30 @@ go run . service bidcollect --out csv --data-api --ultrasound-stream ### Done - Ultrasound bid stream -- Data API polling -- Data format -- Output - - Writing to csv for top and all bids +- Data API polling (at t-4, t-2, t-0.5, t+0.5, t+2) +- CSV Output + - Writing to hourly CSV files (one file for top bids, and one for all bids) - Cache for deduplication + - Script to combine into single CSV ### Next up (must have) - Diagram showing the flow of data and the components involved - Consider methodology of storing "relay" - Double-check that bids are complete but without duplicates -- File Output - - Combine all individual files into a big file - - Consider gzipped CSV output: https://gist.github.com/mchirico/6147687 (currently, an hour of bids is about 300MB) - - Consider Parquet output files - - Upload to S3 + R2 (see also mempool dumpster scripts) ### Could have -**Data API polling** -- consider improvements to timing +Data API polling - relay-specific rate limits? -**Stream Output** +Stream Output - Websockets or SSE subscription -**getHeader polling** +File Output +- Consider Parquet output files (not sure if needed) +- Upload to S3 + R2 (see also mempool dumpster scripts) + +getHeader polling - some already implemented in [collect-live-bids.go](/cmd/service/collect-live-bids.go)) - define query times diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go index af0fb7d..97a90cf 100644 --- a/services/bidcollect/bid-processor.go +++ b/services/bidcollect/bid-processor.go @@ -190,7 +190,7 @@ func (c *BidProcessor) getFilename(prefix string, timestamp int64) string { if prefix != "" { prefix += "_" } - return fmt.Sprintf("%s%s.csv", prefix, t.Format("2006-01-02_15-04")) + return fmt.Sprintf("%s%s.%s", prefix, t.Format("2006-01-02_15-04"), csvFileEnding) } func (c *BidProcessor) housekeeping() { diff --git a/services/bidcollect/consts.go b/services/bidcollect/consts.go index 7cfff5e..73311fd 100644 --- a/services/bidcollect/consts.go +++ b/services/bidcollect/consts.go @@ -16,4 +16,7 @@ const ( bidCollectorInputChannelSize = 1000 ) -var csvSeparator = relaycommon.GetEnv("CSV_SEP", "\t") +var ( + csvFileEnding = relaycommon.GetEnv("CSV_FILE_END", "tsv") + csvSeparator = relaycommon.GetEnv("CSV_SEP", "\t") +) From 144adedf2e2bfcadd2db02fb9a9d65e58a2eb1c4 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 12:25:01 +0200 Subject: [PATCH 25/44] cleanup --- docs/adrs/202405-bidcollect.md | 2 ++ services/bidcollect/types_test.go | 13 +++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 services/bidcollect/types_test.go diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index a559e2a..9c71239 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -22,6 +22,8 @@ Run: go run . service bidcollect --out csv --data-api --ultrasound-stream ``` +Example output: https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa395 + ### Done - Ultrasound bid stream diff --git a/services/bidcollect/types_test.go b/services/bidcollect/types_test.go new file mode 100644 index 0000000..d742df1 --- /dev/null +++ b/services/bidcollect/types_test.go @@ -0,0 +1,13 @@ +package bidcollect + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSourceTypes(t *testing.T) { + require.Equal(t, 0, CollectGetHeader) + require.Equal(t, 1, CollectDataAPI) + require.Equal(t, 2, CollectUltrasoundStream) +} From d2c33e84b7bd2ff9a29c8f1287021c7b811de805 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 15:43:44 +0200 Subject: [PATCH 26/44] getheader polling --- cmd/service/bidcollect.go | 2 +- docs/adrs/202405-bidcollect.md | 11 +- services/bidcollect/bid-processor.go | 2 +- services/bidcollect/bidcollector.go | 23 +-- services/bidcollect/consts.go | 4 + services/bidcollect/getheader-poller.go | 190 ++++++++++++++++++++++++ services/bidcollect/types.go | 42 ++++-- services/bidcollect/types_test.go | 6 +- 8 files changed, 247 insertions(+), 33 deletions(-) create mode 100644 services/bidcollect/getheader-poller.go diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 5d0f76a..c2a7147 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -26,7 +26,7 @@ func init() { bidCollectCmd.Flags().BoolVar(&useAllRelays, "all-relays", false, "use all relays") // for getHeader - // bidCollectCmd.Flags().StringVar(&beaconNodeURI, "beacon-uri", vars.DefaultBeaconURI, "beacon endpoint") + bidCollectCmd.Flags().StringVar(&beaconNodeURI, "beacon-uri", vars.DefaultBeaconURI, "beacon endpoint") // for saving to file bidCollectCmd.Flags().StringVar(&outDir, "out", "csv", "output directory for CSV") diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 9c71239..9747b5a 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -13,13 +13,20 @@ It should expose these as: 1. Parquet/CSV files 2. A websocket/SSE stream +### Notes + +[Source types](https://github.com/flashbots/relayscan/blob/bidstream/services/bidcollect/types.go#L13-L18): +- `0`: `getHeader` polling (not yet implemented) +- `1`: Data API polling +- `2`: Ultrasound top-bid Websockets stream + ## Status Run: ```bash -# Collect bids from ultrasound stream + data API, save to CSV -go run . service bidcollect --out csv --data-api --ultrasound-stream +# Collect bids from ultrasound stream + data API, save to directory "tsv//.tsv" +go run . service bidcollect --out tsv --data-api --ultrasound-stream ``` Example output: https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa395 diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go index 97a90cf..44208b9 100644 --- a/services/bidcollect/bid-processor.go +++ b/services/bidcollect/bid-processor.go @@ -119,7 +119,7 @@ func (c *BidProcessor) writeBidToFile(bid *CommonBid, isNewBid, isTopBid bool) { func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) { // hourlybucket sec := int64(bucketMinutes * 60) - bucketTS := bid.ReceivedAt / sec * sec // timestamp down-round to start of bucket + bucketTS := bid.ReceivedAtMs / 1000 / sec * sec // timestamp down-round to start of bucket t := time.Unix(bucketTS, 0).UTC() // files may already be opened diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index 917a99e..349e762 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -24,7 +24,7 @@ type BidCollector struct { ultrasoundBidC chan UltrasoundStreamBidsMsg dataAPIBidC chan DataAPIPollerBidsMsg - // getHeaderBidC chan DataAPIPollerBidsMsg + getHeaderBidC chan GetHeaderPollerBidsMsg processor *BidProcessor } @@ -42,6 +42,7 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { // inputs c.dataAPIBidC = make(chan DataAPIPollerBidsMsg, bidCollectorInputChannelSize) c.ultrasoundBidC = make(chan UltrasoundStreamBidsMsg, bidCollectorInputChannelSize) + c.getHeaderBidC = make(chan GetHeaderPollerBidsMsg, bidCollectorInputChannelSize) // output c.processor = NewBidProcessor(&BidProcessorOpts{ @@ -55,7 +56,13 @@ func (c *BidCollector) MustStart() { go c.processor.Start() if c.opts.CollectGetHeader { - c.log.Fatal("not yet implemented") + poller := NewGetHeaderPoller(&GetHeaderPollerOpts{ + Log: c.log, + BidC: c.getHeaderBidC, + BeaconURI: c.opts.BeaconNodeURI, + Relays: c.opts.Relays, + }) + go poller.Start() } if c.opts.CollectDataAPI { @@ -83,15 +90,9 @@ func (c *BidCollector) MustStart() { case bids := <-c.dataAPIBidC: commonBids := DataAPIToCommonBids(bids) c.processor.processBids(commonBids) + case bid := <-c.getHeaderBidC: + commonBid := GetHeaderToCommonBid(bid) + c.processor.processBids([]*CommonBid{commonBid}) } } } - -// func (c *BidCollector) processBid(bid *CommonBid) { -// if c.outF != nil { -// _, err := fmt.Fprint(c.outF, bid.ToCSVLine("\t")+"\n") -// if err != nil { -// c.log.WithError(err).Error("couldn't write bid to file") -// } -// } -// } diff --git a/services/bidcollect/consts.go b/services/bidcollect/consts.go index 73311fd..7a6da6b 100644 --- a/services/bidcollect/consts.go +++ b/services/bidcollect/consts.go @@ -5,6 +5,10 @@ import ( ) const ( + SourceTypeGetHeader = 0 + SourceTypeDataAPI = 1 + SourceTypeUltrasoundStream = 2 + ultrasoundStreamDefaultURL = "ws://relay-builders-eu.ultrasound.money/ws/v1/top_bid" initialBackoffSec = 5 maxBackoffSec = 120 diff --git a/services/bidcollect/getheader-poller.go b/services/bidcollect/getheader-poller.go new file mode 100644 index 0000000..c9da027 --- /dev/null +++ b/services/bidcollect/getheader-poller.go @@ -0,0 +1,190 @@ +package bidcollect + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/flashbots/go-boost-utils/types" + "github.com/flashbots/mev-boost-relay/beaconclient" + relaycommon "github.com/flashbots/mev-boost-relay/common" + "github.com/flashbots/relayscan/common" + "github.com/sirupsen/logrus" +) + +type GetHeaderPollerBidsMsg struct { + Slot uint64 + Bid types.GetHeaderResponse + Relay common.RelayEntry + ReceivedAt time.Time +} + +type GetHeaderPollerOpts struct { + Log *logrus.Entry + BidC chan GetHeaderPollerBidsMsg + BeaconURI string + Relays []common.RelayEntry +} + +type GetHeaderPoller struct { + log *logrus.Entry + bidC chan GetHeaderPollerBidsMsg + relays []common.RelayEntry + bn *beaconclient.ProdBeaconInstance +} + +func NewGetHeaderPoller(opts *GetHeaderPollerOpts) *GetHeaderPoller { + return &GetHeaderPoller{ + log: opts.Log, + bidC: opts.BidC, + relays: opts.Relays, + bn: beaconclient.NewProdBeaconInstance(opts.Log, opts.BeaconURI), + } +} + +func (poller *GetHeaderPoller) Start() { + poller.log.WithField("relays", common.RelayEntriesToHostnameStrings(poller.relays)).Info("Starting GetHeaderPoller ...") + + // Check beacon-node sync status, process current slot and start slot updates + syncStatus, err := poller.bn.SyncStatus() + if err != nil { + poller.log.WithError(err).Fatal("couldn't get BN sync status") + } else if syncStatus.IsSyncing { + poller.log.Fatal("beacon node is syncing") + } + + // var headSlot uint64 + var headSlot, nextSlot, currentEpoch, lastDutyUpdateEpoch uint64 + var duties map[uint64]string + + // subscribe to head events (because then, the BN will know the block + proposer details for the next slot) + c := make(chan beaconclient.HeadEventData) + go poller.bn.SubscribeToHeadEvents(c) + + // then run polling loop + for { + headEvent := <-c + if headEvent.Slot <= headSlot { + continue + } + + headSlot = headEvent.Slot + nextSlot = headSlot + 1 + tNextSlot := common.SlotToTime(nextSlot) + untilNextSlot := tNextSlot.Sub(time.Now().UTC()) + + currentEpoch = headSlot / relaycommon.SlotsPerEpoch + poller.log.Infof("[getHeader poller] headSlot slot: %d / next slot: %d (%s), waitTime: %s", headSlot, nextSlot, tNextSlot.String(), untilNextSlot.String()) + + // On every new epoch, get proposer duties for current and next epoch (to avoid boundary problems) + if len(duties) == 0 || currentEpoch > lastDutyUpdateEpoch { + dutiesResp, err := poller.bn.GetProposerDuties(currentEpoch) + if err != nil { + poller.log.WithError(err).Error("couldn't get proposer duties") + continue + } + + duties = make(map[uint64]string) + for _, d := range dutiesResp.Data { + duties[d.Slot] = d.Pubkey + } + + dutiesResp, err = poller.bn.GetProposerDuties(currentEpoch + 1) + if err != nil { + poller.log.WithError(err).Error("failed get proposer duties") + } else { + for _, d := range dutiesResp.Data { + duties[d.Slot] = d.Pubkey + } + } + poller.log.Debugf("[getHeader poller] duties updated: %d entries", len(duties)) + lastDutyUpdateEpoch = currentEpoch + } + + // Now get the latest block, for the execution payload + block, err := poller.bn.GetBlock("head") + if err != nil { + poller.log.WithError(err).Error("failed get latest block from BN") + continue + } + + if block.Data.Message.Slot != headSlot { + poller.log.WithField("slot", headSlot).WithField("bnSlot", block.Data.Message.Slot).Error("latest block slot is not current slot") + continue + } + + nextProposerPubkey := duties[nextSlot] + poller.log.Debugf("[getHeader poller] next slot: %d / block: %s / parent: %s / proposerPubkey: %s", nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), block.Data.Message.Body.ExecutionPayload.ParentHash, nextProposerPubkey) + + if nextProposerPubkey == "" { + poller.log.WithField("duties", duties).Error("no proposerPubkey for next slot") + } else { + go poller.pollRelaysForBids(-2*time.Second, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) + go poller.pollRelaysForBids(0*time.Second, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) + go poller.pollRelaysForBids(1500*time.Millisecond, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) + } + } +} + +// pollRelaysForBids will poll data api for given slot with t seconds offset +func (poller *GetHeaderPoller) pollRelaysForBids(tOffset time.Duration, slot uint64, parentHash, proposerPubkey string) { + tSlotStart := common.SlotToTime(slot) + tStart := tSlotStart.Add(tOffset) + waitTime := tStart.Sub(time.Now().UTC()) + + // poller.Log.Debugf("[getHeader poller] - prepare polling for slot %d t %d (tSlotStart: %s, tStart: %s, waitTime: %s)", slot, t, tSlotStart.String(), tStart.String(), waitTime.String()) + if waitTime < 0 { + poller.log.Debugf("[getHeader poller] waitTime is negative: %s", waitTime.String()) + return + } + + // Wait until expected time + time.Sleep(waitTime) + + // Poll for bids now + untilSlot := tSlotStart.Sub(time.Now().UTC()) + poller.log.Debugf("[getHeader poller] polling for slot %d at t=%s (tNow=%s)", slot, tOffset.String(), (untilSlot * -1).String()) + + for _, relay := range poller.relays { + go poller._pollRelayForBids(relay, tOffset, slot, parentHash, proposerPubkey) + } +} + +func (poller *GetHeaderPoller) _pollRelayForBids(relay common.RelayEntry, t time.Duration, slot uint64, parentHash, proposerPubkey string) { + // log := poller.Log.WithField("relay", relay.Hostname()).WithField("slot", slot) + log := poller.log.WithFields(logrus.Fields{ + "relay": relay.Hostname(), + "slot": slot, + "t": t.String(), + }) + log.Debugf("[getHeader poller] polling relay %s for slot %d", relay.Hostname(), slot) + + path := fmt.Sprintf("/eth/v1/builder/header/%d/%s/%s", slot, parentHash, proposerPubkey) + url := relay.GetURI(path) + // log.Debugf("Querying %s", url) + + var bid types.GetHeaderResponse + timeRequestStart := time.Now().UTC() + code, err := common.SendHTTPRequest(context.Background(), *http.DefaultClient, http.MethodGet, url, nil, &bid) + timeRequestEnd := time.Now().UTC() + if err != nil { + if strings.Contains(err.Error(), "no builder bid") { + return + } + log.WithFields(logrus.Fields{ + "code": code, + "url": url, + }).WithError(err).Error("error on getHeader request") + return + } + if code != 200 { + log.WithField("code", code).Debug("no bid received") + return + } + log.WithField("durationMs", timeRequestEnd.Sub(timeRequestStart).Milliseconds()).Infof("bid received! slot: %d \t value: %s \t block_hash: %s \t timestamp: %d", slot, bid.Data.Message.Value.String(), bid.Data.Message.Header.BlockHash.String(), bid.Data.Message.Header.Timestamp) + + // send data to channel + poller.bidC <- GetHeaderPollerBidsMsg{Slot: slot, Bid: bid, Relay: relay, ReceivedAt: time.Now().UTC()} +} diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go index 22c5467..6a053a9 100644 --- a/services/bidcollect/types.go +++ b/services/bidcollect/types.go @@ -9,13 +9,6 @@ import ( "github.com/flashbots/relayscan/common" ) -// iota -const ( - CollectGetHeader = iota - CollectDataAPI - CollectUltrasoundStream -) - var CommonBidCSVFields = []string{ "source_type", "received_at", "timestamp", "timestamp_ms", @@ -27,8 +20,8 @@ var CommonBidCSVFields = []string{ type CommonBid struct { // Collector-internal fields - SourceType int `json:"source_type"` - ReceivedAt int64 `json:"received_at"` + SourceType int `json:"source_type"` + ReceivedAtMs int64 `json:"received_at"` // Common fields Timestamp int64 `json:"timestamp"` @@ -65,13 +58,18 @@ func (bid *CommonBid) ValueAsBigInt() *big.Int { } func (bid *CommonBid) ToCSVFields() []string { + slotTms := bid.TimestampMs - common.SlotToTime(bid.Slot).UnixMilli() + if bid.TimestampMs == 0 { + slotTms = bid.ReceivedAtMs - common.SlotToTime(bid.Slot).UnixMilli() + } + return []string{ // Collector-internal fields - fmt.Sprint(bid.SourceType), fmt.Sprint(bid.ReceivedAt), + fmt.Sprint(bid.SourceType), fmt.Sprint(bid.ReceivedAtMs), // Common fields fmt.Sprint(bid.Timestamp), fmt.Sprint(bid.TimestampMs), - fmt.Sprint(bid.Slot), fmt.Sprint(bid.TimestampMs - common.SlotToTime(bid.Slot).UnixMilli()), bid.Value, + fmt.Sprint(bid.Slot), fmt.Sprint(slotTms), bid.Value, bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, fmt.Sprint(bid.BlockNumber), // Ultrasound top-bid stream @@ -100,8 +98,8 @@ func UltrasoundStreamToCommonBid(bid *UltrasoundStreamBidsMsg) *CommonBid { blockFeeRecipient := hexutil.Encode(bid.Bid.FeeRecipient[:]) return &CommonBid{ - SourceType: CollectUltrasoundStream, - ReceivedAt: bid.ReceivedAt.Unix(), + SourceType: SourceTypeUltrasoundStream, + ReceivedAtMs: bid.ReceivedAt.UnixMilli(), Timestamp: int64(bid.Bid.Timestamp) / 1000, TimestampMs: int64(bid.Bid.Timestamp), @@ -120,8 +118,8 @@ func DataAPIToCommonBids(bids DataAPIPollerBidsMsg) []*CommonBid { commonBids := make([]*CommonBid, 0, len(bids.Bids)) for _, bid := range bids.Bids { commonBids = append(commonBids, &CommonBid{ - SourceType: CollectDataAPI, - ReceivedAt: bids.ReceivedAt.Unix(), + SourceType: SourceTypeDataAPI, + ReceivedAtMs: bids.ReceivedAt.UnixMilli(), Timestamp: bid.Timestamp, TimestampMs: bid.TimestampMs, @@ -139,3 +137,17 @@ func DataAPIToCommonBids(bids DataAPIPollerBidsMsg) []*CommonBid { } return commonBids } + +func GetHeaderToCommonBid(bid GetHeaderPollerBidsMsg) *CommonBid { + return &CommonBid{ + SourceType: SourceTypeGetHeader, + ReceivedAtMs: bid.ReceivedAt.UnixMilli(), + Relay: bid.Relay.Hostname(), + Slot: bid.Slot, + + BlockNumber: bid.Bid.Data.Message.Header.BlockNumber, + BlockHash: strings.ToLower(bid.Bid.Data.Message.Header.BlockHash.String()), + ParentHash: strings.ToLower(bid.Bid.Data.Message.Header.ParentHash.String()), + Value: bid.Bid.Data.Message.Value.String(), + } +} diff --git a/services/bidcollect/types_test.go b/services/bidcollect/types_test.go index d742df1..2b61b4b 100644 --- a/services/bidcollect/types_test.go +++ b/services/bidcollect/types_test.go @@ -7,7 +7,7 @@ import ( ) func TestSourceTypes(t *testing.T) { - require.Equal(t, 0, CollectGetHeader) - require.Equal(t, 1, CollectDataAPI) - require.Equal(t, 2, CollectUltrasoundStream) + require.Equal(t, 0, SourceTypeGetHeader) + require.Equal(t, 1, SourceTypeDataAPI) + require.Equal(t, 2, SourceTypeUltrasoundStream) } From 788210823179c7889eab4e99a3130e585ff0c3e1 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 15:50:41 +0200 Subject: [PATCH 27/44] normalize csv timestamp_ms, remove csv timestamp because redundant --- services/bidcollect/types.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go index 6a053a9..8ca8d03 100644 --- a/services/bidcollect/types.go +++ b/services/bidcollect/types.go @@ -10,8 +10,8 @@ import ( ) var CommonBidCSVFields = []string{ - "source_type", "received_at", - "timestamp", "timestamp_ms", + "source_type", "received_at_ms", + "timestamp_ms", "slot", "slot_t_ms", "value", "block_hash", "parent_hash", "builder_pubkey", "block_number", "block_fee_recipient", @@ -63,12 +63,21 @@ func (bid *CommonBid) ToCSVFields() []string { slotTms = bid.ReceivedAtMs - common.SlotToTime(bid.Slot).UnixMilli() } + tsMs := bid.TimestampMs + if tsMs == 0 { + if bid.Timestamp > 0 { + tsMs = bid.Timestamp * 1000 + } else { + tsMs = bid.ReceivedAtMs + } + } + return []string{ // Collector-internal fields fmt.Sprint(bid.SourceType), fmt.Sprint(bid.ReceivedAtMs), // Common fields - fmt.Sprint(bid.Timestamp), fmt.Sprint(bid.TimestampMs), + fmt.Sprint(tsMs), fmt.Sprint(bid.Slot), fmt.Sprint(slotTms), bid.Value, bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, fmt.Sprint(bid.BlockNumber), From 01c6ec0d82db65875c177816c703b93ef2db2e7c Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 15:52:04 +0200 Subject: [PATCH 28/44] cleanup --- services/bidcollect/data-api-poller.go | 4 ++-- services/bidcollect/getheader-poller.go | 1 - services/bidcollect/types.go | 7 ++----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/services/bidcollect/data-api-poller.go b/services/bidcollect/data-api-poller.go index e07f0e1..fea2aa1 100644 --- a/services/bidcollect/data-api-poller.go +++ b/services/bidcollect/data-api-poller.go @@ -47,7 +47,7 @@ func (poller *DataAPIPoller) Start() { tNextSlot := common.SlotToTime(nextSlot) untilNextSlot := tNextSlot.Sub(t) - poller.Log.Infof("[data-api poller] waiting until start of next slot (%d, %s from now)", nextSlot, untilNextSlot.String()) + poller.Log.Infof("[data-api poller] waiting until start of next slot (%d - %s from now)", nextSlot, untilNextSlot.String()) time.Sleep(untilNextSlot) // then run polling loop @@ -59,7 +59,7 @@ func (poller *DataAPIPoller) Start() { tNextSlot := common.SlotToTime(nextSlot) untilNextSlot := tNextSlot.Sub(t) - poller.Log.Infof("[data-api poller] current slot: %d / next slot: %d (%s), waitTime: %s", slot, nextSlot, tNextSlot.String(), untilNextSlot.String()) + poller.Log.Infof("[data-api poller] scheduling polling for upcoming slot: %d (%s - in %s)", nextSlot, tNextSlot.String(), untilNextSlot.String()) // Schedule polling at t-4, t-2, t=0, t=2 go poller.pollRelaysForBids(nextSlot, -4*time.Second) diff --git a/services/bidcollect/getheader-poller.go b/services/bidcollect/getheader-poller.go index c9da027..07b90bb 100644 --- a/services/bidcollect/getheader-poller.go +++ b/services/bidcollect/getheader-poller.go @@ -121,7 +121,6 @@ func (poller *GetHeaderPoller) Start() { if nextProposerPubkey == "" { poller.log.WithField("duties", duties).Error("no proposerPubkey for next slot") } else { - go poller.pollRelaysForBids(-2*time.Second, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) go poller.pollRelaysForBids(0*time.Second, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) go poller.pollRelaysForBids(1500*time.Millisecond, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) } diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go index 8ca8d03..678a074 100644 --- a/services/bidcollect/types.go +++ b/services/bidcollect/types.go @@ -58,11 +58,6 @@ func (bid *CommonBid) ValueAsBigInt() *big.Int { } func (bid *CommonBid) ToCSVFields() []string { - slotTms := bid.TimestampMs - common.SlotToTime(bid.Slot).UnixMilli() - if bid.TimestampMs == 0 { - slotTms = bid.ReceivedAtMs - common.SlotToTime(bid.Slot).UnixMilli() - } - tsMs := bid.TimestampMs if tsMs == 0 { if bid.Timestamp > 0 { @@ -72,6 +67,8 @@ func (bid *CommonBid) ToCSVFields() []string { } } + slotTms := tsMs - common.SlotToTime(bid.Slot).UnixMilli() + return []string{ // Collector-internal fields fmt.Sprint(bid.SourceType), fmt.Sprint(bid.ReceivedAtMs), From 24873c04c9d0b78e6969cb30709fbde4750837f4 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 16:00:47 +0200 Subject: [PATCH 29/44] getHeader: only call once --- docs/adrs/202405-bidcollect.md | 9 +++++---- services/bidcollect/getheader-poller.go | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 9747b5a..22d33f7 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -15,10 +15,11 @@ It should expose these as: ### Notes -[Source types](https://github.com/flashbots/relayscan/blob/bidstream/services/bidcollect/types.go#L13-L18): -- `0`: `getHeader` polling (not yet implemented) -- `1`: Data API polling -- `2`: Ultrasound top-bid Websockets stream +- [Source types](https://github.com/flashbots/relayscan/blob/bidstream/services/bidcollect/types.go#L13-L18): + - `0`: `getHeader` polling (not yet implemented) + - `1`: Data API polling + - `2`: Ultrasound top-bid Websockets stream +- some relay only allow a single getHeader request per slot, so we time it at t=1s ## Status diff --git a/services/bidcollect/getheader-poller.go b/services/bidcollect/getheader-poller.go index 07b90bb..fac45eb 100644 --- a/services/bidcollect/getheader-poller.go +++ b/services/bidcollect/getheader-poller.go @@ -121,8 +121,8 @@ func (poller *GetHeaderPoller) Start() { if nextProposerPubkey == "" { poller.log.WithField("duties", duties).Error("no proposerPubkey for next slot") } else { - go poller.pollRelaysForBids(0*time.Second, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) - go poller.pollRelaysForBids(1500*time.Millisecond, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) + // go poller.pollRelaysForBids(0*time.Second, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) + go poller.pollRelaysForBids(1000*time.Millisecond, nextSlot, block.Data.Message.Body.ExecutionPayload.BlockHash.String(), duties[nextSlot]) } } } From 6670a50fe86eb997e82348b80964449b00e60a9d Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 16:05:49 +0200 Subject: [PATCH 30/44] notes --- docs/adrs/202405-bidcollect.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 22d33f7..65bd7c8 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -4,9 +4,9 @@ Relayscan should collect bids across relays: -1. Ultrasound top-bid websocket stream (https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md) +1. [Ultrasound top-bid websocket stream](https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md) 2. getHeader polling -3. data API polling +3. Data API polling It should expose these as: @@ -16,10 +16,13 @@ It should expose these as: ### Notes - [Source types](https://github.com/flashbots/relayscan/blob/bidstream/services/bidcollect/types.go#L13-L18): - - `0`: `getHeader` polling (not yet implemented) + - `0`: `getHeader` polling - `1`: Data API polling - `2`: Ultrasound top-bid Websockets stream -- some relay only allow a single getHeader request per slot, so we time it at t=1s +- getHeader polling + - some relay only allow a single getHeader request per slot, so we time it at t=1s + - header only has limited information. need to use receive timestamp, and optimistic is always false + - Ultrasound relay doesn't support repeated getHeader requests ## Status From 95f806f53b631924ec097ba37e81e37071e28720 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 16:12:23 +0200 Subject: [PATCH 31/44] getHeader silence common errors --- services/bidcollect/getheader-poller.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/services/bidcollect/getheader-poller.go b/services/bidcollect/getheader-poller.go index fac45eb..5c61e83 100644 --- a/services/bidcollect/getheader-poller.go +++ b/services/bidcollect/getheader-poller.go @@ -169,7 +169,13 @@ func (poller *GetHeaderPoller) _pollRelayForBids(relay common.RelayEntry, t time code, err := common.SendHTTPRequest(context.Background(), *http.DefaultClient, http.MethodGet, url, nil, &bid) timeRequestEnd := time.Now().UTC() if err != nil { - if strings.Contains(err.Error(), "no builder bid") { + msg := err.Error() + if strings.Contains(msg, "no builder bid") { + return + } else if strings.Contains(msg, "Too many getHeader requests! Use relay-analytics.ultrasound.money or the Websocket API") { + return + } else if code == 429 { + log.Warn("429 received") return } log.WithFields(logrus.Fields{ From dc8ed1e99ce5c23ece97b9889326f1e951dcf893 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Sun, 2 Jun 2024 21:30:06 +0200 Subject: [PATCH 32/44] csv/tsv --- cmd/service/bidcollect.go | 8 ++++++-- docs/adrs/202405-bidcollect.md | 11 ++++++++++- services/bidcollect/bid-processor.go | 26 +++++++++++++++++++------- services/bidcollect/bidcollector.go | 11 +++++++---- services/bidcollect/consts.go | 8 ++------ 5 files changed, 44 insertions(+), 20 deletions(-) diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index c2a7147..b0d5d25 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -16,7 +16,9 @@ var ( collectGetHeader bool collectDataAPI bool useAllRelays bool - outDir string + + outDir string + outputTSV bool // by default: CSV, but can be changed to TSV with this setting ) func init() { @@ -29,7 +31,8 @@ func init() { bidCollectCmd.Flags().StringVar(&beaconNodeURI, "beacon-uri", vars.DefaultBeaconURI, "beacon endpoint") // for saving to file - bidCollectCmd.Flags().StringVar(&outDir, "out", "csv", "output directory for CSV") + bidCollectCmd.Flags().StringVar(&outDir, "out", "csv", "output directory for CSV/TSV") + bidCollectCmd.Flags().BoolVar(&outputTSV, "out-tsv", false, "output as TSV (instead of CSV)") } var bidCollectCmd = &cobra.Command{ @@ -60,6 +63,7 @@ var bidCollectCmd = &cobra.Command{ CollectDataAPI: collectDataAPI, BeaconNodeURI: beaconNodeURI, OutDir: outDir, + OutputTSV: outputTSV, } bidCollector := bidcollect.NewBidCollector(&opts) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 65bd7c8..fb33a6a 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -23,6 +23,14 @@ It should expose these as: - some relay only allow a single getHeader request per slot, so we time it at t=1s - header only has limited information. need to use receive timestamp, and optimistic is always false - Ultrasound relay doesn't support repeated getHeader requests +- Ultrasound websocket stream + - doesn't expose optimistic, thus that field is always false + +clickhouse-local queries: + +```bash +clickhouse local -q "SELECT source_type, COUNT(source_type) FROM 'top_2024-06-02_18-00.tsv' GROUP BY source_type ORDER BY source_type;" +``` ## Status @@ -48,7 +56,8 @@ Example output: https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa3 - Diagram showing the flow of data and the components involved - Consider methodology of storing "relay" -- Double-check that bids are complete but without duplicates +- Double-check caching methodology (only one bid per unique key, consider also per source type?) +- Double-check that bids are complete and without duplicates ### Could have diff --git a/services/bidcollect/bid-processor.go b/services/bidcollect/bid-processor.go index 44208b9..47c2ac7 100644 --- a/services/bidcollect/bid-processor.go +++ b/services/bidcollect/bid-processor.go @@ -19,8 +19,9 @@ import ( // - One CSV for top bids only type BidProcessorOpts struct { - Log *logrus.Entry - OutDir string + Log *logrus.Entry + OutDir string + OutputTSV bool } type OutFiles struct { @@ -38,6 +39,9 @@ type BidProcessor struct { bidCache map[uint64]map[string]*CommonBid // map[slot][bidUniqueKey]Bid topBidCache map[uint64]*CommonBid // map[slot]Bid bidCacheLock sync.RWMutex + + csvSeparator string + csvFileEnding string } func NewBidProcessor(opts *BidProcessorOpts) *BidProcessor { @@ -49,6 +53,14 @@ func NewBidProcessor(opts *BidProcessorOpts) *BidProcessor { topBidCache: make(map[uint64]*CommonBid), } + if opts.OutputTSV { + c.csvSeparator = "\t" + c.csvFileEnding = "tsv" + } else { + c.csvSeparator = "," + c.csvFileEnding = "csv" + } + return c } @@ -101,14 +113,14 @@ func (c *BidProcessor) writeBidToFile(bid *CommonBid, isNewBid, isTopBid bool) { return } if isNewBid { - _, err = fmt.Fprint(fAll, bid.ToCSVLine(csvSeparator)+"\n") + _, err = fmt.Fprint(fAll, bid.ToCSVLine(c.csvSeparator)+"\n") if err != nil { c.log.WithError(err).Error("couldn't write bid to file") return } } if isTopBid { - _, err = fmt.Fprint(fTop, bid.ToCSVLine(csvSeparator)+"\n") + _, err = fmt.Fprint(fTop, bid.ToCSVLine(c.csvSeparator)+"\n") if err != nil { c.log.WithError(err).Error("couldn't write bid to file") return @@ -149,7 +161,7 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) c.log.WithError(err).Fatal("failed stat on output file") } if fi.Size() == 0 { - _, err = fmt.Fprint(fAll, strings.Join(CommonBidCSVFields, csvSeparator)+"\n") + _, err = fmt.Fprint(fAll, strings.Join(CommonBidCSVFields, c.csvSeparator)+"\n") if err != nil { c.log.WithError(err).Fatal("failed to write header to output file") } @@ -166,7 +178,7 @@ func (c *BidProcessor) getFiles(bid *CommonBid) (fAll, fTop *os.File, err error) c.log.WithError(err).Fatal("failed stat on output file") } if fi.Size() == 0 { - _, err = fmt.Fprint(fTop, strings.Join(CommonBidCSVFields, csvSeparator)+"\n") + _, err = fmt.Fprint(fTop, strings.Join(CommonBidCSVFields, c.csvSeparator)+"\n") if err != nil { c.log.WithError(err).Fatal("failed to write header to output file") } @@ -190,7 +202,7 @@ func (c *BidProcessor) getFilename(prefix string, timestamp int64) string { if prefix != "" { prefix += "_" } - return fmt.Sprintf("%s%s.%s", prefix, t.Format("2006-01-02_15-04"), csvFileEnding) + return fmt.Sprintf("%s%s.%s", prefix, t.Format("2006-01-02_15-04"), c.csvFileEnding) } func (c *BidProcessor) housekeeping() { diff --git a/services/bidcollect/bidcollector.go b/services/bidcollect/bidcollector.go index 349e762..7c16de3 100644 --- a/services/bidcollect/bidcollector.go +++ b/services/bidcollect/bidcollector.go @@ -15,7 +15,9 @@ type BidCollectorOpts struct { Relays []common.RelayEntry BeaconNodeURI string // for getHeader - OutDir string + + OutDir string + OutputTSV bool } type BidCollector struct { @@ -36,7 +38,7 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { } if c.opts.OutDir == "" { - c.opts.OutDir = "csv" + opts.Log.Fatal("outDir is required") } // inputs @@ -46,8 +48,9 @@ func NewBidCollector(opts *BidCollectorOpts) *BidCollector { // output c.processor = NewBidProcessor(&BidProcessorOpts{ - Log: opts.Log, - OutDir: opts.OutDir, + Log: opts.Log, + OutDir: opts.OutDir, + OutputTSV: opts.OutputTSV, }) return c } diff --git a/services/bidcollect/consts.go b/services/bidcollect/consts.go index 7a6da6b..16df1cd 100644 --- a/services/bidcollect/consts.go +++ b/services/bidcollect/consts.go @@ -1,9 +1,5 @@ package bidcollect -import ( - relaycommon "github.com/flashbots/mev-boost-relay/common" -) - const ( SourceTypeGetHeader = 0 SourceTypeDataAPI = 1 @@ -21,6 +17,6 @@ const ( ) var ( - csvFileEnding = relaycommon.GetEnv("CSV_FILE_END", "tsv") - csvSeparator = relaycommon.GetEnv("CSV_SEP", "\t") +// csvFileEnding = relaycommon.GetEnv("CSV_FILE_END", "tsv") +// csvSeparator = relaycommon.GetEnv("CSV_SEP", "\t") ) From 7af600e9f0754a409dff5e1a2c86b4dac9c71e93 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Mon, 3 Jun 2024 11:31:51 +0200 Subject: [PATCH 33/44] docs --- docs/adrs/202405-bidcollect.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index fb33a6a..2add5c4 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -15,7 +15,7 @@ It should expose these as: ### Notes -- [Source types](https://github.com/flashbots/relayscan/blob/bidstream/services/bidcollect/types.go#L13-L18): +- Source types: - `0`: `getHeader` polling - `1`: Data API polling - `2`: Ultrasound top-bid Websockets stream @@ -26,7 +26,7 @@ It should expose these as: - Ultrasound websocket stream - doesn't expose optimistic, thus that field is always false -clickhouse-local queries: +Useful [clickhouse-local](https://clickhouse.com/docs/en/operations/utilities/clickhouse-local) queries: ```bash clickhouse local -q "SELECT source_type, COUNT(source_type) FROM 'top_2024-06-02_18-00.tsv' GROUP BY source_type ORDER BY source_type;" @@ -34,20 +34,26 @@ clickhouse local -q "SELECT source_type, COUNT(source_type) FROM 'top_2024-06-02 ## Status +Mostly working +- PR: https://github.com/flashbots/relayscan/pull/37 +- Example output: https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa395 + Run: ```bash -# Collect bids from ultrasound stream + data API, save to directory "tsv//.tsv" -go run . service bidcollect --out tsv --data-api --ultrasound-stream -``` +# CSV output (into `csv//.csv`) +go run . service bidcollect --data-api --ultrasound-stream -Example output: https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa395 +# TSV output (into `data//.tsv`) +go run . service bidcollect --out data --out-tsv --data-api --ultrasound-stream +``` ### Done - Ultrasound bid stream - Data API polling (at t-4, t-2, t-0.5, t+0.5, t+2) -- CSV Output +- getHeader polling at t+1 +- CSV/TSV Output - Writing to hourly CSV files (one file for top bids, and one for all bids) - Cache for deduplication - Script to combine into single CSV From 4642620eaa20d5e2819de0c182169db5c0384b1f Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Wed, 5 Jun 2024 10:18:03 +0200 Subject: [PATCH 34/44] upload script --- ...-combine.sh => bids-combine-and-upload.sh} | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) rename scripts/{bids-combine.sh => bids-combine-and-upload.sh} (50%) diff --git a/scripts/bids-combine.sh b/scripts/bids-combine-and-upload.sh similarity index 50% rename from scripts/bids-combine.sh rename to scripts/bids-combine-and-upload.sh index 77ca792..ce9585c 100755 --- a/scripts/bids-combine.sh +++ b/scripts/bids-combine-and-upload.sh @@ -1,6 +1,6 @@ #!/bin/bash # -# Combine bid CSVs (from bidcollect) into a single CSV +# Combine bid CSVs (from bidcollect) into a single CSV, and upload to R2/S3 # set -e @@ -12,11 +12,12 @@ fi cd $1 date=$(basename $1) +ym=${date:0:7} echo $date echo "" # ALL BIDS -fn_out="all_${date}.csv" +fn_out="${date}_all.csv" fn_out_zip="${fn_out}.zip" fn_out_gz="${fn_out}.gz" rm -f $fn_out $fn_out_zip $fn_out_gz @@ -31,17 +32,24 @@ for fn in $(\ls all*); do fi tail -n +2 $fn >> $fn_out done + +wc -l $fn_out zip ${fn_out_zip} $fn_out echo "Wrote ${fn_out_zip}" -gzip $fn_out -echo "Wrote ${fn_out_gz}" rm -f $fn_out +# Upload +if [[ "${UPLOAD}" == "1" ]]; then + echo "Uploading to R2 and S3..." + aws --profile r2 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" --endpoint-url "https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + aws --profile s3 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" +fi + echo "" # TOP BIDS echo "Combining top bids..." -fn_out="top_${date}.csv" +fn_out="${date}_top.csv" fn_out_zip="${fn_out}.zip" fn_out_gz="${fn_out}.gz" rm -f $fn_out $fn_out_zip $fn_out_gz @@ -55,8 +63,15 @@ for fn in $(\ls top*); do fi tail -n +2 $fn >> $fn_out done + +wc -l $fn_out zip ${fn_out_zip} $fn_out echo "Wrote ${fn_out_zip}" -gzip $fn_out -echo "Wrote ${fn_out_gz}" rm -f $fn_out + +# Upload +if [[ "${UPLOAD}" == "1" ]]; then + echo "Uploading to R2 and S3..." + aws --profile r2 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" --endpoint-url "https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" + aws --profile s3 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" +fi From bd733c17be7207f85644947fb268b306316eda38 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 7 Jun 2024 11:29:37 +0200 Subject: [PATCH 35/44] docs update --- docs/2024-06_bidcollect.md | 86 +++++++++++++++++++ docs/adrs/202405-bidcollect.md | 19 +++- docs/img/bidcollect-overview.png | Bin 0 -> 191231 bytes scripts/bids-combine-and-upload-yesterday.sh | 30 +++++++ scripts/bids-combine-and-upload.sh | 8 ++ services/bidcollect/types.go | 2 +- 6 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 docs/2024-06_bidcollect.md create mode 100644 docs/img/bidcollect-overview.png create mode 100644 scripts/bids-combine-and-upload-yesterday.sh diff --git a/docs/2024-06_bidcollect.md b/docs/2024-06_bidcollect.md new file mode 100644 index 0000000..1c60e29 --- /dev/null +++ b/docs/2024-06_bidcollect.md @@ -0,0 +1,86 @@ +# Bid Collection + +Relayscan should collect bids across relays: + +1. [getHeader polling](https://ethereum.github.io/builder-specs/#/Builder/getHeader) +2. [Data API polling](https://flashbots.github.io/relay-specs/#/Data/getReceivedBids) +3. [Ultrasound top-bid websocket stream](https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md) + +Output: + +1. CSV file archive +2. Websocket/SSE stream (maybe) + +See also: + +- [Example output](https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa395) +- TODO: link CSV files + +--- + +## Notes on data sources + +Source types: +- `0`: `getHeader` polling +- `1`: Data API polling +- `2`: Ultrasound top-bid Websockets stream + +Different data sources have different limitations: + +- `getHeader` polling: + - Some relays only allow a single `getHeader` request per slot, so we time it at t=1s + - Header only has limited information with these implications: + - Optimistic is always `false` + - Does not include `builder_pubkey` + - Does not include bid timestamp (need to use receive timestamp) +- Data API polling: + - Has all the necessary information + - Due to rate limits, we only poll at specific times + - Polling at t-4, t-2, t-0.5, t+0.5, t+2 (see also [`services/bidcollect/data-api-pollser.go`](services/bidcollect/data-api-poller.go#64-69)) + - Ultrasound websocket stream + - doesn't expose optimistic, thus that field is always `false` + +## Other notes + +- Bids are deduplicated based on this key: + - `fmt.Sprintf("%d-%s-%s-%s-%s", bid.Slot, bid.BlockHash, bid.ParentHash, bid.BuilderPubkey, bid.Value)` + - this means only the first bid for a given key is stored, even if - for instance - other relays also deliver the same bid + +--- + +## Running it + +By default, the collector will output CSV into `//.csv` + +```bash +# Start data API and ultrasound stream collectors +go run . service bidcollect --data-api --ultrasound-stream --all-relays + +# getHeader needs a beacon node too +go run . service bidcollect --get-header --beacon-uri http://localhost:3500 --all-relays +``` + +--- + +## Useful Clickhouse queries + +Useful [clickhouse-local](https://clickhouse.com/docs/en/operations/utilities/clickhouse-local) queries: + +```bash +clickhouse local -q "SELECT source_type, COUNT(source_type) FROM '2024-06-02_top-00.tsv' GROUP BY source_type ORDER BY source_type;" + +# Get bids > 1 ETH for specific builders (CSV has 10M rows) +time clickhouse local -q "SELECT count(value), quantile(0.5)(value) as p50, quantile(0.75)(value) as p75, quantile(0.9)(value) as p90, max(value) FROM '2024-06-05_all.csv' WHERE value > 1000000000000000000 AND builder_pubkey IN ('0xa01a00479f1fa442a8ebadb352be69091d07b0c0a733fae9166dae1b83179e326a968717da175c7363cd5a13e8580e8d', '0xa02a0054ea4ba422c88baccfdb1f43b2c805f01d1475335ea6647f69032da847a41c0e23796c6bed39b0ee11ab9772c6', '0xa03a000b0e3d1dc008f6075a1b1af24e6890bd674c26235ce95ac06e86f2bd3ccf4391df461b9e5d3ca654ef6b9e1ceb') FORMAT TabSeparatedWithNames;" +count(value) p50 p75 p90 max(value) +1842 1789830446982354000 2279820737908906200 4041286254343376400 8216794401676997763 + +real 0m2.202s +user 0m17.320s +sys 0m0.589s +``` + +--- + +## Architecture + +![Architecture](./img/bidcollect-overview.png) \ No newline at end of file diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 2add5c4..2b560ad 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -1,5 +1,8 @@ # ADR for bid collection +New and cleaned up doc in [2024-06_bidcollect.md](../2024-06_bidcollect.md). + + ## Goal Relayscan should collect bids across relays: @@ -21,7 +24,10 @@ It should expose these as: - `2`: Ultrasound top-bid Websockets stream - getHeader polling - some relay only allow a single getHeader request per slot, so we time it at t=1s - - header only has limited information. need to use receive timestamp, and optimistic is always false + - header only has limited information with these implications: + - optimistic is always `false` + - does not include `builder_pubkey` + - does not include bid timestamp (need to use receive timestamp) - Ultrasound relay doesn't support repeated getHeader requests - Ultrasound websocket stream - doesn't expose optimistic, thus that field is always false @@ -29,7 +35,16 @@ It should expose these as: Useful [clickhouse-local](https://clickhouse.com/docs/en/operations/utilities/clickhouse-local) queries: ```bash -clickhouse local -q "SELECT source_type, COUNT(source_type) FROM 'top_2024-06-02_18-00.tsv' GROUP BY source_type ORDER BY source_type;" +clickhouse local -q "SELECT source_type, COUNT(source_type) FROM '2024-06-02_top-00.tsv' GROUP BY source_type ORDER BY source_type;" + +# Get bids > 1 ETH for specific builders (CSV has 10M rows) +time clickhouse local -q "SELECT count(value), quantile(0.5)(value) as p50, quantile(0.75)(value) as p75, quantile(0.9)(value) as p90, max(value) FROM '2024-06-05_all.csv' WHERE value > 1000000000000000000 AND builder_pubkey IN ('0xa01a00479f1fa442a8ebadb352be69091d07b0c0a733fae9166dae1b83179e326a968717da175c7363cd5a13e8580e8d', '0xa02a0054ea4ba422c88baccfdb1f43b2c805f01d1475335ea6647f69032da847a41c0e23796c6bed39b0ee11ab9772c6', '0xa03a000b0e3d1dc008f6075a1b1af24e6890bd674c26235ce95ac06e86f2bd3ccf4391df461b9e5d3ca654ef6b9e1ceb') FORMAT TabSeparatedWithNames;" +count(value) p50 p75 p90 max(value) +1842 1789830446982354000 2279820737908906200 4041286254343376400 8216794401676997763 + +real 0m2.202s +user 0m17.320s +sys 0m0.589s ``` ## Status diff --git a/docs/img/bidcollect-overview.png b/docs/img/bidcollect-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..bd27cc7e72822fbaa944ab46cd984e845eeb1b21 GIT binary patch literal 191231 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

CM1YXAPl|SsN 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 literal 0 HcmV?d00001 diff --git a/scripts/bids-combine-and-upload-yesterday.sh b/scripts/bids-combine-and-upload-yesterday.sh new file mode 100644 index 0000000..18e94e8 --- /dev/null +++ b/scripts/bids-combine-and-upload-yesterday.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# +# This is a quick and dirty script to create a daily archive for yesterday and upload to Cloudflare R2 and AWS S3. +# +set -o errexit +set -o nounset +set -o pipefail +if [[ "${TRACE-0}" == "1" ]]; then + set -o xtrace +fi + +# print current date +echo "now: $(date)" + +# get yesterday's date +d=$(date -d yesterday '+%Y-%m-%d') +echo "upload for: $d" + +# change to project root directory +cd "$(dirname "$0")" +cd .. + +# load environment variables +source .env.prod + +# archive and upload! +UPLOAD=1 ./scripts/bids-combine-and-upload.sh "/mnt/data/relayscan-bids/$d/" + +# update website +# make website diff --git a/scripts/bids-combine-and-upload.sh b/scripts/bids-combine-and-upload.sh index ce9585c..438e79b 100755 --- a/scripts/bids-combine-and-upload.sh +++ b/scripts/bids-combine-and-upload.sh @@ -45,6 +45,10 @@ if [[ "${UPLOAD}" == "1" ]]; then aws --profile s3 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" fi +if [[ "${DEL}" == "1" ]]; then + rm -f all* +fi + echo "" # TOP BIDS @@ -75,3 +79,7 @@ if [[ "${UPLOAD}" == "1" ]]; then aws --profile r2 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" --endpoint-url "https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" aws --profile s3 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" fi + +if [[ "${DEL}" == "1" ]]; then + rm -f top* +fi diff --git a/services/bidcollect/types.go b/services/bidcollect/types.go index 678a074..5d37a7a 100644 --- a/services/bidcollect/types.go +++ b/services/bidcollect/types.go @@ -63,7 +63,7 @@ func (bid *CommonBid) ToCSVFields() []string { if bid.Timestamp > 0 { tsMs = bid.Timestamp * 1000 } else { - tsMs = bid.ReceivedAtMs + tsMs = bid.ReceivedAtMs // fallback for getHeader bids (which don't include the bid timestamp) } } From 083b3e873f8a00db95c6648ec1c5b87ebe8eddb4 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 7 Jun 2024 11:42:43 +0200 Subject: [PATCH 36/44] start website --- cmd/service/bidcollect.go | 27 ++++ common/utils.go | 11 ++ common/utils_test.go | 20 +++ go.mod | 3 +- go.sum | 2 + services/bidcollect/website/devserver.go | 145 ++++++++++++++++++ services/bidcollect/website/htmldata.go | 83 ++++++++++ .../static/favicon/android-chrome-192x192.png | Bin 0 -> 20287 bytes .../static/favicon/android-chrome-512x512.png | Bin 0 -> 59938 bytes .../static/favicon/apple-touch-icon.png | Bin 0 -> 18532 bytes .../website/static/favicon/favicon-16x16.png | Bin 0 -> 809 bytes .../website/static/favicon/favicon-32x32.png | Bin 0 -> 2084 bytes .../website/static/favicon/favicon.ico | Bin 0 -> 15406 bytes .../website/static/favicon/site.webmanifest | 1 + services/bidcollect/website/static/styles.css | 0 .../bidcollect/website/templates/base.html | 126 +++++++++++++++ .../website/templates/index_files.html | 48 ++++++ .../website/templates/index_root.html | 20 +++ services/bidcollect/website/utils.go | 87 +++++++++++ 19 files changed, 572 insertions(+), 1 deletion(-) create mode 100644 services/bidcollect/website/devserver.go create mode 100644 services/bidcollect/website/htmldata.go create mode 100644 services/bidcollect/website/static/favicon/android-chrome-192x192.png create mode 100644 services/bidcollect/website/static/favicon/android-chrome-512x512.png create mode 100644 services/bidcollect/website/static/favicon/apple-touch-icon.png create mode 100644 services/bidcollect/website/static/favicon/favicon-16x16.png create mode 100644 services/bidcollect/website/static/favicon/favicon-32x32.png create mode 100644 services/bidcollect/website/static/favicon/favicon.ico create mode 100644 services/bidcollect/website/static/favicon/site.webmanifest create mode 100644 services/bidcollect/website/static/styles.css create mode 100644 services/bidcollect/website/templates/base.html create mode 100644 services/bidcollect/website/templates/index_files.html create mode 100644 services/bidcollect/website/templates/index_root.html create mode 100644 services/bidcollect/website/utils.go diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index b0d5d25..da29d3c 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -7,6 +7,7 @@ package service import ( "github.com/flashbots/relayscan/common" "github.com/flashbots/relayscan/services/bidcollect" + "github.com/flashbots/relayscan/services/bidcollect/website" "github.com/flashbots/relayscan/vars" "github.com/spf13/cobra" ) @@ -19,6 +20,9 @@ var ( outDir string outputTSV bool // by default: CSV, but can be changed to TSV with this setting + + runDevServerOnly bool // used to play with file listing website + devServerListenAddr = ":8095" ) func init() { @@ -33,12 +37,21 @@ func init() { // for saving to file bidCollectCmd.Flags().StringVar(&outDir, "out", "csv", "output directory for CSV/TSV") bidCollectCmd.Flags().BoolVar(&outputTSV, "out-tsv", false, "output as TSV (instead of CSV)") + + // for dev purposes + bidCollectCmd.Flags().BoolVar(&runDevServerOnly, "devserver", false, "only run devserver to play with file listing website") } var bidCollectCmd = &cobra.Command{ Use: "bidcollect", Short: "Collect bids", Run: func(cmd *cobra.Command, args []string) { + if runDevServerOnly { + log.Infof("Bidcollect devserver starting (%s) ...", vars.Version) + fileListingDevServer() + return + } + log.Infof("Bidcollect starting (%s) ...", vars.Version) // Prepare relays @@ -70,3 +83,17 @@ var bidCollectCmd = &cobra.Command{ bidCollector.MustStart() }, } + +func fileListingDevServer() { + webserver, err := website.NewDevWebserver(&website.DevWebserverOpts{ //nolint:exhaustruct + ListenAddress: devServerListenAddr, + Log: log, + }) + if err != nil { + log.Fatal(err) + } + err = webserver.StartServer() + if err != nil { + log.Fatal(err) + } +} diff --git a/common/utils.go b/common/utils.go index 6740cac..012854d 100644 --- a/common/utils.go +++ b/common/utils.go @@ -5,8 +5,10 @@ import ( "math/big" "net/url" "runtime" + "strings" "time" + "github.com/dustin/go-humanize" "github.com/ethereum/go-ethereum/params" "github.com/flashbots/mev-boost-relay/beaconclient" "github.com/flashbots/relayscan/vars" @@ -137,3 +139,12 @@ func GetMemMB() uint64 { runtime.ReadMemStats(&m) return m.Alloc / 1024 / 1024 } + +// HumanBytes returns size in the same format as AWS S3 +func HumanBytes(n uint64) string { + s := humanize.IBytes(n) + s = strings.Replace(s, "KiB", "KB", 1) + s = strings.Replace(s, "MiB", "MB", 1) + s = strings.Replace(s, "GiB", "GB", 1) + return s +} diff --git a/common/utils_test.go b/common/utils_test.go index 510e03f..5747df9 100644 --- a/common/utils_test.go +++ b/common/utils_test.go @@ -3,6 +3,7 @@ package common import ( "testing" + "github.com/dustin/go-humanize" "github.com/stretchr/testify/require" ) @@ -13,3 +14,22 @@ func TestSlotToTime(t *testing.T) { func TestTimeToSlot(t *testing.T) { require.Equal(t, uint64(6591598), TimeToSlot(SlotToTime(6591598))) } + +func TestBytesFormat(t *testing.T) { + n := uint64(795025173) + + s := humanize.Bytes(n) + require.Equal(t, "795 MB", s) + + s = humanize.IBytes(n) + require.Equal(t, "758 MiB", s) + + s = HumanBytes(n) + require.Equal(t, "758 MB", s) + + s = HumanBytes(n * 10) + require.Equal(t, "7.4 GB", s) + + s = HumanBytes(n / 1000) + require.Equal(t, "776 KB", s) +} diff --git a/go.mod b/go.mod index 62f60a9..b59dee0 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22 require ( github.com/NYTimes/gziphandler v1.1.1 + github.com/dustin/go-humanize v1.0.1 github.com/ethereum/go-ethereum v1.11.6 github.com/ferranbt/fastssz v0.1.3 github.com/flashbots/go-boost-utils v1.6.0 @@ -21,6 +22,7 @@ require ( github.com/stretchr/testify v1.8.3 github.com/tdewolff/minify v2.3.6+incompatible go.uber.org/atomic v1.11.0 + go.uber.org/zap v1.24.0 golang.org/x/text v0.15.0 ) @@ -90,7 +92,6 @@ require ( github.com/tklauser/numcpus v0.6.0 // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.24.0 // indirect golang.org/x/crypto v0.23.0 // indirect golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect golang.org/x/net v0.25.0 // indirect diff --git a/go.sum b/go.sum index e27457d..2e8b7de 100644 --- a/go.sum +++ b/go.sum @@ -188,6 +188,8 @@ github.com/dop251/goja v0.0.0-20200721192441-a695b0cdd498/go.mod h1:Mw6PkjjMXWbT github.com/dop251/goja v0.0.0-20220405120441-9037c2b61cbf/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk= github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eclipse/paho.mqtt.golang v1.2.0/go.mod h1:H9keYFcgq3Qr5OUJm/JZI/i6U7joQ8SYLhZwfeOo6Ts= github.com/edsrzf/mmap-go v1.0.0 h1:CEBF7HpRnUCSJgGUb5h1Gm7e3VkmVDrR8lvWVLtrOFw= github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= diff --git a/services/bidcollect/website/devserver.go b/services/bidcollect/website/devserver.go new file mode 100644 index 0000000..065acdf --- /dev/null +++ b/services/bidcollect/website/devserver.go @@ -0,0 +1,145 @@ +// Package website contains the service delivering the website +package website + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + _ "net/http/pprof" + "time" + + "github.com/gorilla/mux" + "github.com/sirupsen/logrus" + "github.com/tdewolff/minify" + "github.com/tdewolff/minify/html" + uberatomic "go.uber.org/atomic" +) + +var ErrServerAlreadyStarted = errors.New("server was already started") + +type DevWebserverOpts struct { + ListenAddress string + Log *logrus.Entry +} + +type DevWebserver struct { + opts *DevWebserverOpts + log *logrus.Entry + + srv *http.Server + srvStarted uberatomic.Bool + minifier *minify.M +} + +func NewDevWebserver(opts *DevWebserverOpts) (server *DevWebserver, err error) { + minifier := minify.New() + minifier.AddFunc("text/css", html.Minify) + minifier.AddFunc("text/html", html.Minify) + minifier.AddFunc("application/javascript", html.Minify) + + server = &DevWebserver{ //nolint:exhaustruct + opts: opts, + log: opts.Log, + minifier: minifier, + } + + return server, nil +} + +func (srv *DevWebserver) StartServer() (err error) { + if srv.srvStarted.Swap(true) { + return ErrServerAlreadyStarted + } + + srv.srv = &http.Server{ //nolint:exhaustruct + Addr: srv.opts.ListenAddress, + Handler: srv.getRouter(), + + ReadTimeout: 600 * time.Millisecond, + ReadHeaderTimeout: 400 * time.Millisecond, + WriteTimeout: 3 * time.Second, + IdleTimeout: 3 * time.Second, + } + + err = srv.srv.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (srv *DevWebserver) getRouter() http.Handler { + r := mux.NewRouter() + r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("./website/static")))) + + r.HandleFunc("/", srv.handleRoot).Methods(http.MethodGet) + r.HandleFunc("/index.html", srv.handleRoot).Methods(http.MethodGet) + r.HandleFunc("/ethereum/mainnet/{month}/index.html", srv.handleMonth).Methods(http.MethodGet) + + return r +} + +func (srv *DevWebserver) RespondError(w http.ResponseWriter, code int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + resp := HTTPErrorResp{code, message} + if err := json.NewEncoder(w).Encode(resp); err != nil { + srv.log.WithField("response", resp).Error("Couldn't write error response", "error", err) + http.Error(w, "", http.StatusInternalServerError) + } +} + +func (srv *DevWebserver) RespondOK(w http.ResponseWriter, response any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(response); err != nil { + srv.log.WithField("response", response).Error("Couldn't write OK response", "error", err) + http.Error(w, "", http.StatusInternalServerError) + } +} + +func (srv *DevWebserver) handleRoot(w http.ResponseWriter, req *http.Request) { + tpl, err := ParseIndexTemplate() + if err != nil { + srv.log.Error("wroot: error parsing template", "error", err) + return + } + w.WriteHeader(http.StatusOK) + + data := *DummyHTMLData + data.Path = "/" + err = tpl.ExecuteTemplate(w, "base", data) + if err != nil { + srv.log.Error("wroot: error executing template", "error", err) + return + } +} + +func (srv *DevWebserver) handleMonth(w http.ResponseWriter, req *http.Request) { + vars := mux.Vars(req) + + layout := "2006-01" + _, err := time.Parse(layout, vars["month"]) + if err != nil { + srv.RespondError(w, http.StatusBadRequest, "invalid date") + return + } + + tpl, err := ParseFilesTemplate() + if err != nil { + srv.log.Error("wroot: error parsing template", "error", err) + return + } + w.WriteHeader(http.StatusOK) + + data := *DummyHTMLData + data.Title = vars["month"] + data.Path = fmt.Sprintf("ethereum/mainnet/%s/index.html", vars["month"]) + + err = tpl.ExecuteTemplate(w, "base", &data) + if err != nil { + srv.log.Error("wroot: error executing template", "error", err) + return + } +} diff --git a/services/bidcollect/website/htmldata.go b/services/bidcollect/website/htmldata.go new file mode 100644 index 0000000..aca4981 --- /dev/null +++ b/services/bidcollect/website/htmldata.go @@ -0,0 +1,83 @@ +package website + +import ( + "text/template" + + "github.com/flashbots/relayscan/common" +) + +type HTMLData struct { + Title string + Path string + + // Root page + EthMainnetMonths []string + + // File-listing page + CurrentNetwork string + CurrentMonth string + Files []FileEntry +} + +type FileEntry struct { + Filename string + Size uint64 + Modified string +} + +func prettyInt(i uint64) string { + return printer.Sprintf("%d", i) +} + +func caseIt(s string) string { + return caser.String(s) +} + +func percent(cnt, total uint64) string { + p := float64(cnt) / float64(total) * 100 + return printer.Sprintf("%.2f", p) +} + +func substr10(s string) string { + return s[:10] +} + +var DummyHTMLData = &HTMLData{ + Title: "", + Path: "", + + EthMainnetMonths: []string{ + "2023-08", + "2023-09", + }, + + CurrentNetwork: "Ethereum Mainnet", + CurrentMonth: "2023-08", + Files: []FileEntry{ + {"2023-08-29.csv.zip", 97210118, "02:02:23 2023-09-02"}, + {"2023-08-29.parquet", 90896124, "02:02:09 2023-09-02"}, + {"2023-08-29_transactions.csv.zip", 787064375, "02:02:43 2023-09-02"}, + {"2023-08-30.csv.zip", 97210118, "02:02:23 2023-09-02"}, + {"2023-08-30.parquet", 90896124, "02:02:09 2023-09-02"}, + {"2023-08-30_transactions.csv.zip", 787064375, "02:02:43 2023-09-02"}, + {"2023-08-31.csv.zip", 97210118, "02:02:23 2023-09-02"}, + {"2023-08-31.parquet", 90896124, "02:02:09 2023-09-02"}, + {"2023-08-31_transactions.csv.zip", 787064375, "02:02:43 2023-09-02"}, + }, +} + +var funcMap = template.FuncMap{ + "prettyInt": prettyInt, + "caseIt": caseIt, + "percent": percent, + "humanBytes": common.HumanBytes, + "substr10": substr10, +} + +func ParseIndexTemplate() (*template.Template, error) { + return template.New("index.html").Funcs(funcMap).ParseFiles("services/bidcollect/website/templates/index_root.html", "services/bidcollect/website/templates/base.html") +} + +func ParseFilesTemplate() (*template.Template, error) { + return template.New("index.html").Funcs(funcMap).ParseFiles("services/bidcollect/website/templates/index_files.html", "services/bidcollect/website/templates/base.html") +} diff --git a/services/bidcollect/website/static/favicon/android-chrome-192x192.png b/services/bidcollect/website/static/favicon/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..d6475e4623839c11e6a2419e0c73fd65ba043f2c GIT binary patch literal 20287 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 W7dH~wdYyj&0000v^bz* zjQ9WmAP2}wim7`V9{VAC&ir|R?q#hgBIOo)+S=CDl}kUBmfM$W>OpSETacxzyHLKH zd33?z06$KA`c>>n7PkU-0g&ZJmZ^@|TuYv_Ia=)Tu3go{R2HTF(C@$ZB2x1KmyIf8 zl=NzJOc34gI5F++e&qO6;H(w#1FGp#XqV$}lzz+&a@Mo{P7lB_>iUzlv84CYToMoC5|L8E6E9ahPM$ z2{yJ+rPO!^k^Hlm+!%uu2}BLo_!O4VKnP%eB?BM7N}pl;l&aneJn!~$%ojfsgOVd! zAojxu66NN=Ov#cX!j7pUSz6)$s{#3hDUuT&IX}>!G&l5TXjgKVabhiy)laD^oPdMi z9qv;}~y?Ctmh=3Eg zDn^1ni|BusfM9~fz|DXM?qEW*6t{pIdz?y8$PyR?woVx%8&@@aw@V3N7kP#iTN>d) z`d{TlhcL-e;_Ly2|D4i+OnK`LGV zH{fr+o_DXiMK~SJ1CaEI1>Xl%eiE$Cafe@sNBY9i|DRL)kBC48iriLUD`qEloa?9k z-SuI=uV>SMaOgd@!-wv#AeMoA!fz@6wFOvdu&7?@1us6w6FbZ!H^9QEE>e^>jSL#- z2-f-XW{dxMtKA0-9+*yBag@E-wRn&KC!l%l`GEq^Vkr;ong1{8`Iz@1VhyKsG`U)^ z9#QtI-;Sb@BIa{Ca6QLw76v9~**S*NYhklc!hPFRoYO1Ewtyp#zPejw-4CXlyWmGM=&^0PV$~~J74kDtkbr|a`~R6(lm}*Js59ivy1%}{ z8ZTuU$LScpQ)v$z_^(s_842(JPt`7Jl`KQgMxFwc?3zDM{qH(w;=uB+fIC%VcR?tW z_v$rgi?+Mi@qc}%DF_UA-pB@UHgAc}{#RK*=ZJzN^aJ#m(FkGdN%FdG42(ekU)KCa z12Y8MDWS&oE9t+q7Z4XE0Fc3Q5*lv-S@(MXRa=4-XaW}DVgpF{|B4R!SIGT47$Grm zR}vr+;EoUeFXQ1~BDoR)3Na?))~tTz{wttf49o=hYzjz~_^*wNgPX~n=;3$aiUM~? z08P$v0l&)rUEz!cJQY=Vu5KYYKV}pG_BzmDlhY~(eO~&%lR*F`0m*qna1Gmh8s1cf zXc#vtO5|qp0i{0w?`{WV;flXU5Owp6iPZJOItjn|1AcO%Cw{@dS0D}>$%!!^pQxGd z{yybT08CAA27ojP`;mzH|2^V@)Vl)B2%+v{X{WxQ;YR4xI07yW_X4;7MzFpk`k%lk z>Oek$EN;5r_fR0W0wn;Xz*&l$uz(GsFyn}5yR5AL&tM?{zjwIV6N*}|H;f42N0GNH zvd6lu4|X4-mWitP_jK`>kem=DBzsVTsfE1#3xE^Xd{>t8ng{@Q(j`&+lYfspP7TRd zJZ_5t&+y8u5Q zO^z1OcX;<5(Z#>pHO2r4gY(&w9vhhPjW_@^WUww3t#Ar1fMbie|B9WR6-X2O7+H1W zPOoaL_)#~ufcxOCjXWFWZ(%*<-t>0Ql^(f~DO4bLQvARcT=?J&A8h|H1aP{qKWn3lCF2@uA1{W~ zlA;e~<^S^(j6ifG*A`ni+!X?le>>&N6DMGX0veifw&k>=_`7ottI81###){G|Kb9e z8N+zp57_LofTX^Pt4{suA^6Azn3*cUgZFUZ+ps)u0^Byhzmt;rSEX%Sm@~}%(+d1< zc?VR7edU`F5V(wUJ&0 z%ni@tvz-f%$A7Sw#YbHC0Fb33VgLW~iz~UPBd}46m2@d;)KSoBTCqIBnIH~0A>q1I zww{e~kISKi;DpeLNINv~-+9cq3TeU?kc_#3Dbs4;rFQ-+P*q_Fu>VEXziY>*>GCo* zk*aOvo~82%!zwoL-Iv|}RucmTN`r-79VKMprsJr&=D!k#Tz+Uil?wuVI?HqkqAZ8; z#r86J-@M)MVV|h?(h0#I*7(c}p5y3WA2i13L?ri)AfK=9MLEVGGWXfD4Sv`->vEky z=&aA+u$}A1%yGBFH&kKu_4r;6i+@jJ&jlmHg-5ZfJc_3@uhbKUDll{4OcFObT4(Y* zc4=9_@bvHyzv_r8!}+dHKtv>T@jbzpjN^5KlxzWgJ{J)=4EbQj|^r*zEU zgT2L9{d@__;c9NTT94Mhi;J&@{QH)PG|2r`pMOc=n5%tLxLlo@do+`l?r&DOR6wlU z=#eJQPRR+d;KZQ#>R^KqW$&$%H8$u&W<7(l1fST@JKmH7RgVZRnMDM_31IH zw$GIAOJ1>Ux1>2DWui$Aq>sT5TX(Agj+>S__QxFkvks!ieBNg-QfKYUINcl11uJG} zT86u~+Dx}$(L~&<50gb?3$OjAdVbQeOQsENcguURFD9&#G`-FIwjwWPJ$^ZM&jWLP zf5i|dqF@}2koyG=zol)!PB!7G?V!PvqaJqz9t@eg?JD|dTvk1iIx7C9)v zHU-;}2Z?__S{S8oQ)#z4?_`gq+Ty}a1BYyq^u#u?N{5?ioziK4&a`cFp89EMf&kQV zjTIN~tE^-T*as&}`nD7_|8P29K;gSpC=BiyXhWSw?=Yb1c%a>+xP>7Oc(OD}p zw(uzVcx^QV68lYiQvm>PZT>@Olc-bu z@k>_$BIjAuN%49smf+YS{;6KeM($X`N6d-~VA($cW(mU&R0E;k%`9n4hq>jnkm?02Mh<@$WhJwq+;6@G*M#1@sGnZvv07x>|@yDx63k)lF)r2B%%qum8YKnDc2PLip*GZIlZl8#I76j+U zM+%;*9yfpE%|Yn!FGaj{>pY?u$rPCL-OiH^#4hDd?ahxar99etF)C`)r?v1dz~;{6 zclqL0eT-Owwz_GwM_qp-AT{|FJMRabvuPRvSUrF4AGh{+^!1kIt6Pi$!aH()9(e#( zGWT~2yjBCm8V6a%0UpwGF(W;@2cTxQ(Z+qn>~kYT0i{@y+WzT4$`d?L@l-tV?^azv z#~jhVy1cZqPJ*Ai0<~I0z+MI!^e=@iAO4yG7rT3&^!ia2MuYMNuD>y2Q|V!P@QCjR z0Eq(>aBl*fZ`)Z-e6nB3K=ZA6^NjTLZ_~JJnCnl|Kee4Xo4O8i`7D)PJJS9>j+Co5 zK~*nt0Lml8T0(rtoAUeuC5M^QD?~RxocdKYX1R0Tb!#+jzCy{cp80FakbdB-SvCmi zwK|Vxqm>@1va(cX9G4`gfbn#OhMyB7^GXV{Y`nkcO&S>JIWcmK$#I=*A55CD#GwLX zY0KdQ+^LM_-Xe;Mxxw^B(82HRF8PO{6!`r|XU7d#`l3wnAP;E@$}84q1%wKiDy?_2 z%LzXqJLEF2V|&c1_H6bXKZtbfFd1HEM%OXxF$KAT{6V`m0cm@eGjYSjNh*HRcd`kQ z{^K_ES0mPc(P_I;XUta)1HhRXfH1%us zdiX`m0FA1WQbtG8PD_cKf=OeHdo|=J6i*>#Gx^LiLclE7WY1#Ya_U##dcZxu(JL&< zSFhasb8A1dM+nu1%vIVtQp{D8bLM7E1ohw83*vZ~DnBtT_`@$#!CY;B^`4v2y02*^ z+^DLFOnWI1N?%c%c3;w7cA$>yyQd4ZztFg6H5ZjJR{DdHQm1J6KaD;SwiM&FxEywi&0JXVsW((mfSKmLgN zFg;A}mU@M4oPPGv#rBTVxxYF&3+0k`WW9RkAOl7&L{Bzc&lkJjf*PLnG*l*#P&TpH z#%eK#**4jssdlK~PXc3M)?taVZWAX{l;o@GIYEFoH#U9Bfv~ml7uy7o|BuW~-<7o= zDJ%wc@^)s1NF$+{%MAf;tHLzm1(h>?1*c(^-saa%^-do-{PSAMbmAO^*o*`srPj7? zNXs?C#txecx6%#>v;IPD0#YAf7^Tq7IDF_!5x_&{9zVddxsM*ek0{fr(u2q9i()gn-1A^ggf8c>w>w1y9`$$=c^^+PyW z4A2x}iAVlY85rzL(<Wc^g>1{oI_AAucxqWnwZBoF2DOQJtU||pT}dxE>D zRaF3okK^VHKbEL{?~q|T2pKe=h0V>^$FhD5h4+5nIS^7X)+xCIPu@NHs(P0Psya&f zBrh-j+N|jX&#`+Wz!yf#IsxjcaXmT4k0>K~AgE&o{$fRRfj;r3J!5&ezH&S5N}kB^ zR~od;JuLO>#Q^gU4IfvCUp;S1n;Gm;?3%dOZ~W)Zwm}L=^Plj7*fbiJMZlwI=Midc z*&P;uZzRj-2l0)S6{zW(w#8y5223IBFvohoy3q9^o?1eX7O4Bbau5Z*R zHcl3FILGh>>Lqw`A(WHi$jvD0j&I40d*{nKEUVW3?QtP%6YW%W*r%!fDBGHdcxF$I zY&fUrj*&K+sJYk*bWoJz*2CnqF@X73}1<^@ZwFc%W%DvSei| zL1nRVXJ<(^Vhz0>71rPrF}y#r>ZqDCm!;qav&uM+LX3QoSATovc^*g+H2=xAOQj@0 zqV42N6!QhHGt0m7=`fH*;qLiU{Op=b!j_Fw>z70VKgy~YaH*eR!)3CWo1LFwJ96iE zHG|CANHj3lsma!>2lz17+N6+g8gR}-b*1=b(4<8{(VqhnA{g75ma0&MDio$+_n)_6 zt%i#2o-+r_ zt{%?eKsdMYPAS`0bxzl{ly>@#`wCUGb){9Q3A)mvaE~JM_(J{Zqq)m+%_&f&1gv^l z(Ch2K9k}qpv5&CdqLecukWk}h@_^~5Ds<#Tq)VbZ#2i--XV3F{U4#tUELRqi6jxw- zd@f-Vw>@mQ!%YgVjoLQDwP=rUOwhBns`xVC(+e812qgi|A_xIA&-waZ{BMcrdCt;D znr1zuX5kDjK=Vl)Q>@B1G#YE4HyX$sbW$HinT8G72j(*Er(|CVI<#f$8 zog0t|z*`-txF+a@VnpzF?jOiDl*pK>YOqc@z!iu(p>`5mti#e6KIthRP?WYqKM zzUNK#Rv2YH{>^S&%OJT zh`f5!6040v%t4cEzX;K+fBt5w>XybypXuk=_wAIs>r;b;&v;N*?!w(f&U@He_T_53 z@f^{!pnK(!3-+lkw?y=HTBgFt105uUsu}A6SBPdnX!9C{g(3!oIoazD2qo zs&g&y4*{JPvCa(DA^0}~&ZfPuXgO=NdHiLxY((K5NN$mwl70E)MCDDJIu5jM3mnrJiTRYU70M`quuHz=`BoN`tJvlt{U}b3z$Ybue5;~!}9MOjjq=Z$@mRo7_SvRZx`XG-N2~NHxPG? z$frIat=yJNlTvu2p=BRutou_hvmbr|Fxf8T7(BR$4JDQB8TT#E7_4t*E67@b!0*Y+ z1A!PZob#~yNXFXlIEUb|PGDyAPnnqpldCo2lh**3?g(h($smDUc~qy(%j`lejrc-# zgpwehN7TNoSN6Lo1!M~lIPil#2;6(2>d-*@Y0;E z8cENHPP`u}-mkgOIv2(z5;3i)Ayyo!uz)bU%KIUaWQ>Acsn-1>Mzgjl6w3qnNC z9wUj=f!QhnmggVqeEdy)VFFD8N}fJ0b~5YZWx;)802Kc5*wzvWaFzBNTtJDC@uL-+ zMgG8B0Y_(xphng=q}F>$Xp~k&js2u;u;!cbBYpSv4$!IpNvVH!Q3RV}1$=KE(D6%O z4)84_yN#R!2FS?!4ziT?0Y6jm`(^sf;Z9cLIsn6^FXo>h33^h}v%#fzLGlWgbK@`Q z-9D1F6I!aC(&%*GZS%rV#9U+>{O?v^gelWFRIx?fDkz7Nt_?_8)?meDpX1jt-(qE#VTna{BK{H;RY?sTG!eynrQmDXd1< zmErNqzucrIvSv4TRK$=y_XEI}(cWt%jGU8?H%#MjHTGU1w!g^0wZ2bl-9+=zOK$UI zygXlm58Zz1xx)lkjTs$|c|Oa#t@QMB=<%?f|5C_Fd42dA9OAaadDi%GSie75fvq~^ zgPSjc3Vmaef2F|5YL}DWmHouaEtyBtK|D3g4pI5t$ ziqs;V6n|xer1lfeRne^DI8W4GMuZw!ZxDkj+)jP0G8o$UII{nSp|ar*2RwHb^8Vv+=~IB`++GeT^Ie~rvk<91)j}Bwv=kU!KFVXZRmLMrr(0n4hU!W|v9+q4 znBOS~KG@ppY2^68R7}h`m0&BiyC1@hi%i!!E>EM*f6@Z80Ywc9J!uAt6j_xS)|MLD zs_U&;;+;R(-YRtMQv?=Q&Xlu`3=~IlLX*272!Y>LWSdBXg4qmdtZ5$m5f= zQ~bG$)RH=HQ<|nVPyKAQbasGzRFsJ37z*mB;G2)aPj)mML+(@+xNHEq#`B@wvxHE7 z3(Yeg{#blqz=hXJhg?|PU4_kqyOzT003@`)p{Xq-KmcxRanQ!6wc+pTTNCZHL{Tw0 zgHlD1>+-xRt`Yt5g!1piy;^l|5BYK5P+IwDE(^u;1B!#&oqGL$U=T?K9M>fsBDPts z7vJGSQ2tkFi#ZuP$iRp8|8u`$vS7tOmCsx~ zslU_2i4YzJL!OJiXic4Pnzye}=nJnLefCMe`8j$bSqD#ZmZhRZ&^^7cl3k21?W`l0)64kfoUNx;dRhcEASQc=5;?mC-6Z~A@3roFonMdT)G>afv`BrHEi?R+ZGa98`_v=# zy#w2Vb#E=_gu7En+sYL1mqbTkDzqL8tahrYJlz)n+}wFpjrHasxqb^2qyxEn_3lyA zj5e9)gn=*4Jx|IAFoFI=wM@FCE3+GU!$@TgYPzTh0dfj1Q=g}FG$j9?O;E-H_CG5w z$5mKvlU4uvm~*%@iYQa$ODNfV!(@!G`glr8+0H|wSe{i%F?pcSh#a+{OR~_&`WF!X zuA||c>}Akho=TN9;TvA?@4SvCod~#^eQ!_XA}N)I?_;%S~)2))Zms+8L5BtvF+s-cIVbx z$JF3=Kpexlf*jW@6r(`R9b8Z_v7<-*j*c>ChhS}41*47L9c0j0 zzVr_~57&RnlIKbUPQ*&f8RP@CjxaG6CgGqiiGCJ~RV5c+DG7LNfo^p`d3Rq@vVCaq zK@b>O9kL~Sz`KZldcL^LKV`oX8UYLww(bmauwjRJ=y8y|{`4J_QjX!qZn3;WyR(H@ z>`)r@X;%tNgAz2~JX>~NN7P_O7(?Y&Tz|;7cijwDN$AcVryUMk*jSANT2me`kt$TW zGV4BoB{d%SPER+yr4wnxvq|I^3>t=jH$)2yxwtXu7q&rSiI-$CZFL3XNBUQvJVA+{ zg&Ulak>s`4FuuS(*b*d!zq*}rLbO&%?Mr*Y$X=d~Zp2tC%j)CPLxAF#vey_YX%u|K zBHc;O$s@RXWRDlvfRP^WdA?fVdv_fR5uCn(2Q^?31rY2ufQn>R{6pS9b^y{|5`EOfK4_ zbUhXWiYob&oz#g{YI$)Y2sJxGFJTZ^CPt5bYc!QMf*s#^3fjYd=GLEnHb818>O_Xs zNr`9z3DP2QyrDHB=--TZu{5M6(d}HH0u-yb+{v?Ny4Dj1bjv|tl^)6`B>CC(-DxaL z!$iIu*7KIG{N&K1j3Ue{JCb0i%!GJRFY_|@tL*zbPenlQaN?`cn=QCv35M6#ZDFai z1B!wyTai=+91x~!)hFbcQD0?{(i3Gc(l&`I>~%q6MZrI{SaZEmY4zua>~VxXMIn4w z)1Z39{$^wH33B#LQ-S~jYGxup-&$cLef&1Pp*~CUqDw!^Ji?E$_D{tnqgn4XI&@Y& z@PrNYp_QkcK7!k@4x47P9=cas9pgMBxp=dSwl8!@h~&YG0f+S@no_k6IC9_GA{J-) zg`M-v%W9OTtfE4o%M~lQ?g;M8HFg}t?+6(Dhvw$!A@o+M|2M~& zj3RB_Q1d=qm&&L3S`O8Ss;Li|^Vvcd+d#{*zX8y6OucVzxY%@Cs8x^4E--b$p3yx! z{LtgCv~A04&#SM78hCB#fhcaAsvgDYzX~OE@p1xeE}jPWGh?{=0QiECA)E)=j(I;5 z_I$`uI&8uNme92JzCBXdvXQg1YL_#lG0HXa`&&gBeui`9jL27+b||zXGI(q-o-RX` zaL0$2NfkkeC_&3?tFdghyItBsOgc~ zg@m3w=`!Vg3ad5%7Q{i%#}fN^JQiO>9X; zJBUG9u4N=6CmqtHvs-~*9tbjPONB_9?F$|VFqyBCaI<+-NHyFP#aKs8L`#D-UJk^f zIIaZrOKFsaOX@nB(jHq3-=Oqutd$mnE;uK9o~xDW|a)3K5_&Ec~7x${1AGvstLn zS;2K2M0DV@#lYFx%%<-T|CRp-0!)q(%Ys&Q1|ArWbHFb4KxD3s?22YO%7+MG$pZ~e z9o|V+(y(fj(t}n($)5tNH$zbL)#tgA^;Gr6^1p16O`IgxY&9QX(}0@I92yHJQ9n2U zoQgFw=2pHs5d7v6SseIVj>A+5vqiDu8te=WeqH~_Oq>yA_j9R`fr4TBnqZw~w>}a) z|D?m-%v|CqtSYBJXspbBG~M(K9Q(nl-RaNiS-IJjsHYZJ?eRxuF9ETh8TGuI5nqqa zqjv7kS;-aN_j(2fmt@|A_Ak~S0!E4MUWQ|u`EDEdNdo4FU~}IeuNuo2gdOLgg-%N) zPFB3rhoixvc1>-t5DdXDdskzH_beUPUn%i~T_V!}(f|D@gNcryu1vBN?Vj1 zZZFd;_vpD{NM*Mt4`x-1?$PfM?8xTWfEah^$(n?7rK`k>?WzqtpO=2hT)#6Z7X5nC z&ha$4A(S%wwM>ZsAZK_20+|2qu0F(fRnH3(5A%Y2L1rVpNom5i>Hcg$CZx9_dL32U z$6o~7pCvT;V!=?Qp-ckPC^~N}I!m*#k?RFJ>v8htH1j-md6eIq!(YFTa1Sqw`{CdeP5 z^RWL`-13P+v9#nSTJYD3*mFjSp>l@^(pz;1rvR%6oz!1wuIjX(oNji zfF;vGp&Ug^t%Eb&`p3vRemUn9vKPn^%L0Fdx0dT%9^n;OIr1yAi%Lkn83%v5meFCR zd@^TogGoMLY_=W{LX<}v7wQEfPT^#=)|ClS)){hnYboN@f2%B_ZNGN`YW|7gBx*%g zfUp~P0Bbjz3#YsCBRvi*qi%yCYyd1lcaN0@=UQ9sRecKm)OD<^LLGAN1ta!)BuPlD zS0&}DH|N{~Qm{ITd>caip|f^<%yPC;=u?L@pGH~~u`@SE(osQG!nZYwi5uZcfy`pN z!&_)ye?|b47^KE<$!C_9L2F!);<2S?R>SwU@vH2PPC%2R+vKk<6#_WOYO{Z^`Jjc3 zBZ179TuAf&&PXb}vDV6a3YA1^6D{SpeuG7$+D!zuSB}e}_7*Ex3Dpw?IvY0jzT!KJ zMV~rMW=S`Vi|hSQmhgk%!D6|vvL&zoAQ^#}(_D%MvS#QeaWTLA%xOi62ve`?b0HPh z>>MRhe1uZFml8_6&k?xMvTzt&mOApoWA^2jAd_aPVI{99oGiBHt(-VaK14Ufz97h<$TdxST5pfofBr{yaI7z|c|LkD(sHo&~Ol z$IAF^$RSFU>bZK3X|hOQs;0JIEZs}ZsCj0~M+|99bZ0{cm6`5FTAODN8GbM?{Npz1 zM?yske{}~8e=m{a?hMzgkmvTbTCDT{OGN;d9P2^8%ksW9T#V~Lm4DQg*)4UxyxGFR%uBTKk87A{LQj38^U2(I=pKCBp|`F&xX@8%VfhWL}tqoKZdc6DMsSG z8&bcqlyfm~aR>WDxUIB5N4ul@=@o}i<#%dyoMH9#RwV!wPo7I zk0o1bsxE@W)(<%38cbbZn(0fozwBO=*P1H^j01*~>yH0skEh7(0>CS-)1gRsvJQIb z6?&VSNw&A|e^=Dyf8l2V2lN|93-s$fCqXhH<+pk1vrl_`nYQ>U4FMf7D(HKnd(f?ZmS;lLe~I$fCM8 zda)=njs0~^N1OC&eU5TCXv@>cvvrv=Od2nKFWDlgIidOQpe3im@{fJN5dB7g!l7o_ zEcrwWm6MB??8ZsSdnR?FHZ6iMUH1{nvH{EN6{C8I;t%{%+DM1^&xq?BA9~Yvs7*6e zn;e&?noV_v#~!?rA*Z|o27(A;9@t3oF9oM*%TwVVi>@*EsQveoViRMJsvEhENy-Yx zUTlfoHr9Y*b^0(wwJ7GqhPEW8SSb(tW%0YC!&lnwug*QqMc!BXR23d; z96jjVqzGXuUag6$pE%rH@7)lXLD!i7V4IG9OSg&mSFrr`GvZY^Vf z?Ddu_(afwL_pl6Iu(G_?_cz942{>r#DZfENnBH!r>Ep_4pme^3A!e#J=X#$=1WZ}B z7SBbIC75o*cwW-F$ZGY7sYMA*fe+T@>Ik1<85P_ybJPZqNMn-L4u=j_8VH^y0tQZHLBOgmhE7ADMZF73;rZ4v<=zc3sW z#%X?ilKE}%#NEXJlf85xXYzxCl|S@ZRXj3~)oea`xw&0Uyb+J$VpoIcEtr_-B2)zt zIDW5)G4-=l@crm-oj2B>rZaz)yw7bgyGrkn@m0!3O#s_`bzYDe73^6RIh-ylsZ}68 zXg6g%f$s6?Xqd?Ben)cXMDa0+l)5aAs8QdGfjv~Cn4A9_5+r<#G;K4Dm+TQXaL>-@ zyWi3Dj4R*@yC|t7ZRS%iI%CEqm4; zt$nRda^sS)eKxzfoN5;1G)+ncJq{##hlb4;M1`Q9&Xpxft(fuP|J|tx`@O}wC&{oj zKXQN6c72hL2~NVC8vundg@=Sm-Ai?I5LP?6q@_)dF9u-GrwZ#n_MZYfvarS>UvyOuz=*DB`WwBhN|JKLhMuB4* z0w^AXod(j>Xg$>@T%^7|`mcn;h=?tBHs3Lo9%&c#xKY3&&+ZH$ISsP2A#`@es~OA< zM3y*M)RT!%D98^nBIRd2VTvAE)SgTlp}9eLem)TZ@UTC3aCNGU3W#}^;yNgcl523Jb9m369I_pM(`!j19-*|&DvdzIQL~zzx2^wAwmzf54nOtAQ z1ykJ)rV4sYDDN6`_()re*T^%6Gdl!V-fkFUyX(Qy7XwP}4%mt8=D_aGy`XzTQR3l| z$0T_&I%BH-)9$s`ws{}zJ<*gxpY~*Bzy-QPzSkK`)uB{TRw>&`MuOO*LWst%#*-Pl z_r=9!_QFaSgj^>lFYn0l?%O`07rzuoN(OYlN7onR41P+aD~Jx%I&ovHTbC_TB zMG%Fj5su}EEd)ZS8)G8RP&x1s8=fQhia-3`%&259mSCH}66am0e(jMzBqsE+NXyzl z95K+46iei_6BlnkmwvSyoxQC;;(U4zuwPM^-JsiF{W}6+`<-tDD=5YfTCkV>4k6fx zUi$~~!xnswi7x>XggrwuxGM)Mg1W`&s5M1i-tJ_j)5T_zBwx z#2myLmZxsJ1SviiH;s7`#`xd9=v9!0T zk|rI_4$2aHM1CVSoE8+u(zg$rX$PKV!?yv2O+-xHOh4!qpMessw%ce|_*X|s$+Hjd zRGAG-CR&yIlT^qP{~}}{Y(Wugnrv3It6}*X^8kJF`V{FBp z%{k@?jzO)4@LLE-&l&h16u-xp!EPwMjTXPbxycRIs*D~^og#=m7QjKW-|BO%?rwFH zY>4SOnfh1!1pffpNqPFkII8esvKNTV)gSU^C20v3{Z|^-C>|uNF?lf+$07chqKJusC|j=g zF+l$ic#HqgRP%k`TGFrN`Xxsk0L*C9WFS~+kcuVZl7d`?Rdpu6`NT2rxgqOqZsm{G zr!-i`W&0=w_7cO3ZavjMOC9AmDJWl-E`az3ZqVv=+K>d+G%2uQ%-zecIJ^PKF`O?7 zSVG8itdPuedeJkED|FXR8$ALfPqLuX>!kM<1rWVE4pG5qwMue!x0(-+c%4t|M_TuY z;P>lBW_g!J6qfbP6-}Smp_=_=F_PQ!UNNwAtH}1TW|RZ`nEJKc0PZjMgOEB0ipW$x zSW)n#98A%@+=^~(O}Xw|l}O8_MlktF5D*b;(^tfPe+H+&b?2>Vp2-LP6JL0N)+<2q z%1iWoGL$GfE`F)gE_x!e1qv!4dVNa;{b_kKc@uMVgMr&r|6n7(d6 zM}YN4#iHwrBVz^|AVHuMuC_BmryY6m;X_2nhj|{Y;IHn^gxkB2#-|RonarQE)(iK- zb6lk~a=)Biq^F9w`aNSit+SP<`oB2+sJj8s)u}TW8!{>Dog*On&1rfB?z6oy^tiKd zXYnS4P?<%3Q;B47sUP=ywvqo8uFF4{8ZrS%^G-aSu!5-+zk9Oi?I)NF&eI zHhd#M(xg%knGQzIKFMulj`!|FR198tjf!thUCN%~)kH{`E1LcuLLgrsup9b9wt8Lm z{_@(|x?+J>MXjzVaae`W_V!7 zv;D-}fd0yn)lT@~vN@ zWZ@OoJ8yVGk#g8KTmvF}6a!ppF78?=M|}iz45cO2s=L;>1taFLhxPmnm34`rRFTq+ z^{j(s`qHF`*Ya8wydv_}D;`)QbQ`{9!cI2Cpgvt+{4Nkya z`!!8{+T18$?zBU`QL=JK522Ru@B6{)8baQ%KX;XXr{i;qs)1u<_lLx~V(;B}xBn`* z#~q=p{0G$+pOvK>`)PpR2%#FcKXYQ4{{694RZ3%*%C&d|4%J5xM0{#jNnTAe1bb)u zj<_VHVV0W|Y)qBVP>H`A_#Q%4MG!2J8w_3x2&_*~@OYJ1x6??!R(t>43-sondPXyB>*{^nkQ>$+h-io3?oyVv?hw># za92)zLYT~xf`oU-WO-}$Yk66EikhP@=42onWVF}HWxD5$PPE{pqHgt>Dj$#Adexql2R%Jz0-G?E~(khShW(sVq1*?KPo$pfkn7@y|bBdmJ3PY6e@G+~!Y2UM+8n zhC1f?L};qo(<(I9PoiKQ)<6vIdXLwoKDLAq-;GJ$5s6Wxkq{t^ap})i?azPJtJ3&S5SLP67`I{(1)7e1ZPBPfUyh=d*E}KMy1X)6 zNc_TWZyy_Eg}-jOx|4F5Ki?~Q%P+OKCUHgYAz=oLvHi(HXbc1icN!h~Ywf)kEl9XU z0As1b!=NoQ*<*@wbh;#r9q1ulr!vJ^A__j<{yIeg8#|S|b} zDrLBL6Z2d8{R1rOlZyZ94q)e+1AyE-!smT&uhtj!d&fgdL-85|S0zm%%nC{{snB^F zZ<6Xz$L}3HfQtZT;CdvGdPZ3fUZj!wj<9zj0dsoowE~f9vG0Gmt0b5-oVf9L^;E_9 zKP~_(T75ZIFh(XgmU^;P)cT7Rh8cUix)5w!udhnXt0#sbwq3N}*-l4?b=Y4h2}zt& z{sT9dHp3F!D=ZFIoKrk6_45X?`N!d@yt5uY;85tkE>@2bSbRqpzo5DO$mDOp&XTIR zYyF!8OFpH71ed7Y3vF)2*hIP{$@GEM&2q6`;SL0NOBEODq711c3L1T|aT7&**6zIhh&0)P zRLyj|5XA8CQ>Wf$2mYs-ueu&_%aakUK7S{?FBb zwFJ%ih}Y_`j)&PW+;J~R(s>>*7q}~hicV{t1hB^Bh>Vj>LrBFCTTNyaga41Fb6}6N z`?~nd#B9(Qjh)7}-K4S6SdFcTlQy<(qp{K0HkvlJIeF*#U)TEy=GNI~pS6B#5410u z^>Prq1O-IOcqxQUMJxwfL9jVKNk~cvV}dm4P&c2D8e)aZ(W>u)mig=4vB|I zY_PC{Qj$|Y=0tmPKF$#2%l?W369I|&U4O}U(Vz|z*P*BD>fVNtpo;2w0v5R4hyctE zpsKBWJ4%n^Snb@8>ydTv;#mioy!TzGH(vxg#$<0sL7fbEgh{M>W1XEf0%y9CB?qkP zP-mTt+6Z+j&=MeyP%jA84d&X7B-lSKRD`9xk^eK8;HAeC4j46Fu)m!Z=$@-bSlt~q zI640I8>)P=pUeYtOdP?mzk*3o|i~2c1K!4elK>N-!&o8{dpMGYP zBCx4DGL$xG1ecWT1s?!rKzaq>AKR}F0&l%_pOmKrPbM0NS2?Vh;y)Odm0TgKZthzl zw$M|1f82S3vj0X{eUcA+KeYs3nZxTW(65Yp(J?m9S(w$!V!P`fqLP6G04c0=x!{j!CbpUGSDUv(GV9#Imk@-N9$KuwcicD=o0+AZ)&!M+_2)vA;I&U??<=81B0~=xCq#LGoD1tCTaK zfI~UQ(YgwIH{bO|i|n&5snE#o-*idW@B3m(EHdy8%3 z-JEN3Gw36UZ(9W-*0ihWunoyT3@9uJ?yaD<2{ZsqMPx~S0=K>4fnzpAN)ZVZxAu?!TY}pJF5fwu0h*LlO#78z{vs+QKMi zrC^1z^WxM0RSC5)qdt4}>OHcYQExKjRYOBzE>QY{`D^&$i^JUTJ~Obpc;lpv(Hfas z51KU5NH@j7;7^L`Yx;|k`hn{qV0u>fKSsGdE2a%KY3;Q2j`}-e@1lS0K|gtsT5d&% zjaDrAt~RS=t_yr)bhUr&x~6pe87jC!)|CY2WrZQsiB+Z|w+Ysjy*0F#!*8pgWo|^C zaW1(jx)d2}Y-d%8u`l6cm}oUwjzA~iA^V(^AFBKGV^-+g*&{ChhE-I?qIpYU3!u|? z6@P+#`1y>KDjccKO2+X%r9ad?n1-Z%ant^w@aed`)J_B@?=zvv5U(2M<@i9H3zR6F z9EdNm1n_^OgN4D`B}n*Y;kCL#tM<;e9R3YzCo73>jQ3jk70K&j{e!&6Zl6rG9ed_y zEEaE~1s#UV6+&k$R3L+cF4LeHqaWD|4cWjrY_A;{UNP8Tyu`fkr;+D82z7R{iq%!t zs$%9Yk##2+Du;%=)`0=yyKzhu^Nk`8oRS|Jm?r*^@51d&O14gG9rHOLX7vEeG5%AI ze|UHk|Ii_U>Hl`}_cKM+eR8|~U8gxJ*sNJMmy6-z*3ig0U-?r!JFT3C!Ms_DD+gkS zWsEI9?^gj#Bk2wIfVSlhauQl=w1`gh-(+E23Iqtm}8}2fL%0 zbOW7bPAOJqKwYXq(>E~=8T37$a2qJX_tLHw=Kdw&FdKwo@ZeBEr3(}I- z%w^g^%&-J|%CNO*k zd}j4|H9zMH=0kPY)5EcbREOv*kqMMIu_M+t#wCKVe?lyHPSxlgUJ1Z?Ib<$fN-ua{ z#Cn7}ic}WM>nH275k$$MiB%C$UAVobOwDB3oNAp=%Gm=X-k?R> zEWe=}aYQ31mfy23^LIgx@PAq3uW&oV<%JoT^6!^En>3g9j0-<)2{&UrmB1T{NSyGZ z(!wt9rdSo$?dtymK5VAcy#dG2;tWz>XqE8i`1aoN>&~_Znv}*!1U^WN>M`kchV;FP}F2Oa#c|60W@hP(81HTIkg0 z0Yp5a8%4f|W2_b&+~#o$^!?rq8_I2v`haohgY=xv2JeKx!9Y4Jo zj1!@cclTbqtm7;XYV}O;+oII+;vszc5g2JUS`)$?!#F_O7jooW^bk2 zxf6`{*DU{MtPHaQ4S;*Py!pU37_3SCXuhyk7=z9&_)!4O5mJpv{^vu{JqDwxCP3(^ zb8+ZgdPMmkQP`1^#JOMl>L-3_K1hu0^nBZU=|P4C8MH&Knx}rZS(n~4xSd|b>%~yK zV3B^Ym(x$Y*Rt!`D9~H8*&B#IL~yTxaE}bEBlT}W+l~pbIRfW#i6$iu*hn2zNLbAn z+l^}KubTh5*o$KZQ<{J6wq7?tuP@!2(29iW`x#7_yG$}d#qGX>11)gCe!jL0c*Grv zp5?9O%iHccGIBqh7!5%FBtF8 zE{7nK=GmIs)(8p>oiqA`x z$X4I)Yuf$qFje_~F;^<|?oBOW(kek|Dud=ZKPbno zE_agk>7iDKqu}jk%E?%_gc(0di{MX!=&E+IpBX$rV;K0iHHTmiIJIa9Fv7T+>{gxj z6(B`61*|wNLuhX}$OvvzafR$0xKwHVc(MFG_By!w$Evr;xhG7Eg_LHC}Xe@g^oP0?n{gzLIgkl(jOB z;c_vY%j2Y_!+yjW$p+AHAlXz~*;ebVxBP7Ov#)NZs3Ai(OK&@P@|%M7jR&Rwo67dF zbcSTR@-*f~f&v4UYpEZrJmH31(7m4LY(0BnG0L@2X;paKq?ICRWps%L=u)QOy@~A(~_yk$Gl;n z%-`!W*xn{!E(dn^gfC4-0g2dXRYG0QHyQ(kIb9>ol*be`MsH#wpb7#t)|qEQx-#Rc zYHxRGF@mU~m8-@Z?}Hw?FIW+iZ*ERFM{WYaF^L|wn%tQZjkICL$nDu@sjd+Z4+rAm z^qZGL(28aBo(T=c0b}&10^dc6wZ<@Bai0h15`=%6)HNQQ)g^gb)idO0L29KZKDUZ4 zYAb`Hpc!7bfn~dzjC6BB_G>jTV=O@l9#v+~18mLS`?Btq>7J|r1QE#DWck+|pp}l; zm1IpmuhkO8-2mNHzZu|hh+tOT#jwreeks7w!THeJyC`^hw=EU)4PxU_op|qHsY7xG z2r0PABzJO(Oa)cEZJ^Es7ym%Q(4BuWkB|GnaR^JBOo@3J_K}lZ`^nqS1-{=GoDzwn<82|9wF>j3wR zMa0~@Zw0VM!i!qo(enMXD{gVFa3k?Jd#52p?}st+SjHkE`!vMM+0n7wkQkodWJSN%iczkYYR!h!Yf)faE_ie zVRS)vnQ2!lbxST@n1>0?$e2Q!<5cIs8xw1mhV8+GAE2WC_p&ncyitF>_#8>p=N2-) zS))Banl7BF+`BkZfdYg-!mJ%)ba$6MYcn34j&~msWDNd$ADTk`_ylr`nJ+)50y>?S z!QfO>F+3&a{JDlY(hd|1H2`T&2ONrI;1wOw27cDgn{6OfUDDaNxB5L!=SkDBY1N^9 z_7jkcWnQyu0RU4-I5hA?p7_b(KMj;8nyrhow{iQeXRDgA2 z1vcr+N8@x-feM6z-+jC9-F`5J*zhIK!#@7zn}tUBUyvVqJY1ZsU>P@l1S3<1^64Xa zBETQZn`|fjrtm)f3SWL4K3CKJ+_QHR3!dIPtc;^@1bmwYxUz#)?WOBa)=yi+5d-|( z{#{IZO|GWOyVwrng#BHe8mHKfR*F2pH;BCa6>0jjU2LWR$2N8;Bp-{LrT$`6UYY%$ zBZtf00{_M4QMKcF)l7v(cs!dnf@6hC6RXBGZ0|IJ@7AYG`NePFVmts8-ot08=m5v> zh%19iTB6iUYAn>r<$4R0#L@4BinRQkPY%mnPuOIikscX-wr@kr%u8fZq+a^fJOyu- z0j$l#%Z8g&-ngNLl})q}8TCG@q@D=JJU7;DTF;taP2$7W6}qUW`Kjt6?n*wj@e{8A z5zwD?=ceMbfs}x{xGKvb_2&{;l>)xcB{OD5{}6t+I(abZal7`ABSe{Bo}m)!67yfb zfG(F_I$3bSh2P7iimJ~r&S!DiJU<4)_rLqmtSLs(1XRoPkqfw^@&aNu+l5f3cD5Dd zuuB)cAo?N4#p=j1d#PY2H8v@K#0Z)M>AhPdjR2-0Ka`f{fJ7>T={zKryC0zxOZ1Yh~H_7~F}R!0OJ)a(}i z-Ry=sLHg&|z_)Lu{u55(_k5Kq@u*%Z8V2E8!6D6LRQ#%&3JY~9F7f)6zy&E2j44oI zL0QtcSUM1a3}G+5i?eFj2*$B$)s^JSg?)Sk2oc4{vn$Wr|Ji5riL=*l|+<8ImW$Cnma+hNZXUled*Bb#D<&DYVT zxJZs*H&e*2841ZJpvusP6DJLkH<<9-z?S1qJ2OX@nS-t&hA_=bodOTiq!8b(v~$S& zz;{U(SX-16zd~+ZlX>5pR=wr%aOcoiL>?GkitSfq$#-n3f1`m&g+|O60rRWeN3jx1V&ADBxn1*ghQ8|J&o@m4@{XKn3OYg=LYbbO6pK-t`yUL!gOC-jIuww`To2lSTs) zIVtdx>qWt~JVZ~{IJsxdCBInzrnicY7+kUx<5O(bL;#}=$Q7X?>Rb3&=4O-`-e}4< z8m?I@oJY&&$=o{(GsA?}%OA!f|3^p7wzH=$#vH;MT5v@OI)~pId3B=aHHXRDMsvT* zGGKZlir1*O;daCmmBHC)zsEA}1=h{`hiDFcrs=oCKy!uLFgHcKU za8{?K{CyncW;bLD0iplBV{>$pj0t7IQ{6nIQ0A*7fP&E!JKd2n0=^w|?o3SZ>i>y- z=P;M=sfe9}tI554Fzfjl@5>Uk(CU*lh0WzMZ-g|C^X+!0SC-}Db^Z$6L5fc-RaLSdjS%$J0`e=w}c9emozwfe?i z>xMYSg@-@8$feZia8ShK-}=Y*Lhfw=@b8&q`|dpoj207Rr$-)vXtYe)Gkl&S`GPs* z4pcLEc60?t6l`(o)T4of(>;+R^V7GdB!o_GwJx-A5~5$yGAJ(Wna`T?R+ykhCDe5L%$_w3_uq(`J$* zr~BtLbu=$}>UcQ|$!_<{nV&K-#7D9SWW;Be7%pXH&vt1quCL@fH@?gI38DF#oGkNo zq*^83DV3sFjr=G zir4>REDT1p2q4U6l2-+}HPckCw?lj&gnm!(B+J!CqFy`nu*7YGcT~HS@Me zntXWl_>~h~)XLnK5VKOXH&7nDNvg1>7~=EFbxuU=YD51^&>hfLMz_9K()Ph2&}mN~ zfS|o9zmI_YUs#x1%|PrXKW0c9pgXN3M~Y`M@+-K+s#A%53M8Mmz+H(glPP!U9MELx ztvPPrYz19st6_h2vOKsw9A4KJv1z|ga(q%7;W>QH8z?LIfgkg|ExnNqQDH@P<4=-V zZ*g^fEls*bvh2}zPD|>MCPbT?|KBss?HHr^Z7iAty07HfzewuN$*@jxZJu`egM&4A z*dIec#FoKVvJ9<3?L7;zQ&$RQfG zpKX9tcNl_xA;Az!gs!QPZL1$c{x+<-h^}Cl++;z8FOQYdu8<@fkFUM1c+oI*W*RS7 zauR~bJ49Z>Aat7PF4(I~?en-er}GY2`j zOQ#^E_JF%%m@xcc(!?k=6sn-j75;u;#Nib^S=r0G>8VAp_~<~!$>mP#S0d!!A|&1M zRx(l4xp3k-{5J|7F$_RgJS8dBuYAyUjx<4(1o)D24pdUOi3a8lH$V7R1S)Zt1=5n?ohj92#gvEeM1u>@jdKUQ>;3D=kVR$EIMWv z=G=dT+`}SD*wbcJ!>Uq)wRdaXl^c__`4H9A(M4rrl___g1JN~oM4(xQ@_xo#-4pwX z-P6D3N1N@2-5)fvnAKKC9RGzznntPyMO<92|EgExCCvRWsuc`B!|TEK%>XKm80a~N z-^FmD-lGaSymjHj6N4d4>NDFCjO#FD8A|Ge*~1N7+`x(6aOiV$w=z;{@9qh_PKRyh z*L(ZnyU8)p%BF40A~_tf<&P>FjhdnI_kLPl2=x66mE-cQi%@0*MO>7xLtU$Ypr*l>4h&Gobya7i>3YnByU@faUaNFwi}X@?dhVP=Pma?$QZAixR*0) zMay!l{a&3=@kED-mUZ{|j=u@m#D;c#m7n#ZifxxjvlR(>wwq*F3(Dy+Yt;S>5F)JB@^s^BlFB=AJZcNmV*1a5a1XwNnu#^c&7f?gsVtBdz_(bz{Q6jq)aq9z`EHQX1~+ z<3|dG?-du{;r^cmFbjL8Bml>qZ4ceRcZ3N#_N~l|90%C=Ta?Ybm(q!h0)%3K6G^vG zijPTDEzh=HiSv3pQ94owfDm1N3yk_PGLbm~Jrz%Sow)_HZzpr`xwS1Sb7dk})|WwN zNOGZA18lwoo}tBdS-L#BeJ!|lMRD(+5v34$ z*epJ}Bij8*Vkcg-=>Q91tXn4j=M+%8v%n7-i7gYc`AAY)G>IUY8-C4GjmYk`hh8JJ zkMYCrZ#V@g$*4A@j}&q*ch^XWKau^=Fk*)Az?k@lpK|ZJ%LzBy%66p0Y99t}wbFUi zgdB8vs^hZ8AT8U*cGTjuFkIYN=l0+W)W^<;RoRj(DFR1;Gq*_(tYL78L4?^go8H5! z1$vVG%jK?IB8`qe4#sRQFTCu=@7^^3YCcvIT9nI1vUXvWJ+E#I3!Uo~Y$MrwngtXm z<8Ag;fOG_wP&DLbs4IJvonxQ7O7GygA3^U+5{47}g#-Mg;LM{a_;X^MDWGBs<}FID z0vQn2%DYqfOX`pP5b~*s7!-nvw>lj*eoi-#2XcvN(IAZfUPwl6F)-HZMFUO0xB95< z{kMm5oj4XN$RsD2U`@ASXoZ)NU>!wg07>bMSyo=NZGYk z?%VLh(m!Fn{0#1+J-rcO=z6dXyGOMoM zJN~sA)iyam_+8V0d8uIaNB6yT46iDQt)b#ZR+cZ9U=N;^h`mLS&0-W8UC#>*137eT z>|W&0AeF?wXyUZC1>7n!ZzDwgyMJ$tnw^l{rNrLHCOG{UbG9A8tu4iiBI1eDB9nO$ z8kG&{oPqn%V5FrzTGLRs7&}~>R}$LawQ5Aw>%aB|w#CVAHTlWeM!1CA{aFk$g!lw{ z;P%BljOQrpE;1CaCzTHsZJ&G&R*1EW$NKQ@};= zxyg=#9PhM7qP|E_Die(0S$jZ5~$!d`Y(3Xo*d&s-laTGXwG ztgHNGFtLN%YTOdqQ?GtrrohOyUA6^Syy~RfL0s3gZNc)*0dF1Y^%201X4)U3xU@r==v&$V@IAI`U(s*%eDTU4h)>ksbdI;kj(YtGX zV%GaYUHo}E9Q$qqK1^tR70Wx~Dr)-(s~LV`<}SEB zxj#!QT5$8x#tW-A<$suE|Ix&?KXmlvu$L4)Cx3MPydjTCW<=35lhWJhtrGZME;o!eIL%U;u^-UA{wIz_8;0Qhr zm#MrVBYQ1zBa3}l)as}mk!BI7rI$w_+nDpmk=5575z(cXAe>r1zdcje`($k^04uaM zfco0vH(qjhZWcsTeW;5&XIhFpV*`)ga3pc948Ls;_TK}sz>d@e&^Tc38U{ok7&1Z! zA`a28&%1%`6W^B-1Fsxsm3svjYXUrtU*VWo5Er8T`L9i*uKXfI#d|a>Pl2zFTWIVyW$mP8mG5f3iv=_ z&+St2W57Q+Pkh9lNnh(fSu9Q&C{al&5@mM%8K7aT{x?yp`B!8sJ^7(#v=@e)E-ZBR z;@sE^|BxbgtInp^L1HSyDyKI#3#+52@RONBCfGyUQ6g`RXf<8hXrO?+6!a(E&ibG| zCh!jAo4b9`MX9Chu4j~l3EsClRSZ7*0@!X|+wlu{e4XCyJX^``fq0By>aO|=hG@(6 zYE`E&8~7(U32R&)`x?S9*+=_vjY8?egJQ)6^ z$@Wv-w)LQUiMPLj0R5G{W#o|+9 zs1>WGYJ_?qa9kanv%J<5s}2mApA9i9sl|{97HXLL-+STVg7-FAG92OE6)2!*sXT7k ziIjxJrZSRn`2>H4of}VgT79FB#)nS;9^g!s7I*XCyw0D7Q|9fv{TzsL z*Ie5{W&w2dr(xIOOBH3KO7wpZ>3$grjU9Su^!W$|Z_BE%9=g2Ke`?lkevS<8NF2oh zRI;$?p#!@m85M-tttZlAlfmR&d+V?pwwCDxzDoVc+Rj%i^(8!r$X(a+1Y0YYe!%*T z?qnuyWf0FxrO9T;f7Q9f~m9m1cU2b9-Ua$JGhMQjE162$iaR`!Q~R7_oq~1 zhMo131&u+YRq@&xNCBpMng??D!Vw<}xP71aV@^Y&@l2C`fwJViK+<(YIk(0}sybzw z8TJl@MtA#^y-pGZOm!p+NYGFfVQ%_Lh@E2L!6R*!lW%qWPk0YpH**F$AAPeR}3?(5~iBT)jH8dIU$HtLxHS= z;5OQq{LSCesA#LF$v3E+PN~7Pq7fFB{{A=zgcExpH zhIhqIKDhDhbl&2gGvCPoeNC5>u+RDF+-!-sfi>h-lXu-;OF)w$S{}a9sQw8AMx1Jf z^H!K$%Dof;(H;3B8M`TuI%I+t^w(5{M4(C#7iq^n!<}Vw7sPM^9V||-R!vlm-bf1- z0Q%ne07}f+rLqa+&@p`02sdqMtU2M19qFTBzuf?P8ZgQYy*K)=7EGs~AG;de(Vm#2 zh9Bme$50{%H~D6!PgLRdC42t`>c$>d8?PbC?72;}&Q6V18tZh0whMSjH7FV&jfwGa zB-*>+^=czYaKqE2Ez@X+zW*k=c#j|tFj*60&BWMsOYv2zTdIVtypTYp9t8lcUNVa4 zPAu6VRzTA_t`O2Qmr5A+k7c0ttiSNHJg_GvU&%S^3(}-4sgU{WAzE)vE%0Naqe3Tv z*)|;jvmoKAB5(SomN+^=*T1DE^+EwhBO#xGv&wy9PGeV@>QFP_?f_con9x}q-f8s3 zfr-A920^&fFJLJA8*Q1A?n^oQIABa@x?UTwBLSXTQ7h2=-_cM$&#}1g?Q~cHo=8iv zvYH+3MYy5!45M{08MS?FqS2U;S^PsDXFHNdfSUG6Isv5S?T5>HqC9r)8nV+naGqhr@`9d>m2al+AeDa=q0Ri*|Pdus`{+*03 z8`BBw&4ylRkgJ?j!Bta&Q+wzrI3Or#8R1xZcs_Yn?&il5a0R`?#|2miK(+Y3ahWQV z3Owf}t8ioEC?9a(GZb43PhxecTUvJwL?Rg5O+KR)kH|!h%Sx|lHvljrKSJ-#sf7~f zH^#u1R8?VZm9nEQ+gnYe;Jn!iJBEYVjWxEZIA>u|qtSG2e?+^F;`)z~8`le4ggT+R z-_RtBYT!^1FuQr)(v6_WgGXDzp0Pzmg}WFDY$g_j_-KjcTXCIq{GLKH4wCEvZ~Z=l zN_GbYENFh8h+JO{WwxD<#OE)6sTB!WigAhf%sH9ayqOq|$sR$}-cUQ1MTr_l)r1J| z?1~IiNJskmkKAG#Fqz4q-BtG6ir4|h%^7NgxhYBVE3NEAuBzJ8;T9fq zT>JD3f-yRrO_$4@L0zer!g3WsylhHngj(x!vmyjLB$;V&(PxrEYkKX+qZOiNT|;Ou zf}Aa*x*QoR74dC$Jzq7PPufB!`#p)o3`2*Z7sd>V_=eFQbb8nvsfkd{vx~MnIs!5H zSIdw;Hl??WKycTR{18@N=mjz7?R8i@r3VV^uSP)=A#Qk#Xuv1S>MR0B(+U!?m*2kK zi5=|PcWvbiL%fmEj+=Yny$XNoSK+r9L{WZ!prP+x`fhuzpgYYfEt<-T#$U2 zm`5Zkb^2e-GDa=ZUJktnN&@+s22yfVIK1hhWa_U>fwWJ4h40o168m)Ov(kVh1B%&4 zXVxJ$Ryr90^9KFJ_xCFjJArbOQfW~i3Jl%0{c-W6&FQGzAe9s(N|Xa`BIyFBwMRCc zWZ07DI0v>N%tK9pj1Ha0S{34>0E&}OUi-C+_Ftck7Orfgk`UjunrFZxnBZq{Q%;*T zkqHm>!0aP3tF+$B+@$QE$v9Wlys5XIqbhk55NjoI((cg&OK;Wz6;Wf!l4iNxLsb~^ zJ8dM3$WGQ(W;b^pJa5ND(#tt4=xD=NFQAs{>+q#yl)N&>px=jdB@B&DZk1+Q{6U!({eH zlZ@@>y&q@h>8o*d#%LnriafBkLML-3&7#X67)q~KCpamypF-0m0_LLrmI+aF-qWhN z=&cJ&$Cf}M7P2du5im7>7CFha^WO(L67T?AOPFy~#o20>n(_1L)hdK1)rZ_pmf8&8 zcjna)b<_HAwM2_)nS@_rKXLW<)}Bp#=ZfSu({nm3gzhC^tm@7bQfDMW zWPV9rhmGz{%T>OM9e?j!F3yB(#ylYw=YzoSGe<6is?+hxPKaXh?I=P{vnU6CP;lwPf?ewN&SOK{4&PiBw+nEHpCEy=mw^H_Jd`CUPaYx91G@1 z3Pz)`FJk{RNO(Fr{jhldv)=vSOwM(Pe1Y3K@9Vod!Qrc%@Iy!XVT-l{5w*73MW`CP zj^#gnxZeg_T!(>?*wbqnNZTs5!w+zK?h} z`pH<_xA`Bv)wg87#uUT=m<(6BH4&A>6m<na~)(o>x7lgoY-H@mGp6Q*yRGAL}c6<#T~6V;U!pM6wn-49yrUIy&&5=omnzVcan zAgLSN2=89Y%Pr*8FjwWIR=kD3rvG=Cpxmm!>#c~P)%jMWK@A2#sETJEnH0WYOf3aGprs69!%XPKN z7n#cb`WS%l9#`{$wn7ChsD@pE=H}>fn=#CKTcEb#I?>gtvLgDr`q~73 z!HoWQDMVaa3;BguoXt@J|hB}kDVDIKEnT$6>-A>SrdH)&p~{M__|#1>VF2@YA@TQ7`CobpHV-Ff z#6Y)SzqQ$p9Ln}jEWgno6~Sh+F$GXqWOhZ6RmprurPAXok=?04F+-#7mZQQ8Z(N%F zL4s&e9I&NkO)qrdKNE$T+G36GDK;WQmh7bVo#a5ye8L0mi@vg%+D@{t*!HQvmTzGX zi^acI`#(Q=)|+oxLSMs;sJy=>+RkVFX10H4mQ|b1u#X2WW_!)$4IS-0Yf{Mp89I#o)9d?tO|`b7XWsx4%%R?4{8HwjAOPBM)`t!#Fi0A$j6i| zBk_%uf>d!WpG-%cAq#7>K4?AC1y`Ts!oI*crp?bsQX_dTkJpsWiLIT+T2K-8aCME=qXZC5WVYM>EgChXrH~=mT;?}_r^mfBg!Iq6Wi&S z-3rkd5>HSVk*mES^9A$*M+)!c>h%0?r>2AZb_@qRI(|T9#D_^A;1wL_UgCo#Mz9@_51i2s|xd)I~>}hi@)9h(Jv^HMLQK99BKXD zg2-_dZ!NHP)PA!s7Fe^5e-ev$h>|)tuhwEdmlgo-?XeYI{qowKAvvN@Lg__0Z4k8b zaPn#57pL|~PS%lsKzCZ&qO_|1;8RrO5~CR` zV4@`^59a&WugEWD#W%zh<%aUx9J3!S627r6!r5$g`0=U-B_|1vPwfU^$c*XN=&@T% zwe!GKaQ6+g`l3hFaLmup%s<0MWrCV=1Uz0W@uPE5hfKLYzrnC|^f+JE(ay_>D-FBH zI0C>z(m;ywb#$@xH28?N<~L&iH`vjL8_J=5z)4HH-}h0L9Thnb4ygNfIWIm}?DH~G zFaE>#rfwwdNz-)tR>FI@1^yYeeQ@C}CQ;Hgq*{Na@=tBp(IKdsYSc*?g}gYi)f%Hr8} zb##Sq=De1+TcS717brfNC2BSDkfNb6p}$+a>UPLfMm$OfM0>2(w3l6qJXj&03zn8B zr=LrN-3XBVZ3;Y|t+i+4fJQ_7$O zgTyI8gJlGJaj!jhK(=d&wvbFHWEl#;vYf^^$;L7ss*NX_&vSu*J?3Vui9YK#7{wDP@N-zjEc@{3ES+J_NIy+DsH|8F>2Lsm}nK=WDvl({0Vif z1x!Jwi0>SU?VlH_5~#6-E5airziEE9@DxifUuhK{t7U`#MfyfRH~7 z*m`N(-nmwE)dZ?*5OH`}Eo>AkEThS(+jZfE7?;>GXZ3^?s{=TC*84a%EKhXY{m6lj zv=IkUJ2lA`@9}iY1#$ttB9TSnrH%g@i@mcrW-u?khR%JzWE5QCBzQ52x=ZFKNqX+? z)kIqw3ORj~o^+b?!=>=HWR_sa3Nf8CFuf8C4e}VDiq3|Tdjw=&L>#O5!o;>a1?hqH z=f8FbJeqBhm0=~mz)AKP%hH?=BLHyWe19827c#Rth!QJ$8ffZGUI*|t=t3|QbU&3@ zOQ3CAtQW-UNek@hj9f0zh zGu@3roJ~V4aafoNJy9HNOeVpAZCg9vgB(T1eD^vzG2)IeNJwIpD42-=wiBhl|Jyl8 zTNM)=N190{y3AHJ3?iFJzg7qr#1=HUkTvWOSpBK@Em^XN9~Lr`keCaH4Pr@U-LJ$j zny-ktDgJxX_b829#H1r0GT!67GxIVzMzNhe^P$@R7@T9aEWrJip-JHZBgnXOQ{v4j zS#b4lLfxKu!iHvD2}#NLwdeiyIwLGBtm-`eWv`@yH%#@3deCg$769n^^RcM$Vf5pz z13O#rOM!heF~jS0?*@4KO=J~Wn+#!R8q3!f%4+HRt2Ohlcp1n^{88=%i`-uq zhDu~0fSSDx%3bz*t!VUtm3cT6Lj^~m*I91`iG|=HTnGXDLe)Y>l`v7sbey! zuI{@N%fGKLa)FWGIn;@4glA)d7}6uG`W%IyZE`O%33^`K4~wvDMfR^5#g)`fdnbhIQ{fMDl&*2DW7n0lMEPg-3?*v9R0t91 zE#F;FmRXtS&9$TLmg?+P2Jb;wT1NS|mYf-o5RB^~D^8%#IOl@nx;G4h#_a~{33d~> zZKpZKw$kdTE?ijiQYqs(pC$G+$ky;e-ZdYGk3KhgZt0dZc*bQuFePKiqFok!!kqPuH;$b?AJG2BSGUYQA&mO@wA6&jZxn>%{_#$6c!eXIj zC6&i$PHOE2fE_Mh1bZe!E$MJ8F${h!eG8eSrAp9Jyvt71T))x3WxQQWa0D!n7ksy2 zkfn!y&ayk5`h=+=g;xp@PJZMG`168!6=PygnD)d8<&1lKJ5A1uOO0?=;1#kT> zja5w$o!7uD`iI`ezQ^vxYdjPYHzo3>2Dm<_LT>G1H8tA_Mrt8s<4={IxCFV(;)O?d zdmA70ikE|A_#a5vwc&eP2N-gsMoJB^UFVaaE?nF3J-GY3HbFB4rPsf0PToTZlk|Go z(@t*`js>>(|5<>f8q%W)MiKFFr zX|zZddxk0PnaoIb{oRKweEN--H=YMXrNJ$6?T2w zCZV|lnM^LUR3(A~f7Umy{Rz|)+isLrbyOyk9 zWomx0Yxe}gGGA(S99Y&3)vGBC0kwZoa7$ySp%s8cPsTpolnms4fae$ntvEG^Guu-} zhIrHKY%h$|4|ga#(q!;kDg2g5q)Goto*SbhUS01HS7f9cE1n+XdY>&35-S@~@$8h` z)2urN1zMtHY2cp-I@e3&G{^U-{288N$%s6_ivK8l0*S%P5s%HAT!v#(cpWt)j)1Ba zf_}v_Y*UlwWPtpj?7CXGqjC$f0-8RIS)HZiZL?Vp;jLHI>yO8*kX@@zf+&6f4nc(B zm>M>0XVU38iGcl|l?N1)h>;(UvDym%fT({ri@JcX!kC*|@A_;Weje32olhh^kEfL?L6UV@YWG3>n>=5?&-RAs_M zwaOJ0PD2=J3Gy1YET6%vJa~J^$L-aW@OO_DrvKbo)mb7`k2;d-kAB!djyGZD75Nf@ z8&w15XeuM=ka1+{;G4%e6tZ?U6}7e{)KxdX&AQ2z_M|`?j>BTP!a~JMjl_zs04101 z!qVg@n7Yf^M}Kp3UyO)Tv{6M4CXL?uiANGK*MHre)sv~Fe5#*m ztPBZWV)7%KjD?x-_^BIHF5QEU27mgVd!Q6Ig7Th17%He5k53#u^Zz=2G#Wy-s>y^| zEHvbjIG5oUcsN^Q*qeBaC%B!<&(E90Mr!9xZ8&p%TrvML$_p6V*|icx4coTa(v zM))BX#ZU4-`!=V@i7{=O=@*mxfpXplQQu5_vyf7Cjq;@~zF2WyK!t{9-VpYPj%PRq z9X$dWR`m7+{r}N)&Czi;Uw=1dHL$ck$vSxAV_AcGBZ||O5B%O#zgUH z!O>d`CjpZ{#{v23r|Xx}MSqB5N{ruXcaYLGWU4b2R2&`f}(gy z`*FcAG6BZ)tub32`94`cJ}XTk66$BCQ2QJ@%i(bX1$y#IhKk1#z0kH4Ecwma^5fIX z%)F%EEUb|T($pQi;0FVBw%k+W-;VdL58qZ$9RUw$DTi;^lG z~XOSca$8$J8w~gE8+`2HF+B)+Gz)#aFjS9wUfxB5r(Z9oz6#9ke{WMVcmFlV)97uh81x1))`) zR=?J$D4bECmr+ehp3~?D@2je2u0o5{*seNMu%p8+-Fl#3@o_o-2s9|N83~MBeTd7- z)g|079O9y~TKR1|Tn?XCNEGz1;$N#+haGIw!hhlv3Bd`|@>p)Hvr7MNUmPT_vlAI? z(F{rQ%8y%lY#h@ux`PaBvb|=^g;{cTTLKLg41XD z*sg2CwH0S5xo3pnZb#W?Bx)=SP@6$SXyV`=oJ(`XTRD{ZHy4%6BGhz*xLLAY`9RHw zm{p`EwB;LSdU!E>dd&0I+;rB_1(W4!$}8mQMgq(WkK2$5=YN6O%5e@sxjuSECpvI$y zEz^^n2x27aHQr^RFTJ021>7xFMPn!A478rTdH#-=o1lYTyRHq@Y9>{tfMpjPJcFOE zy9=LMU28gK7Yum6tUcbrsmn!*`D~gOSujNv&8xeWVG=`YyXe{?btyD#a%|N%;gLM! z(iWaWC4M{p3Ct**)Rp+_gyqBT^Kny5FY?)0$id%F)JK5+5;hHbGJW@2kyT#$Cp-0u zuA0oI__7+za7H)+7=O^rS16&;XeA5K`Ap6ciWE?!A=>usmXIDR@t=Ps-l^kIqNl9s zwx^Iw3+SfE;udLfPAip`dNd&U95jJi<@3hIk#MPFUC|PnCwN-1{-`_LuvIBP2?=`# zN3-AZKxX9}tgXw-sOrrZKaszc*$^iTj=Yv}B6Xa$Qpo%)`XbCJgTuRQ{zHJO!gviv z+0le5C>vIGmV5@}fH0kGJt@ep}Mdz~=*U9kxK?S57A@ zOE!lPfrbt4DRBY9%|>t`$D2}bY1O0VGLMz{P|kXt{qpqt=wnS%^stzftkCdxC**_Q zb~(1tS*3ivsF+osyK{2McOW+64J)QS-EUe&hk(X@2m&A!q|~tX5}k`L+!X_QTDaLEEq@8<%?&S-oOv0Oc2dk{0#<>}E;9 zNQoF`&D~Y44)*;^@=y&}`(D=VLk%PLZ4z}lunq+RDks^XnXLtm;8WUkFKN5@E&e)s zet0p{V^_6Fc$_?JHsw#f`N`K#h`(*e+IULlwzdY4Y-SvX1K-m**JdxwF=MEaj};q( z#U5Yn7@Ur$v^<`LA?g>hHfw<(!ZV7*c^CAnXlyP{h-H(Vv=eu5_qDD4iHA*hu`T(U zuB~5@v!e0Fl(4}jB?~$a1uxcP4fSAbf(I0Mm*#Dg(0>nl(f6I1U`}3U>U%G=9e301QkY(dCYf6wGMVq*owNJ+=bbjD=A%Rs)6lVu?1z>FSn7k)2i%hAuJM}TdR%MP`r|1sb zC<3iN>;zjb+hh9>tg}Y&%7EV*8%~q{b#?L%Z;d)AqMj(*!}cw)dg619eGLVZdvNR>*x#*Uf8K7@W<4sy7$OW=%yVOHzWv&mza- z9I#wejytMLw&-(KgZ-R1i@Lui2O0Zhod890Sr*Ntwq@;g(3H8i@}~wjgqpJ_0+hJA<; zu}rOwS!xST$A$MJnNOdKFMz;5figEpa3FrQVEYgnA!U~FtGMeop3ME@N>fl|qgw#Z z_o^rq_5kJ)>#UOsA0cA=D4Tz)fPetUQfYsC1NFPpzeDS#8`#L`Zy8z|w=#TCPxLmy zz%VUV08m~thOgTGI*a|B@^sM#O0k7nwifvlg*yUDu~#O_hZh_6`lSESiD_oNP#DXx ze}3yJrGBxq>SjvMvwRkVY(r@Khu<^Ek1Io*v1&yNDq!S)C~QgPjt#B|Bp|qs9gIz9 z9W4gE#(2Ni{qprC_;DDw`U`CXqwAIyWf{@ro5;~>^nt*F!68H;I2y%V^&fZskUQKU z)O6*ZKEUX*%&`WTYh?uV${ekiTYOB__)P$#YouyXPH~J*8M>SoQ(nXWihHa$<)MTG zokwWid-tW8i^EVh1gC${fHp|gvRps#R$&&XV(A+8hnL-2A`p(_-BTvqu$u7Zz69qV z8&5Tg-G3HZJm$#zovC8%(?*H;>~Q?L2NtusH-%579>)Y_UkiufY31`9gU%XWsr%_< z!mDVJA;h62Ix)Z*p@ujhEplX;GWeg#`qAdTy6QaaHx;vKA#|1z+T3g#EU&iWnJhmI zE8QZ=WUr6gx=+?`3K>yW7^-zG%*lw9-gnZw zQ_l8~bli%r(6f@Nwx&kX@fwxF3={w-VU!RjpC+Mm7K{_;|69?O{%*u9c8v`omB#n_ zi^yNFEf`yc5QTEIwpJT?yIGA1sPVuuiu+Z7^}7T=0LEu|xB&W>i{stH96v7y2B>ZNLSB3kp=C&xjo#hMsE z(J7s$e`>czPPnw}f+4>EM36SO!Yo;-`xv&ZKsF^ZHqI7>7fYVyYVyb$MVZrg*K?>? zzgD=9Q@%O+M=^T*8e+mHAb`-9;DZHAIw_ZOUHP-nPr)fQf#>_T6NA0AVs~BjbYho2 zQJM#12{?^R#?w85j0G9Ya!PNgQhgNEn-r)mPeaKhaAI=CK&FM&NwNhHmaZ|p2w-zOKhxTUTiN8l@lJzCGa6s&Yk6Tr;fn{w zstzOkPC>FS)Pz8~Lg?OcwAR8-%h9KD&l`6-37<1MHiF1&0+aBHvtJSBQ!fCq4dXA* zE4jimsK>;cG{b^GfjQd^?}NuA^g-_DXuo~k58(u5=`|8VUl_Cm>BL_zfujdK4}tKk znoxA@TTP##gHk(S2;{3!uRdj_Rzm+e=}+kwg2OGu{`LRPfSS~l>eCh4m1*_DKT`)$ zvgAjYR|$CbSvgZja^!a}XViE|$ur@UnPNh9zg*JB_2)lW_r9Jwp~C$@Ote}3o&{C= zj7R7M|E-Q0hC>bkQRI1jG2k^HXOU+tMVP(O>YEt~7735s8`tpFUzQjG`8pUC{1OY| zAQd`|u=h_r@nQ>}L1rO0=YY})O=@|TubIlBy@FqMXU0HCh#6#YB8A*ueTdS}vibIy zvw}21&+YktV90zD(rO-GZ{W!@Cv-VnCm`M@kt7BXR!Ox}q#61QDfP(8Kk>X4U10`O zyv*YF%8F8xD?fW5E5|y4;`ej?5dpv`TfDWwZ{X+rf<;$?=ok9#^3EMYu)Xx^wj`GW z5A@uNAcE49VX{hs1m$gZ6XGz~-{QCZ?Als$9J}HgT7G^w6nd#QUo2z)tY$BI{k z8q&u=-%MS+Og@N`X&tYRC+4V;e*QxHW<*<>zyITBZlB`m&BzeL3Z&`9X>X+JK+xM_ z?nERlQtu@)=HtR*tJAr%RSCG;MEr2;&(BfVf$69pL82XESR3VKeoeu{|4kP8t{ndJ zGC5GiBqv<^M@{zmu<^p#c11DoSPAkJodX&NX%jA|M{?h z?LUJuIypu;ue(t!ytiMVe=4v{E17_)i|o!Q#@;0-xl7<0Dq!r>uTX z=ekHJu9^!Wd4kGcK;m~t>OM^t?R@XqG-?^S`V}DARxZ;tUg-l@Fi~*}?1pZN%26^$9T=`e7O2q-hHyj~fhtrKv1HxokKu*4sKwDy<4sfpPDUEDpfaABdu-QWxg1mrJ{;<-!K-E%|KKRQil+Y1j) zkQ85k0IgR5y52bhVi){;(}+{?Zy~2~#3k`|9^xxdKQ0#FQrEeQPR2V;foFU+WoLS+ zE^b6f8WKkso#$6Rxyt_UspANc+FcEI0-kLkULH%htj<1d=k{YT{>F-$OWk^6g;iCe z8_q;UiwdbLoGsD&%8XG&$)mKqoG$FQAGTOUm6~xAkcV%XZ{9O@r6Pd{5~EUg59T`D z#-=(O{BdcSJ_%hhxVeHCRerhuqZ0>f)$uuMx;k(3;Tx#Ldmb0Pl!lT0GqE7(Qw@}O zQ>O6nLH-5920WP_Tj7}dRDZ-mX#BZZ!7mbh8FY6*>aujP+r9~XPfPC6$B$%V@ahb9 z1<)v?I*Wgbl8HPeCScP#xj{9dppZDoax4Y)9KU|ko(1MJ;K?Szt*&SC7MD?JBY(aE z?eoZ=uP_gKf2M>;{`*Q`tXHnH9#wE%Cvn4sPVAHeX$*1)g8+$qp(*{o36P=$L>?g} zL`Wtp4e^&c{m}WigUa>nN~ST)JB3d!*nu>B&>cway@mI5vd!Xi&bRdbq;;Iak_-79 zN%1cv8f$6Qe-yJG1u+My0^R3HzZSO1sv%ic_o0<*(B1~`Fko%5x{oRO!=agb|xRa)BWuE3lB(u;fKKQpsvU7_I##GtS z%;zjmqkUSf(W*&)R01KwkYlZD%fU61szaKJr{#F%5y5{=OJX7jdi`pX38g~fsNz>6 z)x?UlkqC6Oh#0vm;dU#uPBNHTp_(~H?4Y;{a6t+Mcni{cJBRE=-O=6K=>8(u-yO?p zJT`aHy>BT%d<4dy!sI*MWw3*;+S%AVjO)%>wZg#3*P6(#6V$lhpU*n&2dkwbHfRsv zdZu;@(J1D=s^9$cq;^EG!7l_W`!z?DWBu+Kyk(s8bVq|p{hzO&$c=OfpYImMj{<>& z(ZOQg$fA#S?Plqi>}zNuv*edCscvxEYU-Qk9Cq$wTh;86HD(D0Pg}_^MIP| z?m|M!;iVlADGL;tLDI*#g0uNgMRd}^bQ8$PH5M%k*#?NoxDop^;F}Mqc+B4eX)&PI z9vNaraw%C54TUo#b;C;rr=^{3z9jsD*+tvW81wyr*oR*htG%qGwA@J|3fbc#>;qAI zx8ks`G@jY68*Uk@P$^fhVIT@Fd8lk?lUm~aRb3i+f=&F36Yu$x8n6b8iRGJq#+EU_ zLXJv{8;5dh&?mR#@5O6^@-xIt3nc(B={8Kf>Go@e-MGIBxywbvJ<=WMRlfDERm^)Y zLl-LPUh&OEY$Q^qfG7*l5awI@HWqpNl`%`}{wv@S{Is@{@?aNX%@Z89{EUvdjcT?S z0im5?>6;vE9bQ@Kn>KYVDdAcGI|q4F@3a+OEB_0I_=RThP~`;FU#ojKNar(3xtO`B z`%Y7wOTiRoLV^G6K+5+3JU!O$XH!@VE1W4$J=EVvchjrlS}ww79H5> z&X0+YY_t9SK=V32EdIp6hTqnb#ulR6MJjOCbDzKD)YYzZO+BtFXqW4>cTaFGe$b$H zCWV~G&UOH`Gcw8Xn&+2vRpk6{#V%HY#>b587Tw7vvdg0xQ|{E40J_~U{hf07CJBQE zuPZ&oU51KZr5e<|CG~ImY6UzXzZo-2PK%)~Ko#nf(8)*Ro-9PkqnOMLT$-93G)Xpq zZKvOI6}7hFuS#IfAF2jXBwN^{cKbg>zT2KI^dF7U>Mp)n{VZL@VAXuyL|u-`-IgC5 z7jrlk)3_WFfc_NJ2Q|GrhtS#vU|sYSE3HFfD!M$~bSF-$8M|_(pLSbeBA?39Q&Xu2 z+t_M$#Ve*vm=9XQu7pP~l@F&JVDcvUcxh5Q)sve8y3fPA0ct+7;TN?3=wlheoZY=) z%8J;%dTcl`Hd(b}md!%I&+pVmP^Hn_TL3_KqenFSZOr(Sd^_!MrFX*9T^YBL0+|mb zL@r~RTKsL9*(oq9rVDSa=*1o0PIfb4;>Aq20fM(pkn#Uqeu&7VPXq@(RKp0BpS*x4 z{W%d4b-o$fRjueP>NPdH+f-EL>*^*Tnn(v*&;;d^kx^_Rh>NY^3@bv>AyHvLg{1cv zeMG&tyf121HC1WoH!a|tR-==pdrpCU5tqV)$8QcMd^sQeb8}zzD|=k64my3Ogn#~- zLOx-DX{@a2AT}pP2`vFwaAl0N6!;t0Mbz;xuGhSkn~H*`<$Y7DV~H+ZZnKZNnd5JM zBGd#~D{1O(uOu-xv_T~U07n^JpPuGs9Ouhn^6cdCZ>B7^bTey4SjQ>zex(21h&1cK z?Jh!@6{F)q&*IfZXb;10W}#Yzme78OtIaF#9LBTCYEovyWe2xNT*!XYbtKh&yfF7c_Yk3%M=YHV;D5Wa+ z;DAzY>Kz`SZw!7d?{U*^OpM8tBh3tzHg-8#5cYvnSZ@WIXd2G$qEug7dKWNIvM>PI zA^{x&9wV8+`nRuhOAPa&&HttLfp`PJpheY^#$1^#ny~vJB7cA2+2duj5;|ngbUeGU z@M+FVar5!Gi@;xeC$KQo9a_+53p;j`_x3ApwOL{%YrtI*8qV!*;aEkvOmer?{LhJ2 z2DyaXW_i-y(`XWak@9wK)trs;R|eqG29QtdSK8z_7Bd>w90Ptb_q;YXm{YSoW~b+f z#Th5~LOD71zY$BsY2(+nwbWP=9=-}VD17I{jz(#eXl3H=vP8v@pB^&z>5H*U{d|*WZ+h_F-$7f}-VfatZnWw5A zU*Bh$$~;mV{N4+2hpqpcBJ1#UIKz>L3z!<`p2rz*@EBTQdVLG%;d@DP^+Q>M@3l-Z zh}v(oZNF&Bmps0hEG)pQb=<x-Mp^GkTM8daTHP*+UKQvx3BLpyQr0bo*cd zNx~;W#p&k+bR8XCt-D`{^+e=mw+9o|+9KA0vCCeso);JunjL^(?rq5s1q}|bU`-$> zMcogFT35Kt1#N)=xReNRgS4!jJY(vrH^19T8P_ftWx^8 z-bCGk#-6uRGeo}XJvngNYj*+Z@*S0D2bYz@i+Lt#Le=^aA`8$2A+n%DIo99bIaHHs}min##Hd>hk$r{7NQU zFiQIp_7$FJ5h1NSy#5F%+$|eK6yTPnFD-=iiDIs61Chs*2ossaE#J2FGyO!2trYLr zG4YA_1OyBUBIwjW5hBr~U`yl6p>)MK+$YDwuCt(V;n2Z_-!{tHjI(r8@YmLJA;r4Cf=>0F9K2te z6h+`l?HpgpPhb2Foxp*0I?Cq$|=Cn3lsR7hv z?LtR0qeVQEg@vGNV#YPf-XkS%{p6A(e#X0H!=Lg157|-{Z2r|FG*pw)eR1P;A}qwn-aG|HR>MIp-|2qBa358FmrKct;_yszzp!`q=gJO$M$&-;csTC z&eHNuujJ#PWvQ*3lueSn%a=X+iVce@mrQE3!3bH$hiuo_vW`UGvl_cdgW+g!(&4+(~?+3bbieO2O+ayOYSg5y2|B>1%uem&%~Kumu_3d;vF> z>?iLzd!m@sq_|(cbwLjy%k_`caetx;&^^<;R&A5tL8q#SVrWv(G20PRcR|Z!kiQ1e z5cfG9X!eR(?;b-{bE9M}qy`MVXV;b{uG-P~2(1!X`I5rHXHro1rnPna^n+`Y)h|qn zDwnm`VCnUDaAa z!p5^Nxa934Dj$?#dc^e0nK|o+rETe&^kZ}Xqq-kd0yS{M31#t8cO3Cq zs=6s{1Etr4veW=!PsjvdOaL-aC%|f>5+?6dKu!Hr7o~t8eKOoroG<(XD09J-dzhe1 zY;ha#waPAXe4=)zhxK`EeITgCdn`)ZN`5@0D)5a>yODFew=I1~-6;T18)}n<^@&t8 z-lnP*8kJCqY&6|`o4Ke5I_-Bg;Dfgh@&W2rQbYR=k-~LhSeifnj{hwmjpCtsm9fJH z@1LZrK;CqeZ^rU&lkdPbmmI{}Ys06m@^mWk(Q=Pt|Z6%pgb-b$vU9x-_#tUJ{u**z)GsQi@k1bq!ojLjBHim*UbjIDy`*dYC zr+82Ch&%SI5)wU)E9x%_4JcKHgdgeCM{*5&_w+x%{haih@>0HD^`8YH2FnBNlK^6= z+#9OYtTgQp?NPRO6~N}gQW{mkuX}Tb1lTZ95_!m382ehT^fyrRSqk5Z5$-ha^2P?q*g9%~kGk23(~`kS~?d^H7A0n>}Zgq8do zGl*mu-?;nFU~Oth?MDVe0r@iwnO`*wzH8HzlAAbROkDcq+2N%AxB1%Kz$32J$0dEM z&UY+L%f0y?qwjrNsp*zq-KCEym15G_2O%vy6d{9k%h9e^zV=TkVD9=`h6aYGdOE#S z(-#ST!{KBi92-;9@@0(tF|wdbecF-z1bG;>xxUA2GoZs(eZs_ePp7J`sH9UYWj??i z2+RvL@z8Y$B)C8SFE5*a()D@FrjeX}{*MYDii+vyuZRBurLz1`&=Er8aL5W~7M|r` zH|55qRH3Gpirj^~CTdOPsMoZNfoI>-mrtwcs$DVn%9=H)0lu^VLv3ITvzq|>{MKXA z=6az-?3Gp$7j6C`9;h(*V&v}hOP8s6K$>#(Bt!KOiiaXCADz;NvZrI)h?>NHoj?~wK>P5x;s3Zv_aV3j zr=n-m2TxVnU0!)b!r)qcFI9Kloq?dS#ikreYSudYp@bQlaHlE_NaN5(6b&eo0+^uix1;C< ztr!M>)*LjuT6z}}yeME6E}F5-3!AS|lnweY(j567X@yv*!aC;OrQu(JTD&UY13j{1^@ryk{%yUF^`xQ5S zokm?)cr4A`%%3$^*{(_@ngZE*kawj^ihdfeEfN7uk5tSDZo0%(ds_xAyOBhLz|cLOAP85PKXN%cK+LtbWkM7EYoIEWUxfuBpQdHORP} zbMNC%VadU?eNjx`x;(v970=mYD!2slcM(hLW|dY?%mN@2>C1(rrM?1p zCApA5tm~#^%K=lyJAOGxp63&x_07Yv+P z^I%h_j5h4~)RkwvylQ%mOz}kvg~J|^AH!bBy@}-vkr2J*#awY0Li9P9Xq0eJ@r3!N zXUX1#9`d%ye_*5KXwd@5R-kt1H|q0;cI!9^tu>*h6_TLs^aI5*vXG~XQdprzdah!R ze2v<#AJ9%JEvA`3_cHagN-#n=$*nx;Nc$Q?LU@sHk-^AvUGJWf6HAESRZ;ku=9g$0 z0&Pn7sd#Oo(o{1_qfz#JalcpJr{E0EcUH38J@E!l+Mq2^f>I@eW6?vf#s2JFMpTD5lae?vrA zbi0X7i6EKc=~&hywdGfpScTcL=vYos@=d!I0M2h9y>=Br%4Ek;&cP26z;vG5j)ma5 zR8!>~@pUxfCm#D35t|()kb!lK=o*TYU1!ek*fw$tRF*+0_j^Cs;0q<=nUQg>5nHct z>mn-ej+)A+qW9yAZkZp?fWS>#!t?4lyhrO<|HY;FADfMtRrg=3c;ya8bU7Y)L69L% zNCiO+`CfA4ik~s64IZC71uPF80AYHCvvRf@P;;mok$ozaGK;(|*;5Ir9Jly&H&l|c z{f})1)g419U2pdmdlnQm{{rD;ROJ*ybq8@g&B1*dUx(Gb3-mTTd-zzinpW6k4zDfu znG;B2-4yd_`Y{rYL>Mw0%2#mNwox?ZLLZr@XyjVQERw z^KUiQWayK)@UxmIcm?jv{$_H3lMD)6j=yQzSFQ>%t||5Ubmj188Ynurw%O$5C74Gh zR^m44O-^Us)lJJBG5&NX?XRpi!fIx1hW7QpiK|rU?P#Luyahh5o)G9M=w1!#SWo1U zn|yyuBfWW!T%GupD#rmS&Eu>WxSL}wcDS|RGsSu+Q8^iZK8k$yIzAZ=&dBtKX~zf> zh+C5lO1lc5^!Zw^J~kd5xZb|-liZum${Q=)-*mM16V$3LZl0t6p2bmXGX6fOIus@G zzMY+>byR(ADb-j(A``xKpxIb#w!YhX;zf`>#zn2err|ACIu>#(2dmYVHHD|0;_*>@ zCY#7-Cn%^bM0^!cKP8ar6T%9?03DBi;g9a&tXn*!|EMBp{fhV<_r4x(+M&CL8MLZUh@1$g!Z;RR{ z;Z~=5R^Q%gO>IcgH5Rekg7G=$`CW{~|H_Wn4{EErc;+V2)9BRIons4+y6U({M{4pL z2|?E&7VG+V+305jg03iR%+%&(D>|yieVFvkA1AfPpV6}mQx8;BruQ+mB#}wKN zv5d^`owVEQjJznLmzU)<(_HtGvz{7h*Sx4)bsQ5;iHtmsZ$6E4^DxB?8rPd2y9@3p zy$Fz$YWAZ*pVYQXQ9>l?{5<3JqtIQQjWVyzT73ViFYq$TYI#LA&b5uH%d}~k`lz_h z5`s%Ou|t3%R4jnFj+E+qw=<=}=CRGjjmi_04c7+{Zs_IrxQ{^oGD~r(tEbvl~Tk7}Y=D^NXdpuE<$BxtrI>T~jm zN16tmEfv2!QfMI$U1EHOAlGWx*xZsmvs&aopAr~fAPSqlhs>z?nZ?fM+!8(qcNRto zhmxbMOne~*N_M4Sb(D`1^qrLQdWqdg|+dk4{(f?2#0KRqPHqpy3+?*0(aF4AZzJu^79@8y5!k$DFl$QNKKGCvikrv;+XtB;#y@%Y+m&`GM(V+&P$5Z zp@aF=5bpdmQq>3Jm;y$LBCh3h|0VkCMO9XV%jNe@221{8y_Uy#XV?foZwHi;*!v~e zNbytWmz5MX6uuv5qq2Hc@X}Q(0lrLg@5~*QJMVV;Wo4Y3uIxgtfrkdn?=hKSKcp0Y zc#j*Z+}Vw!%{5@Qch~Lv(M&9T^o$!ArnANb>xR4Z@|HhAS9GC<^ev)Wiv&UXH?5Am z;B`*mof{acnvL|g=IFpKItV1|to2u}*bjAt;k|Lljjt{WC0?{6@%S(h;nbXk9OC=`=@w#&Lpd9@MDlLUU%zdOV5^16x?I^1xOOt0F+f9-#ATAJRFUEhI@m}4og(c zPd44PR)3kke2AfQRtPO{!5~&5l1i{N0teRvt?|Q(k}vER^Rk8il}Ke;?Jl8Ea{ta%FPWf&v)|lkv$#q7om;rl@!}p| zr`Ct(u*y2$&Y>^j$)+AtlW%QQ{*JXmM}nQdj}WT`JBUR&9$>34%{ggCZ2v7ji2 z0L2+{t``r;1x7AELAnLq6tFqnTyShkRk4- z`5*`STJaCl95LcNUwG43x45B#OxlN@gcAY^TFJjLYgM5=hYLo$yyD0F$?EjW{sM!}`|2Sa{-z7!Vj43$%u0BV?YRa@YjG_#LNO z)#*C!s*7u%{kHD0-K(lh=-vp#HUTDMyGXlLxd>D49$T}B+SvF*pNlV{{`;vXD|ZD2 z7#fT;CpZ!a_ewrkI6TSb#|7RPfl%as5Z6^uRhmp@e~&Y^!-Xdi&Io>4O2;a(sJ%Ah zSkpJUvEEVR$el|uR`K}WC4w!ID*nj%tvdUxK8hKJ3*WQ2E2mEsUNRn1)oTsBpPLyw zd7zuejvop>W_``DtO&dXEwW!a-Lgf^sn65LAptwO6;#^MqMp%rJfTk7Ejt zCiUlcBfE@F>q18NdeG(VYIjFMK%US;rjM6w45eZ%EvNIb@NDVB_t=C5T*;0YBr5Er z(>)QuIVvIY_##s8^dL-V3Og?C(=Q)4^uaSi>dic~q|`7yrP(i;(mb1v4Q6$B`G37A zb$?atGjWGGFxlDj8~;j^%u|@NP>!~JZozbMY$~}+W#{OZT9`jZc@IRrdNeotf);=M z@D@x{YGR;kx>y+@M#qt3f}PbjYgpa7ZpB0_b)G0pqAa`C`D&^|VhgFQc%XadL8D)T zE8z|KFZj)Wq6EzSBDO~|{u8YSwd^&F4ThCMUQ<-oO$PwpP>`LgPJp#w%~DK2GJxt$ zSi0ABJZ;8RP+LT`9n|Vt`1}S$E zj5AHG$gV2&1+7ofM~-Ya?Kgi4!pyvk-sH1#x2y;^4)Z~`T(1_YcJ_KYmJm0TcnJxP z_wp@Gzc|c#6|YBu5$-r3M>H8nxPIvJC2ZIxfep*Tzo7o9wxlQM84D0519Mf#OWU8(j?{`-?qklR?uJ*J8>-HY1Zpl8N`2@FqG z5H$A%O6>zdT%D{%RO9$shv$`YU#Une*g^D&{)*c){?FjB2|9^_ju?oen=IU^l!^5t zw`(1ef$(CP_?5Mvoe5y9zdVpXbALp!MADdB(cPI%mUvZ#h<_a!mJ?<@rwWE7d3_jN z$4FdZC0M4P#o!A!Qu?{wzQ<-A(R-nNxxnjft@nli)Oa15Ei{fX)L9Am9~K;IV5Vd=C8OifJ6%9$FmMjH)^QBk_{^7u1D}Lr(=$KZEH4z^&7y^A-EcY(d zzzLVt9aXyLYNfi}e-(AJ*HU*vsiU=^xYWM=s}pz^CoJpqJ_)a2r01?u5By}>aTj|% zZn4&@x~aT=^o7Q`t)_D;!eT*|=c*irEQ}65lh;4D5P@TN9B-I2H{?qDJ5O2p+fB*C zzP(|oJDlfcMf2^g0FYZVCx3qguZ`+IpAf<~M-C3AZEBw}eWTV*7Ej1|DP?PUCnMrL z8fMSws~XwT!i|jPJdgzNvEcKU9Z(08VXSNMcU~#1f_?Ac2qU7!N+BCIt&P~5Do)XtWiUZ}bbcO)R^%*=s5rQAg2*nGC4!l+$?~Tq zme}Y{k3z<+<<~qiKra`eCsar<0=e?(qy-|jLdXxzS;c;J{;?Xs943bw73~_ zwlM?08BHffCz7&G)C2E;|RGQN0Cp{;!ndT0?;K(oz_*hvZ_&Dr(EonzHD~ z50<*e3{kq%#3e$uH7!F4YjZ1fnqrtmKNsRVX*aXL!8|Q~XRB$^3)89PfRj1?1wrSX zneLABO-(AuydYdhK!YgZN#^S0qI+9x5aq0;ouUt-CtJ(nlTePbdH!ZpX?IzZT74Gc zFKP!Hth&0_MHFC*f<7PUXusxCK}P%aSbdTN1SUudQ4LRwC;j}(bsnd;+f*G0>4o`c)J90ktMtcxMg*h~%>4~nUM@Ni ztt#+lOUeHbZ$yU(+FK?O57kWoFF1ldt7n0y`6Dg-5dDfX^T!Rg_+-UR(i;Pb;6LR_ zY1=am)Yqt=Q!aY>EnI&JhEQ2oh@vh`%%5n`ze2nM|J&O^2Y&wSsNL)bE!>s6uON1` z5XM@)`#2Y{`2|JihaGPY`~)9d-k5n1zdu#;z#=O8K0NOadKDB-C$|U}Zoz4zfRiO*6(GzGhBB z;DEWLp{oUv+qlVAzt>Y`F*O-oQR_Ny{?>fAo6|<;^8M)RYFMc`pg=b%D_?a|GC5~V z11(78GX&6hd9lDR*i9Qyg}}iYtbk6jjM8cXBlJ162vxGw4#S?EBW$Ki1nce4%qCf(i4psJ+etz+M)Cu8>qV* zw9s2-$Q(VkagF^ktL)!XSh#@xs-TT17R}C4y@F0&&AfvrEf^LZe%PkN6m$5Z$<}yG z3wQ?B``T?y1a)#REcCj^agqL5TN3~ofNlZZz%bs8V!9^O%xY>=MFcASxB{A;SC6aM z3^|gi*Sh}S6;EhF#P+xAsQE!#N5t3I_0N*OvkF=R*~1qHzDpO+vhA7~OWO|_rbmib z29&{&$rPSQV3>Em{zb_mqErCIV;Dh=oro_zuXZDi_y~|7gxuPoe+4Xe2jpjqJ!*O* zIh`R(AM&f z%o)SZRXc`2$7WJ3rwn9vGTSOO4jSgzyE+^c~N55(Zm3`kKBKz`1W%A?9$Csa*-Q{V9ls_q&#nu?BuSmEt#40Tm-zTlylGvd*J{e%SpzgT z4D+FXH9ttKtY;<{4iVtJ-qG>o_BqjMbFu8P21`cPH{t#I6GO09=@qMl8L1k^2Kl6e zVjx#2vAjTl5W|c^)Bc$Lr)hX=5qKhKKczhCS3$TZvsYt}uOR6^Z)K?@8iH zC^6Vlf078x9Ud8yF?CiC zdRoBXbH#t`_*6alz~fcO{^3gs&7|9Led@Ta2k83-cvmYD)op_bNQ@}%XzA*49#3Nz zOpI@FF?`a`W+DOH7L5!$6V#YEi(5jbhIDu;-3}o6YC@sIj}ESqHW&;=@9vtoa?u03 zJAwJ+Gi7$d*=YY*h>$dRliF-?(?MjWwqhTP>(mQ%LaaTvh|qD5%a+L+Yl(3@ZLKAH zxXIwKugO#)s{PNXoO?y`aL_e!zf;c*A^)!J-q!H{YP#xxCZ9LH!Gs|-MuW)cP`Y~} zAxNi4H%NCXBL!(tq*V|>P$Z;lbT`sU3n)2KYQGo1{{Gotx4V1qiF@wze4aZ@yhSjF ze*Iw5L$s#`W;-O2;roLVeDrd>vJ6d9ZRMP>`Li!LT=8O{YKGHoz z`5eeDxi0*^S`F5gfR6EfGv!~zxAOg(`y)L_IPorMBHq?CI&@K+7cIHV`sy$VJaPHK zvfzJCO6aQ|S0!a;PFNR2_rdNkzvi;uzH6}I!Gt=7!dnEd*vVBEFR(?C==M#-7e4dx zKYsW03K&wbJ?XK$s=v0eu_AM2OAw}ca5eTWiLM1lo7elLUwxK}Awr54R8WBBbL$?7 z@Yfuqyg55d_uEZ_muMJ`;AJ@&-)Mh3es=WB?5T=*#^ig56ylco`dirS=K zr~G(((+y()_)-!g+;=0$<;G;hV=DYCAr%6@hDUcFbUt?s;JnD^EQ>%cREmhlwa|B> zwz`DEHmS-6*So{bqhK`#uP8sPrpde9_xCI4!u zyBYuB3)Nsx#Lur!<_-Bf<&cVVUNpX`;g#?i593=c^!wRa-;3Hd@3Y!hQ7d2;w-i2+;fa8HeM8UFz+uvm;fBt;2V)W&ULPp z4j&XeMHKKc#gg^D+Gzvsj&2RPuC>0RrK-I?gr0@)9*zCUV<^*xWt#ZZRSx!4Cd^|X zqU?)!b4+}B>7Q5cCRrqz7(qQ7;YoFgFV}atW(Q3;29MO(Kpx9RbFiz@k&gUow|Kfj zbD7>qCAm>K@rx5#^WfB;Wo@Gd$qSw(!582FZ|UF{)qW5Fdk<|s53%Z85XW2erDJzCYloM}HI0!q-SG(sIEr4TPDx3% z9pDB>3oUl}W2F_Is#hJ)TQAPEVl zh?|#QH)X{YHT&ZZCNii9J~0uvTjgbGYW{3`eb-ZXUE8Y|u&q-^6M}(n!YdnN$4$53 zF~nrKuR09R`d*l;M#vcl=;6TZR{>U-vXiNGXq}{nfpQXIn~wYWmTKa9z8?L&PqzFr z6HG&+s+8z87EZ=j#LXvK+@yOYzVXDgul#`$7`*#%j~~d?tZjETWV+Wmrg_XEXtP`f z=6;U3Zv$L7r31mEj%d`i>wH3L#13qICO+}}jR*NPZ_2IGm8jFyro#C}hWI%#x1q2R zcF&_?kAi~TM_{$>`bn<{oBRx2DifZEOnDJ_D$<4ZpHAO!O^mV4isK=rUXd@g>it%I z9?U#2{%HQq3*(2Id)?eQz(SI5+LlZ*epp*GBecsG3sjO(m`2WsxGDbgn;DsdG`0G3VK&Vw6t?Xl4cY-oI=^(Q|am>2Ie$4#AkI2G4mTl@ozTCVs< zGY;ew#Cg1uwvHoEs>vnKyI!R|%XMrn7-IwikeHpn36ND&ypc0;BNxq~0kNOkeMW+T zhl|?=wDgatuSitt8PUU^$1=17p?r>ERPpp?4x3q~Z{HO(1|eZN$=d#pSBc0X-)jhc zb~bhj_FL<3B$FSx89V<~i?G2$#oj z$2sy1Z0~?ubJWYAq1^vl@m2U4gLv#cbG7=fvr!Zq=QM3T?`pOxsC5xi`HBD*61s;c z`ZWiU=Sf_-%`w}#!?1MWN3w}k#wt<+CDek?D|V89T2JRV5kUo?sZEgI!5Pr3MDsLb zmN~3!1yW0cknjb(5zT>@S(_EmX;7_O5r*jM^|$6yle!9X`zt2HFiwZ69lcGKHpYU7 zaTsDAe-V92LH$xy5Ip*mfJJ-3rcbuS`sACfE!aZ(VQkni9L3^DwL$~h7S4rwrBbEI z;P{eR=Gf$B3~ky2JC(fqRIO3|W10;rV;KDs%{t6ex0;)_X#M3wPI$+;&fJ4OF@r5J_o>J&x>sdQ!Fa z^LDAZvO;Wj_*^rO?J}D-l?cGNHWq4Syz^9x0Ey$^7k|Uco0U)&#w1K8RE;Nkx7XS)HFP3xPJtSb|EfiK%IJ=!Mjtm98j?b&+U=A#OzuQ zi0Z^+e`yAlwiMy>F|B?TUI!(QwRb9n{Wjn@4frD+eKX-15?8uzP)BW4=#_6-cd01M zK??CjWBnL=+gbjd)(}UJSg9bjw|TzbPJ3KQ{=^UI(UtN>)J$EGkMYh1x{&aqly+|0 z1OkuWtZ1cZ8c0bW4mmH7yBys;d8li{&h);dSI0F$o5(p1q{_Jd?- z=Qd0jAAJKDv|1D;CPM1%UY{~ThA=H<|5Y%X%*`E99au0R}9x-=gzc<~S!31Uk#_X^OQ01rfVRSyx0#uJtY|S}h(wS~hcdhSO%2fGCzUG>Vub z$sNnZGQ>_!G;lR@O37}vdWWwZaDumu!{d`95hkSK#|KF%4o9&1he)~404lCpOxel3 zuzM2AqA43B8Xd>1VfZPyI*(;*SwnVQOjRAM#tM|+Jb;K;*D7%a1JA@o%#`D5%TA%g zyMd!LUNm_`t9Ld2?Z+Wy4dz34e_aV^51T<@{>13YVrjOt1*Qdaew_H^*l3GexEFdi zwo9O}r4R)@Mj6-lBDh&rF`@gD=2H|IL`ecEa}0!u+YZnWSQz)1k)ETJ+A{wGU~@)& zOFeAX+8B}U%j#51g;OMJi001>ub63SYfHv&sMiQo>v;Rn5z4V+M(1-?}>rnu3zuJOtVtIo+!dl@;!gk!l{Cf)i@kqig9pEJniqNRN0@k>U zgM{yt4^_Nl_Vh}BmeR;L?}g~(AynS^yxSATqgAgxW8>=c^tB5h_0HS{&b*;(eL_g! zbPp%8vQR&TdA6oU`I?3?kM-I)W2xf~D6pbAN?Q;85QqJt9SlPEvI(A24TPn~Y*?!A zeYSp1-DH=hCA0>_I&xw|sbGW8p+PwP6$9U|EPtm9So>zi)MQk|nSwwG^xJpQh8qFX zMXn&$YC6E>(70Z+Ri*e9!VEY#nPr?!{dzX{i!p7>hcvLPN#udGa?0(e9HPHIy$#F} zp3o!KbX#v}PAVl-d_q%O$5-!sCPfz2P~Eg(_Z#w7AXwKA6BW$Ssl*2AReZ<>Qdus>#M8yO&xWq0TNlDdpljQk#c1h*WlYQ&XTlli#AxOr>%z^}P zJ&+03?-&j6#a7Hct(mVSwzx<=N@Oo`4w6-5r=-}b5TXf1SGv-DJz{{7lvm~i&2{Ny zZ_vN{0Ai;T0#Z{iZlP@7?TzwA6BAuMgIj&F*+%4XY&BoHfvogcr$iMK#*l7C1W7VJ zqrV}g0S%4r1#dXF8R*a3ib}bpql^Z?;w&EqkJfgtt~9hSql>i*cG+0kxql$7ZA1yw za92Pj-t76$k;4j1&l654T5{sDoqy^RAmO9tKgK*?ZjDR|tSGBQm&d(AkuK#W>$l&G z{}@yq5+_MmPEL3$@Y|ShaLis(tp8Pdg_ZIxv2T&Hymk-rv#fnjAc%|5x~FBsD}N@w zb|%T+^HE=VBdANh2nd|{!fbWJc13l2+?XkHNhQVs_)Kug=vVea^C1%$J1bKc_oOK# z(5f56U32ccdK)>xi*{v=wre@Cx{`0fGUtbcZ@)R8o%QsRn{s2$Gj?`!zDdlg9!FE; zHfQnto^g4tW5?$OQO9|H32v&hujBk_zFS-AUW5AWar8vLR6t;0Jkadzv@@*@gvy^v zbXlif^<_gF7QFxM2PQ>1)qTcua>o=jI}qo^W9Uj6=?06L(Oy2Jk+fZTs6Sjd#Ui%XRXGKd_2yI{zHuhU>5TNt#ws`RfZ6-n~~6B~yRK0KR}J z9sN^5-oYaOjtwFLI-lj%KkC$`t8u1T*yago-z0^@03f%#@np6qP5|Ar&i)M-u@3`s z5*}ObhMX_OQ0J6V7I(`=QL6-B3X6mZ{Kng!+`@%qF`ILYw2WIJ179_RyFTw~owGax zK&++@9feQ76$3Q3p6&Fs&)p!L((s&PpyZ-0RO@{dDh7ntN$nH+(a69#KgUw4+OV1? zCgK}l1eO~h>&(f~1&MW5{J0y>UO!0q!p-NX()Rspx$8AtorrZdR#LELkZX`8p@?6W z+jz7(pii#aO@z;vJUvqQ#OeMzDO@J_d1=RTd$ z7+2elGjyv4#_Nsd`3u|(9x0_*>GU?^qWP*!E~!^kAIfPKLTmF&nZxvjN@H$@hPJ&2lN9uDm7`J$-FA(LGN`_%v!{LjkyfZ1<7Ni5Xxx=hqOy zn!~IcRQ$(~z~)4Lw}$tIeLHV0;BI>#+$E^Gy%ZG;f2*5H@rwI+8`k*4*g#rR|E3Pl zlsI0I+f7CermLmtx;2k5g|}iEGc5<8@>#AL(Lk@!4Wb7Tbwu_iui}N&f>IwcpXu7; zn@cXeV9q}sF4rs*sDMHEP`KNhkd|qJ{eu`FGj{xSnD+Yc-78MJ;`Ci2SV~Bror_z4 z%Z>c>j9YWRUXQEn0q+eumOC--H$blyCC7`R7=7;xK2wKReGJjzbk<^&8EVrZ)#RAOXa+!p*uz4XxFs{+Byla zHY1B4(<|Kl`aXyl(A{3>v^3a196eql-5IY^??``a!P)o%NlCOi-Nku89AFG%2XZ#F zrZcy&-o#Lx1w7`$uLBwoF9!4*iA25wO8YM7v-%G`YIY{1iz5mk6}Ci#Q9+1;Q*iV0 zvEHvh3LX56=;Ukb|xoLe_vQbVh9H5M1z z2y*)1pcg1V8gHSHyZ19NzT`mKVH3H;v={8Fnl*E3eZr-9l$tGg%3*Nwi;l&Q-;xLw zZnah(mOv)6g7kLw_@z#GhyR7*b+X{zN7BrC5uWFj4=asDezl0e#S-uEJ#q<)t%j2U z2shZ824!MQYZoOD@eMJB<2GZ7s#*N&GXw8<1dY{DHR-F(iI%BiuLrHtvuL})< zCF&N=(*deu@oDHQP&%xU?cE5-&yNp9uMT_tDY|q!M2!EL8lGQoF>z!FDpj2F%LMNEN=@5T6ZcQbJxizB{xnpU3))R#ANovZwwE^JKckXcPKZ>< z2Wx}~AS|^xYNpf<{i|mPSU6_$;2`omJGC3q$kljT71DGBFBci45ic(jh{0Z|5O{*T zfQtN`Uh3HjZI$d(vJJyeF|L z+5iSf^R90iXQ;5LUP9^mWA}v0cfAW8LRTtv;Z#rAmA#m&Yx;NV2> z0Gmg<7wU1-jep{p97#vnr!3j|^Xc>-u+Rskk4+Nv)gr(DQsN!5j~Ldw;w8b)%%-I7 zbS2(8tM|H${*I~tu-7y|Lp<_!x^AG*+Yz$`J{zJ2GPA4YKtzS1@m1lE!l|2o;M6UH zXU5V>*ks%3!(L|_BmnRsHX5E~q$Yx+%Mw)1nrzuzyYA^%fd|17GCm5uj8+UmhR% zV@(5cNCjC;Kl_=pEgo}gCKKDk(;*ny4hUU4eY8UvxgQvMNQPX~&kZv#pkfPB`Qqj( zW&Ufd_OadTpGE?>HWXO0Jr3ZVk?Z{%RXRcOPFR^M$(nr?I8Vl{*@hQXK1hSC{w~th5(n zt^Fx}JHMu@Y}XTx4aeTeeV4?)wO4gu$~!f>S;j)QZPIdC=@OEP;-$rcdNr7XS(&Z^ z)MTsAF0ULUw>*nA04NYvcc)NeTMl_BrKF=F*E%Z17TbylCLZWw@IK;%P~hAIM(;ng09Z!54+SySrr>8Px*UHDM8X z^Si?m{`7bPOSf>iX~o->Qm>pV{a-Tu{X04rI#Pmg1$S7lPF4qRBs714hkj8~1I?YrOV+grH8vNbgk+vd0OFnkZrezLk3zC?Xx0;j$c298@kMQP~ z-=1is);GeF+M$QUQlE`%WB(4K1x*hJg=2}MkL1Zo`x8m9#?=cHJ-;C6-$(VGsCLa% zc{PqUs8?EgO5WcAC~$n0P_)E2!Y#)n*o7@;`(l^=9mpac)(^7p%x=?p*r>XKC_Adz1r)yR@ET;5i+XBpCCeO$fohPF(+gz4zxs! zqGHHM3=By-zS+|UT+{M9WJFumPJ-d>m$`s=?zz9}fRaau09Xk25k~&X`L}he1^;hJ zd^=N|#gp^S$!$&x3wE@e>R^SA4&}A-pybiC(2?DYwTndtFXJ4`F98NXTmENfpHjVj z2RZiK4=nUj{`HHlgN`&+)hq~RL{HbrcDMyhvVoc%y-IOAh(RTvv8Pk9%h%$hz{!Y4 zkAnsY?;h9K=6RiCxJuxtVy-JU!d610Fva30j7%0)6-NK>cXh;|ARDCeBNw)@uDzN0 zs845PhZ;xbnyiIIZ7t$*bT3T)HnS1OfdocDMfzP&-1^)NTip4a;Gl)C0Sh>JHMpE$ zL1%K*3K0B2vt4iOQHM0Gmfw~287C+6-&RRap%*dfPEU5z z8<^-#hI1hnnIU+|*^mz`l}u%Q%Kz_)K9mG>2Pf8zfl;gwm$rf#oX$TJlDD(m384^J z^t8x++53O9A;xie{Ib+Ium(%%G*O`W(0~=F9>zwO;;+;m-uo%tmO6mVatzBZ%`g9EI;heICCM7xxTkR+Q`8?}=wl zkR+9#|1n$l??T@Mlf&Ltg?3)Wy@6*@5}@2Nft$Ek)E`l*M*P1YX&i2FPc+QXd{-{v zdm|CrmUb!0Q~sY$>BOV0CsbCPcK2IypD1p=e6ueFB6rIUdZIVAi@UV!jq}<2|1-Z2 zv$*Iha7<0Rn|Y9T|E@-l8W0LVvaEFen@FgdA}THm6lTX~?A@QA@hgAHu!{rGj;nr9 zF8*S+8p(T%`oBTd?k~E5rXWG$dlyV>8Kj}T- z0zUl#S7X{u!>6FTk;YvAtr>|j>M#v|H z$+Y5814_a!nBu~rx{SzlsGZ17aTjf^7ylUzup(JM-D z^e)K00RKtBe-_0FagGO#6GG+N?jiDYqLX4Tjt@^V6Mn^hBVWa0dedpnR10r_aHl3+J ztaKDtw-EmFAma}P2y{Jl{t4LX-dlzUD1nRRcALa-cUuyHu+b|J4Y(v%cHPbnAgY85 zF`@eK|1Tvt_$T!P@|thFeF>FV83)^I@6)waX*A_()5DCyq?m}lF#Xr@t$5HbA{xJ7 zPKM8aZg5v}_LX@2a6RA$sTY-6p3@5HTfY@IjAb_b&k03=MhO*q>T5z+;s7eSiO}~D zy_W)!q2(aPbjRODfBwG+0toaWZ}jVim^2yp25b(ExZn%cJLDjiu;$GxuSZsJGHP6! zm)hF;L;v4y&7r|KA0%tgGBc+uZ>pp~K?af9q;Hk0en)HJ?4g?tV$T}>Z+tj7hg?FU zj=YyYYzfz8UK8AATfRTJ1KI-jOAUrl(M*whaG}1i;p8oA|1;-q#2&3s70O-Hy&S8V zP%e2R3fP}4ES?(d|NxqN`FFR)nUJpOf?1_RS#0mY&hyOH}$Y4oIi+M$0WEJ~n8> z>T>8F+1C7ik7xV_@5VPa?xu|9P=LrSxcPLOZ@cI@1v)TDK560HRJn)d0V1pRc zS;v2iqs)Ys0?mT+h*J(5ep3wwjcWfiw)g~8tAQ&{@UTn@6*OU8VVeygdQ)6est$#Z z|0WmOK{ic*40HGl65W)%;`1-WhIe$@@)ll~fNaqfw$s8e4?t7!4kP#{ zGQ`N`aBksxNH?cx@_)7-i6nydd>0P7a=CD$cq<9Q_A*KPW4EtrtdjB{=n*5AozTBe z)}7w;zbS*s+3k@wu+cW)crRfSgLRdC5~9!!(75ZLMUqhLb6Of2tRTPOZF|VcF)hur nXIKfJtJUMF{C7%$P!Py_zGX60>K#zl00OBfYABS-TZH`&tT9_C literal 0 HcmV?d00001 diff --git a/services/bidcollect/website/static/favicon/apple-touch-icon.png b/services/bidcollect/website/static/favicon/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f74258d954b31e18ec718c7441159fd8e169560c GIT binary patch literal 18532 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 literal 0 HcmV?d00001 diff --git a/services/bidcollect/website/static/favicon/favicon-16x16.png b/services/bidcollect/website/static/favicon/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..2449b62694ca5dcf698a9e6493f1466abdd57425 GIT binary patch literal 809 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 O0000K?s-^2?`-- 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 QUHsYq^Z)n2ztaQ%1Q>0EvH$=8 literal 0 HcmV?d00001 diff --git a/services/bidcollect/website/static/favicon/site.webmanifest b/services/bidcollect/website/static/favicon/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/services/bidcollect/website/static/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/services/bidcollect/website/static/styles.css b/services/bidcollect/website/static/styles.css new file mode 100644 index 0000000..e69de29 diff --git a/services/bidcollect/website/templates/base.html b/services/bidcollect/website/templates/base.html new file mode 100644 index 0000000..0205078 --- /dev/null +++ b/services/bidcollect/website/templates/base.html @@ -0,0 +1,126 @@ +{{ define "base" }} + +{{ $title:="Mempool Dumpster ♻️" }} + +{{ if ne .Title "" }} +{{ $title = (printf "%v | %v" .Title $title) }} +{{ end }} + + + + + + + + + + + + + + + + + + + {{ $title }} + + + + + + + + + + + + + + + + + + + + + + +

+ + + +

Mempool Dumpster 🗑️♻️

+

+ https://github.com/flashbots/mempool-dumpster +

+

Illuminate, Democratize, Distribute

+
+
+ {{ template "content" . }} +
+ + + +{{ end }} \ No newline at end of file diff --git a/services/bidcollect/website/templates/index_files.html b/services/bidcollect/website/templates/index_files.html new file mode 100644 index 0000000..54d8d3c --- /dev/null +++ b/services/bidcollect/website/templates/index_files.html @@ -0,0 +1,48 @@ +{{ define "content" }} +{{ $day:="" }} +{{ $class:="even" }} +{{ $change:="" }} + +
+
+{{ .CurrentNetwork }} +

{{ .CurrentMonth }}

+ + + + + + + + + {{ range .Files }} + {{ $dayTmp:=.Filename|substr10 }} + {{ if ne $day $dayTmp }} + {{ $change = "1" }} + {{ $day = $dayTmp }} + {{ if ne $class "even" }} + {{ $class = "even" }} + {{ else }} + {{ $class = "odd" }} + {{ end }} + {{ else }} + {{ $change = "" }} + {{ end }} + + + + + {{ end }} + +
../
+ {{ if eq $change "1" }}{{ end }} + + {{ .Filename }} + {{ .Size | humanBytes }}
+ +
+
+

+ The data is dedicated to the public domain under the CC-0 license. +

+{{ end }} \ No newline at end of file diff --git a/services/bidcollect/website/templates/index_root.html b/services/bidcollect/website/templates/index_root.html new file mode 100644 index 0000000..53506f1 --- /dev/null +++ b/services/bidcollect/website/templates/index_root.html @@ -0,0 +1,20 @@ +{{ define "content" }} + +
+
+

Ethereum Mainnet

+
    + {{ range .EthMainnetMonths }} +
  • {{ . }}
  • + {{ end }} +
+ + +
+
+
+

+

The data is dedicated to the public domain under the CC-0 license.

+

Brought to you by Flashbots in collaboration with bloXroute, Chainbound, Eden and Mempool Guru.

+

+{{ end }} \ No newline at end of file diff --git a/services/bidcollect/website/utils.go b/services/bidcollect/website/utils.go new file mode 100644 index 0000000..caa69c8 --- /dev/null +++ b/services/bidcollect/website/utils.go @@ -0,0 +1,87 @@ +package website + +import ( + "fmt" + "net/http" + "time" + + "go.uber.org/zap" + "golang.org/x/text/cases" + "golang.org/x/text/language" + "golang.org/x/text/message" +) + +var ( + // Printer for pretty printing numbers + printer = message.NewPrinter(language.English) + + // Caser is used for casing strings + caser = cases.Title(language.English) +) + +type HTTPErrorResp struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// responseWriter is a minimal wrapper for http.ResponseWriter that allows the +// written HTTP status code to be captured for logging. +type responseWriter struct { + http.ResponseWriter + status int + wroteHeader bool +} + +func wrapResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{ResponseWriter: w} //nolint:exhaustruct +} + +func (rw *responseWriter) Status() int { + return rw.status +} + +func (rw *responseWriter) WriteHeader(code int) { + if rw.wroteHeader { + return + } + + rw.status = code + rw.ResponseWriter.WriteHeader(code) + rw.wroteHeader = true +} + +// LoggingMiddlewareZap logs the incoming HTTP request & its duration. +func LoggingMiddlewareZap(logger *zap.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle panics + defer func() { + if msg := recover(); msg != nil { + w.WriteHeader(http.StatusInternalServerError) + var method, url string + if r != nil { + method = r.Method + url = r.URL.EscapedPath() + } + logger.Error("HTTP request handler panicked", + zap.Any("error", msg), + zap.String("method", method), + zap.String("url", url), + ) + } + }() + + start := time.Now() + wrapped := wrapResponseWriter(w) + next.ServeHTTP(w, r) + + // Passing request stats both in-message (for the human reader) + // as well as inside the structured log (for the machine parser) + logger.Info(fmt.Sprintf("%s %s %d", r.Method, r.URL.EscapedPath(), wrapped.status), + zap.Int("durationMs", int(time.Since(start).Milliseconds())), + zap.Int("status", wrapped.status), + zap.String("logType", "access"), + zap.String("method", r.Method), + zap.String("path", r.URL.EscapedPath()), + ) + }) +} From 79c563b872ac52fbec6ea22a38ce1c429d3e3f5b Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 7 Jun 2024 12:04:26 +0200 Subject: [PATCH 37/44] website foundation --- services/bidcollect/website/htmldata.go | 17 ++++++++--------- services/bidcollect/website/templates/base.html | 4 ++-- .../website/templates/index_root.html | 1 - 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/services/bidcollect/website/htmldata.go b/services/bidcollect/website/htmldata.go index aca4981..900fe4d 100644 --- a/services/bidcollect/website/htmldata.go +++ b/services/bidcollect/website/htmldata.go @@ -54,15 +54,14 @@ var DummyHTMLData = &HTMLData{ CurrentNetwork: "Ethereum Mainnet", CurrentMonth: "2023-08", Files: []FileEntry{ - {"2023-08-29.csv.zip", 97210118, "02:02:23 2023-09-02"}, - {"2023-08-29.parquet", 90896124, "02:02:09 2023-09-02"}, - {"2023-08-29_transactions.csv.zip", 787064375, "02:02:43 2023-09-02"}, - {"2023-08-30.csv.zip", 97210118, "02:02:23 2023-09-02"}, - {"2023-08-30.parquet", 90896124, "02:02:09 2023-09-02"}, - {"2023-08-30_transactions.csv.zip", 787064375, "02:02:43 2023-09-02"}, - {"2023-08-31.csv.zip", 97210118, "02:02:23 2023-09-02"}, - {"2023-08-31.parquet", 90896124, "02:02:09 2023-09-02"}, - {"2023-08-31_transactions.csv.zip", 787064375, "02:02:43 2023-09-02"}, + {"2023-08-29_all.csv.zip", 97210118, "02:02:23 2023-09-02"}, + {"2023-08-29_top.csv.zip", 7210118, "02:02:23 2023-09-02"}, + + {"2023-08-30_all.csv.zip", 97210118, "02:02:23 2023-09-02"}, + {"2023-08-30_top.csv.zip", 7210118, "02:02:23 2023-09-02"}, + + {"2023-08-31_all.csv.zip", 97210118, "02:02:23 2023-09-02"}, + {"2023-08-31_top.csv.zip", 7210118, "02:02:23 2023-09-02"}, }, } diff --git a/services/bidcollect/website/templates/base.html b/services/bidcollect/website/templates/base.html index 0205078..b9f2df9 100644 --- a/services/bidcollect/website/templates/base.html +++ b/services/bidcollect/website/templates/base.html @@ -111,9 +111,9 @@ -

Mempool Dumpster 🗑️♻️

+

Relayscan Bidarchive

- https://github.com/flashbots/mempool-dumpster + https://github.com/flashbots/relayscan

Illuminate, Democratize, Distribute

diff --git a/services/bidcollect/website/templates/index_root.html b/services/bidcollect/website/templates/index_root.html index 53506f1..161a5b0 100644 --- a/services/bidcollect/website/templates/index_root.html +++ b/services/bidcollect/website/templates/index_root.html @@ -15,6 +15,5 @@

Ethereum Mainnet


The data is dedicated to the public domain under the CC-0 license.

-

Brought to you by Flashbots in collaboration with bloXroute, Chainbound, Eden and Mempool Guru.

{{ end }} \ No newline at end of file From 996e22455c6b8118aa8205eaee2b85d8eb1be680 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 7 Jun 2024 13:47:36 +0200 Subject: [PATCH 38/44] more doc cleanup --- docs/2024-06_bidcollect.md | 4 +- docs/adrs/202405-bidcollect.md | 93 ---------------------------------- 2 files changed, 3 insertions(+), 94 deletions(-) diff --git a/docs/2024-06_bidcollect.md b/docs/2024-06_bidcollect.md index 1c60e29..1179ac8 100644 --- a/docs/2024-06_bidcollect.md +++ b/docs/2024-06_bidcollect.md @@ -14,6 +14,7 @@ Output: See also: - [Example output](https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa395) +- PR: https://github.com/flashbots/relayscan/pull/37 - TODO: link CSV files --- @@ -33,10 +34,11 @@ Different data sources have different limitations: - Optimistic is always `false` - Does not include `builder_pubkey` - Does not include bid timestamp (need to use receive timestamp) + - getHeader bid timestamps are always when the response from polling at t=1s comes back (but not when the bid was received at a relay) - Data API polling: - Has all the necessary information - Due to rate limits, we only poll at specific times - - Polling at t-4, t-2, t-0.5, t+0.5, t+2 (see also [`services/bidcollect/data-api-pollser.go`](services/bidcollect/data-api-poller.go#64-69)) + - Polling at t-4, t-2, t-0.5, t+0.5, t+2 (see also [`services/bidcollect/data-api-poller.go`](services/bidcollect/data-api-poller.go#64-69)) - Ultrasound websocket stream - doesn't expose optimistic, thus that field is always `false` diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md index 2b560ad..3d71df7 100644 --- a/docs/adrs/202405-bidcollect.md +++ b/docs/adrs/202405-bidcollect.md @@ -2,96 +2,3 @@ New and cleaned up doc in [2024-06_bidcollect.md](../2024-06_bidcollect.md). - -## Goal - -Relayscan should collect bids across relays: - -1. [Ultrasound top-bid websocket stream](https://github.com/ultrasoundmoney/docs/blob/main/top-bid-websocket.md) -2. getHeader polling -3. Data API polling - -It should expose these as: - -1. Parquet/CSV files -2. A websocket/SSE stream - -### Notes - -- Source types: - - `0`: `getHeader` polling - - `1`: Data API polling - - `2`: Ultrasound top-bid Websockets stream -- getHeader polling - - some relay only allow a single getHeader request per slot, so we time it at t=1s - - header only has limited information with these implications: - - optimistic is always `false` - - does not include `builder_pubkey` - - does not include bid timestamp (need to use receive timestamp) - - Ultrasound relay doesn't support repeated getHeader requests -- Ultrasound websocket stream - - doesn't expose optimistic, thus that field is always false - -Useful [clickhouse-local](https://clickhouse.com/docs/en/operations/utilities/clickhouse-local) queries: - -```bash -clickhouse local -q "SELECT source_type, COUNT(source_type) FROM '2024-06-02_top-00.tsv' GROUP BY source_type ORDER BY source_type;" - -# Get bids > 1 ETH for specific builders (CSV has 10M rows) -time clickhouse local -q "SELECT count(value), quantile(0.5)(value) as p50, quantile(0.75)(value) as p75, quantile(0.9)(value) as p90, max(value) FROM '2024-06-05_all.csv' WHERE value > 1000000000000000000 AND builder_pubkey IN ('0xa01a00479f1fa442a8ebadb352be69091d07b0c0a733fae9166dae1b83179e326a968717da175c7363cd5a13e8580e8d', '0xa02a0054ea4ba422c88baccfdb1f43b2c805f01d1475335ea6647f69032da847a41c0e23796c6bed39b0ee11ab9772c6', '0xa03a000b0e3d1dc008f6075a1b1af24e6890bd674c26235ce95ac06e86f2bd3ccf4391df461b9e5d3ca654ef6b9e1ceb') FORMAT TabSeparatedWithNames;" -count(value) p50 p75 p90 max(value) -1842 1789830446982354000 2279820737908906200 4041286254343376400 8216794401676997763 - -real 0m2.202s -user 0m17.320s -sys 0m0.589s -``` - -## Status - -Mostly working -- PR: https://github.com/flashbots/relayscan/pull/37 -- Example output: https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa395 - -Run: - -```bash -# CSV output (into `csv//.csv`) -go run . service bidcollect --data-api --ultrasound-stream - -# TSV output (into `data//.tsv`) -go run . service bidcollect --out data --out-tsv --data-api --ultrasound-stream -``` - -### Done - -- Ultrasound bid stream -- Data API polling (at t-4, t-2, t-0.5, t+0.5, t+2) -- getHeader polling at t+1 -- CSV/TSV Output - - Writing to hourly CSV files (one file for top bids, and one for all bids) - - Cache for deduplication - - Script to combine into single CSV - -### Next up (must have) - -- Diagram showing the flow of data and the components involved -- Consider methodology of storing "relay" -- Double-check caching methodology (only one bid per unique key, consider also per source type?) -- Double-check that bids are complete and without duplicates - -### Could have - -Data API polling -- relay-specific rate limits? - -Stream Output -- Websockets or SSE subscription - -File Output -- Consider Parquet output files (not sure if needed) -- Upload to S3 + R2 (see also mempool dumpster scripts) - -getHeader polling -- some already implemented in [collect-live-bids.go](/cmd/service/collect-live-bids.go)) -- define query times From 469042ca17217242f33e60dff62c773997c8828f Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Fri, 7 Jun 2024 14:08:06 +0200 Subject: [PATCH 39/44] devserver cleanup --- services/bidcollect/website/devserver.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/services/bidcollect/website/devserver.go b/services/bidcollect/website/devserver.go index 065acdf..04520c5 100644 --- a/services/bidcollect/website/devserver.go +++ b/services/bidcollect/website/devserver.go @@ -85,7 +85,7 @@ func (srv *DevWebserver) RespondError(w http.ResponseWriter, code int, message s w.WriteHeader(code) resp := HTTPErrorResp{code, message} if err := json.NewEncoder(w).Encode(resp); err != nil { - srv.log.WithField("response", resp).Error("Couldn't write error response", "error", err) + srv.log.WithError(err).Error("Couldn't write error response") http.Error(w, "", http.StatusInternalServerError) } } @@ -94,7 +94,7 @@ func (srv *DevWebserver) RespondOK(w http.ResponseWriter, response any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(response); err != nil { - srv.log.WithField("response", response).Error("Couldn't write OK response", "error", err) + srv.log.WithError(err).Error("Couldn't write OK response") http.Error(w, "", http.StatusInternalServerError) } } @@ -102,7 +102,7 @@ func (srv *DevWebserver) RespondOK(w http.ResponseWriter, response any) { func (srv *DevWebserver) handleRoot(w http.ResponseWriter, req *http.Request) { tpl, err := ParseIndexTemplate() if err != nil { - srv.log.Error("wroot: error parsing template", "error", err) + srv.log.WithError(err).Error("wroot: error parsing template") return } w.WriteHeader(http.StatusOK) @@ -111,7 +111,7 @@ func (srv *DevWebserver) handleRoot(w http.ResponseWriter, req *http.Request) { data.Path = "/" err = tpl.ExecuteTemplate(w, "base", data) if err != nil { - srv.log.Error("wroot: error executing template", "error", err) + srv.log.WithError(err).Error("wroot: error executing template") return } } @@ -128,7 +128,7 @@ func (srv *DevWebserver) handleMonth(w http.ResponseWriter, req *http.Request) { tpl, err := ParseFilesTemplate() if err != nil { - srv.log.Error("wroot: error parsing template", "error", err) + srv.log.WithError(err).Error("wroot: error parsing template") return } w.WriteHeader(http.StatusOK) @@ -139,7 +139,7 @@ func (srv *DevWebserver) handleMonth(w http.ResponseWriter, req *http.Request) { err = tpl.ExecuteTemplate(w, "base", &data) if err != nil { - srv.log.Error("wroot: error executing template", "error", err) + srv.log.WithError(err).Error("wroot: error executing template") return } } From 3aca0bf7cfd443e94aa48fde88d98cc0cfb09cd9 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Tue, 11 Jun 2024 09:25:15 +0200 Subject: [PATCH 40/44] minor notes --- docs/2024-06_bidcollect.md | 10 +++++++++- docs/adrs/202405-bidcollect.md | 4 ---- scripts/bids-combine-and-upload.sh | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) delete mode 100644 docs/adrs/202405-bidcollect.md diff --git a/docs/2024-06_bidcollect.md b/docs/2024-06_bidcollect.md index 1179ac8..2baecad 100644 --- a/docs/2024-06_bidcollect.md +++ b/docs/2024-06_bidcollect.md @@ -85,4 +85,12 @@ sys 0m0.589s ## Architecture -![Architecture](./img/bidcollect-overview.png) \ No newline at end of file +![Architecture](./img/bidcollect-overview.png) + + +--- + +## TODO + +- spotting some weird lines in csv files, might be concurrent writes or not flushing? + - -> double-check file contents \ No newline at end of file diff --git a/docs/adrs/202405-bidcollect.md b/docs/adrs/202405-bidcollect.md deleted file mode 100644 index 3d71df7..0000000 --- a/docs/adrs/202405-bidcollect.md +++ /dev/null @@ -1,4 +0,0 @@ -# ADR for bid collection - -New and cleaned up doc in [2024-06_bidcollect.md](../2024-06_bidcollect.md). - diff --git a/scripts/bids-combine-and-upload.sh b/scripts/bids-combine-and-upload.sh index 438e79b..048569a 100755 --- a/scripts/bids-combine-and-upload.sh +++ b/scripts/bids-combine-and-upload.sh @@ -39,7 +39,7 @@ echo "Wrote ${fn_out_zip}" rm -f $fn_out # Upload -if [[ "${UPLOAD}" == "1" ]]; then +if [[ "${UPLOAD}" != "0" ]]; then echo "Uploading to R2 and S3..." aws --profile r2 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" --endpoint-url "https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" aws --profile s3 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" @@ -74,7 +74,7 @@ echo "Wrote ${fn_out_zip}" rm -f $fn_out # Upload -if [[ "${UPLOAD}" == "1" ]]; then +if [[ "${UPLOAD}" != "0" ]]; then echo "Uploading to R2 and S3..." aws --profile r2 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" --endpoint-url "https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" aws --profile s3 s3 cp --no-progress "${fn_out_zip}" "s3://relayscan-bidarchive/ethereum/mainnet/${ym}/" From a27889b239133ea6a2ac58146bf5e73fc1735442 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Tue, 11 Jun 2024 09:27:01 +0200 Subject: [PATCH 41/44] bids-script: delete --- scripts/bids-combine-and-upload.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/bids-combine-and-upload.sh b/scripts/bids-combine-and-upload.sh index 048569a..0b65530 100755 --- a/scripts/bids-combine-and-upload.sh +++ b/scripts/bids-combine-and-upload.sh @@ -19,8 +19,7 @@ echo "" # ALL BIDS fn_out="${date}_all.csv" fn_out_zip="${fn_out}.zip" -fn_out_gz="${fn_out}.gz" -rm -f $fn_out $fn_out_zip $fn_out_gz +rm -f $fn_out $fn_out_zip echo "Combining all bids..." first="1" @@ -37,6 +36,7 @@ wc -l $fn_out zip ${fn_out_zip} $fn_out echo "Wrote ${fn_out_zip}" rm -f $fn_out +rm -f all*.csv # Upload if [[ "${UPLOAD}" != "0" ]]; then @@ -55,8 +55,7 @@ echo "" echo "Combining top bids..." fn_out="${date}_top.csv" fn_out_zip="${fn_out}.zip" -fn_out_gz="${fn_out}.gz" -rm -f $fn_out $fn_out_zip $fn_out_gz +rm -f $fn_out $fn_out_zip first="1" for fn in $(\ls top*); do @@ -72,6 +71,7 @@ wc -l $fn_out zip ${fn_out_zip} $fn_out echo "Wrote ${fn_out_zip}" rm -f $fn_out +rm -f top*.csv # Upload if [[ "${UPLOAD}" != "0" ]]; then From d5084881aa86c2baaddc392cba2680dd49076bdf Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Tue, 11 Jun 2024 09:30:24 +0200 Subject: [PATCH 42/44] fix script perms --- docs/2024-06_bidcollect.md | 4 ++-- scripts/bids-combine-and-upload-yesterday.sh | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) mode change 100644 => 100755 scripts/bids-combine-and-upload-yesterday.sh diff --git a/docs/2024-06_bidcollect.md b/docs/2024-06_bidcollect.md index 2baecad..972ecd8 100644 --- a/docs/2024-06_bidcollect.md +++ b/docs/2024-06_bidcollect.md @@ -92,5 +92,5 @@ sys 0m0.589s ## TODO -- spotting some weird lines in csv files, might be concurrent writes or not flushing? - - -> double-check file contents \ No newline at end of file +- Website generation +- Dockerization \ No newline at end of file diff --git a/scripts/bids-combine-and-upload-yesterday.sh b/scripts/bids-combine-and-upload-yesterday.sh old mode 100644 new mode 100755 index 18e94e8..7e48ebc --- a/scripts/bids-combine-and-upload-yesterday.sh +++ b/scripts/bids-combine-and-upload-yesterday.sh @@ -24,7 +24,7 @@ cd .. source .env.prod # archive and upload! -UPLOAD=1 ./scripts/bids-combine-and-upload.sh "/mnt/data/relayscan-bids/$d/" +./scripts/bids-combine-and-upload.sh "/mnt/data/relayscan-bids/$d/" # update website # make website From d9ec5db340561308b39f731bc4bfb7536b3a6285 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Wed, 12 Jun 2024 09:39:33 +0200 Subject: [PATCH 43/44] website --- .gitignore | 3 +- Makefile | 6 + cmd/service/bidcollect.go | 15 ++ .../bids-combine-and-upload-yesterday.sh | 6 +- .../bids-combine-and-upload.sh | 0 scripts/bidcollect/s3/get-files.sh | 8 + scripts/bidcollect/s3/get-folders.sh | 2 + scripts/bidcollect/s3/upload-file-to-r2.sh | 16 ++ services/bidcollect/website/generator.go | 211 ++++++++++++++++++ .../bidcollect/website/templates/base.html | 26 +-- 10 files changed, 276 insertions(+), 17 deletions(-) rename scripts/{ => bidcollect}/bids-combine-and-upload-yesterday.sh (82%) rename scripts/{ => bidcollect}/bids-combine-and-upload.sh (100%) create mode 100755 scripts/bidcollect/s3/get-files.sh create mode 100755 scripts/bidcollect/s3/get-folders.sh create mode 100755 scripts/bidcollect/s3/upload-file-to-r2.sh create mode 100644 services/bidcollect/website/generator.go diff --git a/.gitignore b/.gitignore index 87cb106..6ee1f64 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,5 @@ /relayscan /deploy* /test.csv -/csv/ \ No newline at end of file +/csv/ +/build/ \ No newline at end of file diff --git a/Makefile b/Makefile index c5e2809..cf02b7d 100644 --- a/Makefile +++ b/Makefile @@ -55,3 +55,9 @@ docker-image: generate-ssz: rm -f common/ultrasoundbid_encoding.go sszgen --path common --objs UltrasoundStreamBid + +bids-website: + go run . service bidcollect --build-website --build-website-upload + +bids-website-dev: + go run . service bidcollect --devserver diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index da29d3c..5fe54e2 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -23,6 +23,10 @@ var ( runDevServerOnly bool // used to play with file listing website devServerListenAddr = ":8095" + + buildWebsite bool + buildWebsiteUpload bool + buildWebsiteOutDir string ) func init() { @@ -40,6 +44,11 @@ func init() { // for dev purposes bidCollectCmd.Flags().BoolVar(&runDevServerOnly, "devserver", false, "only run devserver to play with file listing website") + + // building the S3 website + bidCollectCmd.Flags().BoolVar(&buildWebsite, "build-website", false, "build file listing website") + bidCollectCmd.Flags().BoolVar(&buildWebsiteUpload, "build-website-upload", false, "upload after building") + bidCollectCmd.Flags().StringVar(&buildWebsiteOutDir, "build-website-out", "build", "output directory for website") } var bidCollectCmd = &cobra.Command{ @@ -52,6 +61,12 @@ var bidCollectCmd = &cobra.Command{ return } + if buildWebsite { + log.Infof("Bidcollect %s building website (output: %s) ...", vars.Version, buildWebsiteOutDir) + website.BuildProdWebsite(log, buildWebsiteOutDir, buildWebsiteUpload) + return + } + log.Infof("Bidcollect starting (%s) ...", vars.Version) // Prepare relays diff --git a/scripts/bids-combine-and-upload-yesterday.sh b/scripts/bidcollect/bids-combine-and-upload-yesterday.sh similarity index 82% rename from scripts/bids-combine-and-upload-yesterday.sh rename to scripts/bidcollect/bids-combine-and-upload-yesterday.sh index 7e48ebc..97c264a 100755 --- a/scripts/bids-combine-and-upload-yesterday.sh +++ b/scripts/bidcollect/bids-combine-and-upload-yesterday.sh @@ -18,13 +18,13 @@ echo "upload for: $d" # change to project root directory cd "$(dirname "$0")" -cd .. +cd ../../ # load environment variables source .env.prod # archive and upload! -./scripts/bids-combine-and-upload.sh "/mnt/data/relayscan-bids/$d/" +./scripts/bidcollect/bids-combine-and-upload.sh "/mnt/data/relayscan-bids/$d/" # update website -# make website +make bids-website diff --git a/scripts/bids-combine-and-upload.sh b/scripts/bidcollect/bids-combine-and-upload.sh similarity index 100% rename from scripts/bids-combine-and-upload.sh rename to scripts/bidcollect/bids-combine-and-upload.sh diff --git a/scripts/bidcollect/s3/get-files.sh b/scripts/bidcollect/s3/get-files.sh new file mode 100755 index 0000000..71405bd --- /dev/null +++ b/scripts/bidcollect/s3/get-files.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# require one argument +if [ $# -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +aws --profile r2 s3 ls s3://relayscan-bidarchive/$1 --endpoint-url "https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" \ No newline at end of file diff --git a/scripts/bidcollect/s3/get-folders.sh b/scripts/bidcollect/s3/get-folders.sh new file mode 100755 index 0000000..18c47ef --- /dev/null +++ b/scripts/bidcollect/s3/get-folders.sh @@ -0,0 +1,2 @@ +#!/bin/bash +aws --profile r2 s3 ls s3://relayscan-bidarchive/$1 --endpoint-url "https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" | awk '{ print $2 }' \ No newline at end of file diff --git a/scripts/bidcollect/s3/upload-file-to-r2.sh b/scripts/bidcollect/s3/upload-file-to-r2.sh new file mode 100755 index 0000000..55d5e8e --- /dev/null +++ b/scripts/bidcollect/s3/upload-file-to-r2.sh @@ -0,0 +1,16 @@ +#!/bin/bash +src=$1 +target=$2 +if [ -z "$src" ]; then + echo "Usage: $0 ["] + exit 1 +fi + +# auto-fill target if not given +if [ -z "$target" ]; then + # remove "/mnt/data/relayscan-bidarchive/" prefix from src and make it the S3 prefix + target="/ethereum/mainnet/${src#"/mnt/data/relayscan-bidarchive/"}" +fi + +echo "uploading $src to S3 $target ..." +aws --profile r2 s3 cp $src s3://relayscan-bidarchive$target --endpoint-url "https://${CLOUDFLARE_R2_ACCOUNT_ID}.r2.cloudflarestorage.com" diff --git a/services/bidcollect/website/generator.go b/services/bidcollect/website/generator.go new file mode 100644 index 0000000..9418147 --- /dev/null +++ b/services/bidcollect/website/generator.go @@ -0,0 +1,211 @@ +package website + +// +// Quick and dirty website generator +// + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + + "github.com/sirupsen/logrus" + "github.com/tdewolff/minify" + "github.com/tdewolff/minify/css" + "github.com/tdewolff/minify/html" +) + +func BuildProdWebsite(log *logrus.Entry, outDir string, upload bool) { + log.Infof("Creating build server in %s", outDir) + err := os.MkdirAll(outDir, os.ModePerm) + if err != nil { + log.Fatal(err) + } + + dir := "ethereum/mainnet/" + + // Setup minifier + minifier := minify.New() + minifier.AddFunc("text/html", html.Minify) + minifier.AddFunc("text/css", css.Minify) + + // Load month folders from S3 + log.Infof("Getting folders from S3 for %s ...", dir) + months, err := getFoldersFromS3(dir) + if err != nil { + log.Fatal(err) + } + fmt.Println("Months:", months) + + // build root page + log.Infof("Building root page ...") + rootPageData := HTMLData{ //nolint:exhaustruct + Title: "", + Path: "/index.html", + EthMainnetMonths: months, + } + + tpl, err := ParseIndexTemplate() + if err != nil { + log.Fatal(err) + } + + buf := new(bytes.Buffer) + err = tpl.ExecuteTemplate(buf, "base", rootPageData) + if err != nil { + log.Fatal(err) + } + + // minify + mBytes, err := minifier.Bytes("text/html", buf.Bytes()) + if err != nil { + log.Fatal(err) + } + + // write to file + fn := filepath.Join(outDir, "index.html") + log.Infof("Writing to %s ...", fn) + err = os.WriteFile(fn, mBytes, 0o0600) + if err != nil { + log.Fatal(err) + } + + toUpload := []struct{ from, to string }{ + {fn, "/"}, + } + + // build files pages + for _, month := range months { + dir := "ethereum/mainnet/" + month + "/" + log.Infof("Getting files from S3 for %s ...", dir) + files, err := getFilesFromS3(dir) + if err != nil { + log.Fatal(err) + } + + rootPageData := HTMLData{ //nolint:exhaustruct + Title: month, + Path: fmt.Sprintf("ethereum/mainnet/%s/index.html", month), + + CurrentNetwork: "Ethereum Mainnet", + CurrentMonth: month, + Files: files, + } + + tpl, err := ParseFilesTemplate() + if err != nil { + log.Fatal(err) + } + + buf := new(bytes.Buffer) + err = tpl.ExecuteTemplate(buf, "base", rootPageData) + if err != nil { + log.Fatal(err) + } + + // minify + mBytes, err := minifier.Bytes("text/html", buf.Bytes()) + if err != nil { + log.Fatal(err) + } + + // write to file + _outDir := filepath.Join(outDir, dir) + err = os.MkdirAll(_outDir, os.ModePerm) + if err != nil { + log.Fatal(err) + } + + fn := filepath.Join(_outDir, "index.html") + log.Infof("Writing to %s ...", fn) + err = os.WriteFile(fn, mBytes, 0o0600) + if err != nil { + log.Fatal(err) + } + + toUpload = append(toUpload, struct{ from, to string }{fn, "/" + dir}) + } + + if upload { + log.Info("Uploading to S3 ...") + // for _, file := range toUpload { + // fmt.Printf("- %s -> %s\n", file.from, file.to) + // } + + for _, file := range toUpload { + app := "./scripts/bidcollect/s3/upload-file-to-r2.sh" + cmd := exec.Command(app, file.from, file.to) //nolint:gosec + stdout, err := cmd.Output() + if err != nil { + log.Fatal(err) + } + fmt.Println(string(stdout)) + } + } +} + +func getFoldersFromS3(dir string) ([]string, error) { + folders := []string{} + + app := "./scripts/bidcollect/s3/get-folders.sh" + cmd := exec.Command(app, dir) + stdout, err := cmd.Output() + if err != nil { + return folders, err + } + + // Print the output + lines := strings.Split(string(stdout), "\n") + for _, line := range lines { + if line != "" && strings.HasPrefix(line, "20") { + folders = append(folders, strings.TrimSuffix(line, "/")) + } + } + return folders, nil +} + +func getFilesFromS3(month string) ([]FileEntry, error) { + files := []FileEntry{} + + app := "./scripts/bidcollect/s3/get-files.sh" + cmd := exec.Command(app, month) + stdout, err := cmd.Output() + if err != nil { + return files, err + } + + space := regexp.MustCompile(`\s+`) + lines := strings.Split(string(stdout), "\n") + for _, line := range lines { + if line != "" { + line = space.ReplaceAllString(line, " ") + parts := strings.Split(line, " ") + + // parts[2] is the size + size, err := strconv.ParseUint(parts[2], 10, 64) + if err != nil { + return files, err + } + + filename := parts[3] + + if filename == "index.html" { + continue + } else if strings.HasSuffix(filename, ".csv.gz") { + continue + } + + files = append(files, FileEntry{ + Filename: filename, + Size: size, + Modified: parts[1] + " " + parts[0], + }) + } + } + return files, nil +} diff --git a/services/bidcollect/website/templates/base.html b/services/bidcollect/website/templates/base.html index b9f2df9..22959ca 100644 --- a/services/bidcollect/website/templates/base.html +++ b/services/bidcollect/website/templates/base.html @@ -1,6 +1,6 @@ {{ define "base" }} -{{ $title:="Mempool Dumpster ♻️" }} +{{ $title:="Relayscan Bidarchive 📚" }} {{ if ne .Title "" }} {{ $title = (printf "%v | %v" .Title $title) }} @@ -24,22 +24,22 @@ {{ $title }} - + - + - - + + - - + + - - + + @@ -111,7 +111,7 @@ -

Relayscan Bidarchive

+

{{ $title }}

https://github.com/flashbots/relayscan

From 3dcf2e62172a934accfe34de9acdbbb1ac8abda0 Mon Sep 17 00:00:00 2001 From: Chris Hager Date: Thu, 13 Jun 2024 14:35:00 +0200 Subject: [PATCH 44/44] cleanup --- cmd/service/bidcollect.go | 2 +- docs/2024-06_bidcollect.md | 27 ++++++++++--------- .../bidcollect/website/templates/base.html | 9 ++++--- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/cmd/service/bidcollect.go b/cmd/service/bidcollect.go index 5fe54e2..094194d 100644 --- a/cmd/service/bidcollect.go +++ b/cmd/service/bidcollect.go @@ -56,7 +56,7 @@ var bidCollectCmd = &cobra.Command{ Short: "Collect bids", Run: func(cmd *cobra.Command, args []string) { if runDevServerOnly { - log.Infof("Bidcollect devserver starting (%s) ...", vars.Version) + log.Infof("Bidcollect (%s) devserver starting on %s ...", vars.Version, devServerListenAddr) fileListingDevServer() return } diff --git a/docs/2024-06_bidcollect.md b/docs/2024-06_bidcollect.md index 972ecd8..b499f24 100644 --- a/docs/2024-06_bidcollect.md +++ b/docs/2024-06_bidcollect.md @@ -1,6 +1,6 @@ # Bid Collection -Relayscan should collect bids across relays: +Relayscan collects bids across [relays](../vars/relays.go) with these methods: 1. [getHeader polling](https://ethereum.github.io/builder-specs/#/Builder/getHeader) 2. [Data API polling](https://flashbots.github.io/relay-specs/#/Data/getReceivedBids) @@ -9,13 +9,12 @@ Relayscan should collect bids across relays: Output: 1. CSV file archive -2. Websocket/SSE stream (maybe) See also: - [Example output](https://gist.github.com/metachris/061c0443afb8b8d07eed477a848fa395) -- PR: https://github.com/flashbots/relayscan/pull/37 -- TODO: link CSV files +- [Pull request #37](https://github.com/flashbots/relayscan/pull/37) +- Live data: https://bidarchive.relayscan.io --- @@ -29,18 +28,18 @@ Source types: Different data sources have different limitations: - `getHeader` polling: - - Some relays only allow a single `getHeader` request per slot, so we time it at t=1s - - Header only has limited information with these implications: + - The received header only has limited information, with these implications: - Optimistic is always `false` - - Does not include `builder_pubkey` - - Does not include bid timestamp (need to use receive timestamp) + - No `builder_pubkey` + - No bid timestamp (need to use receive timestamp) - getHeader bid timestamps are always when the response from polling at t=1s comes back (but not when the bid was received at a relay) + - Some relays only allow a single `getHeader` request per slot, so we time it at `t=1s` - Data API polling: - Has all the necessary information - Due to rate limits, we only poll at specific times - Polling at t-4, t-2, t-0.5, t+0.5, t+2 (see also [`services/bidcollect/data-api-poller.go`](services/bidcollect/data-api-poller.go#64-69)) - - Ultrasound websocket stream - - doesn't expose optimistic, thus that field is always `false` +- Ultrasound websocket stream + - doesn't expose optimistic, thus that field is always `false` ## Other notes @@ -69,10 +68,13 @@ go run . service bidcollect --get-header --beacon-uri http://localhost:3500 --al Useful [clickhouse-local](https://clickhouse.com/docs/en/operations/utilities/clickhouse-local) queries: ```bash -clickhouse local -q "SELECT source_type, COUNT(source_type) FROM '2024-06-02_top-00.tsv' GROUP BY source_type ORDER BY source_type;" +$ clickhouse local -q "SELECT source_type, COUNT(source_type) FROM '2024-06-12_top.csv' GROUP BY source_type ORDER BY source_type;" +0 2929 +1 21249 +2 1057722 # Get bids > 1 ETH for specific builders (CSV has 10M rows) -time clickhouse local -q "SELECT count(value), quantile(0.5)(value) as p50, quantile(0.75)(value) as p75, quantile(0.9)(value) as p90, max(value) FROM '2024-06-05_all.csv' WHERE value > 1000000000000000000 AND builder_pubkey IN ('0xa01a00479f1fa442a8ebadb352be69091d07b0c0a733fae9166dae1b83179e326a968717da175c7363cd5a13e8580e8d', '0xa02a0054ea4ba422c88baccfdb1f43b2c805f01d1475335ea6647f69032da847a41c0e23796c6bed39b0ee11ab9772c6', '0xa03a000b0e3d1dc008f6075a1b1af24e6890bd674c26235ce95ac06e86f2bd3ccf4391df461b9e5d3ca654ef6b9e1ceb') FORMAT TabSeparatedWithNames;" +$ time clickhouse local -q "SELECT count(value), quantile(0.5)(value) as p50, quantile(0.75)(value) as p75, quantile(0.9)(value) as p90, max(value) FROM '2024-06-05_all.csv' WHERE value > 1000000000000000000 AND builder_pubkey IN ('0xa01a00479f1fa442a8ebadb352be69091d07b0c0a733fae9166dae1b83179e326a968717da175c7363cd5a13e8580e8d', '0xa02a0054ea4ba422c88baccfdb1f43b2c805f01d1475335ea6647f69032da847a41c0e23796c6bed39b0ee11ab9772c6', '0xa03a000b0e3d1dc008f6075a1b1af24e6890bd674c26235ce95ac06e86f2bd3ccf4391df461b9e5d3ca654ef6b9e1ceb') FORMAT TabSeparatedWithNames;" count(value) p50 p75 p90 max(value) 1842 1789830446982354000 2279820737908906200 4041286254343376400 8216794401676997763 @@ -92,5 +94,4 @@ sys 0m0.589s ## TODO -- Website generation - Dockerization \ No newline at end of file diff --git a/services/bidcollect/website/templates/base.html b/services/bidcollect/website/templates/base.html index 22959ca..72d8554 100644 --- a/services/bidcollect/website/templates/base.html +++ b/services/bidcollect/website/templates/base.html @@ -24,13 +24,13 @@ {{ $title }} - + - + @@ -38,7 +38,7 @@ - +