diff --git a/README.md b/README.md index f324f39..a288fe9 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,6 @@ Here is our minimum database disk space requirement for each module. | ------ | -------------------------- | ---------------------------- | | Runes | 10 GB | 150 GB | -Here is our minimum database disk space requirement for each module. - #### 4. Prepare `config.yaml` file. ```yaml @@ -108,7 +106,7 @@ We will be using `docker-compose` for our installation guide. Make sure the `doc # docker-compose.yaml services: gaze-indexer: - image: ghcr.io/gaze-network/gaze-indexer:v1.0.0 + image: ghcr.io/gaze-network/gaze-indexer:v0.2.1 container_name: gaze-indexer restart: unless-stopped ports: diff --git a/cmd/cmd_run.go b/cmd/cmd_run.go index 206de00..325686b 100644 --- a/cmd/cmd_run.go +++ b/cmd/cmd_run.go @@ -23,10 +23,15 @@ import ( "github.com/gaze-network/indexer-network/pkg/errorhandler" "github.com/gaze-network/indexer-network/pkg/logger" "github.com/gaze-network/indexer-network/pkg/logger/slogx" + "github.com/gaze-network/indexer-network/pkg/middleware/requestcontext" + "github.com/gaze-network/indexer-network/pkg/middleware/requestlogger" "github.com/gaze-network/indexer-network/pkg/reportingclient" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/favicon" fiberrecover "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/fiber/v2/middleware/requestid" "github.com/samber/do/v2" "github.com/samber/lo" "github.com/spf13/cobra" @@ -137,6 +142,14 @@ func runHandler(cmd *cobra.Command, _ []string) error { ErrorHandler: errorhandler.NewHTTPErrorHandler(), }) app. + Use(favicon.New()). + Use(cors.New()). + Use(requestid.New()). + Use(requestcontext.New( + requestcontext.WithRequestId(), + requestcontext.WithClientIP(conf.HTTPServer.RequestIP), + )). + Use(requestlogger.New(conf.HTTPServer.Logger)). Use(fiberrecover.New(fiberrecover.Config{ EnableStackTrace: true, StackTraceHandler: func(c *fiber.Ctx, e interface{}) { diff --git a/common/http.go b/common/http.go new file mode 100644 index 0000000..d5055b5 --- /dev/null +++ b/common/http.go @@ -0,0 +1,6 @@ +package common + +type HttpResponse[T any] struct { + Error *string `json:"error"` + Result *T `json:"result,omitempty"` +} diff --git a/config.example.yaml b/config.example.yaml index 1454b1c..f5454df 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -23,6 +23,14 @@ reporting: # HTTP server configuration options. http_server: port: 8080 # Port to run the HTTP server on for modules with HTTP API handlers. + logger: + disable: false # disable logger if logger level is `INFO` + request_header: false + request_query: false + requestip: # Client IP extraction configuration options. This is unnecessary if you don't care about the real client IP or if you're not using a reverse proxy. + trusted_proxies_ip: # Cloudflare, GCP Public LB. See: server/internal/middleware/requestcontext/PROXY-IP.md + trusted_proxies_header: # X-Real-IP, CF-Connecting-IP + enable_reject_malformed_request: false # return 403 if request is malformed (invalid IP) # Meta-protocol modules configuration options. modules: diff --git a/core/constants/constants.go b/core/constants/constants.go index 0526759..15c887f 100644 --- a/core/constants/constants.go +++ b/core/constants/constants.go @@ -1,5 +1,5 @@ package constants const ( - Version = "v0.0.1" + Version = "v0.2.1" ) diff --git a/core/indexer/indexer.go b/core/indexer/indexer.go index 3521205..05dfff0 100644 --- a/core/indexer/indexer.go +++ b/core/indexer/indexer.go @@ -91,6 +91,10 @@ func (i *Indexer[T]) Run(ctx context.Context) (err error) { select { case <-i.quit: logger.InfoContext(ctx, "Got quit signal, stopping indexer") + if err := i.Processor.Shutdown(ctx); err != nil { + logger.ErrorContext(ctx, "Failed to shutdown processor", slogx.Error(err)) + return errors.Wrap(err, "processor shutdown failed") + } return nil case <-ctx.Done(): return nil @@ -204,9 +208,9 @@ func (i *Indexer[T]) process(ctx context.Context) (err error) { } // validate is input is continuous and no reorg - for i := 1; i < len(inputs); i++ { - header := inputs[i].BlockHeader() - prevHeader := inputs[i-1].BlockHeader() + prevHeader := i.currentBlock + for i, input := range inputs { + header := input.BlockHeader() if header.Height != prevHeader.Height+1 { return errors.Wrapf(errs.InternalError, "input is not continuous, input[%d] height: %d, input[%d] height: %d", i-1, prevHeader.Height, i, header.Height) } @@ -217,6 +221,7 @@ func (i *Indexer[T]) process(ctx context.Context) (err error) { // end current round return nil } + prevHeader = header } ctx = logger.WithContext(ctx, slog.Int("total_inputs", len(inputs))) diff --git a/core/indexer/interface.go b/core/indexer/interface.go index 761d25d..bb5a5dc 100644 --- a/core/indexer/interface.go +++ b/core/indexer/interface.go @@ -29,6 +29,9 @@ type Processor[T Input] interface { // VerifyStates verifies the states of the indexed data and the indexer // to ensure the last shutdown was graceful and no missing data. VerifyStates(ctx context.Context) error + + // Shutdown gracefully stops the processor. Database connections, network calls, leftover states, etc. should be closed and cleaned up here. + Shutdown(ctx context.Context) error } type IndexerWorker interface { diff --git a/go.mod b/go.mod index d10b2e6..e0e5c27 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/Cleverse/go-utilities/utils v0.0.0-20240119201306-d71eb577ef11 github.com/btcsuite/btcd v0.24.0 github.com/btcsuite/btcd/btcutil v1.1.5 + github.com/btcsuite/btcd/btcutil/psbt v1.1.9 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/cockroachdb/errors v1.11.1 github.com/gaze-network/uint128 v1.3.0 @@ -21,23 +22,24 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 github.com/valyala/fasthttp v1.51.0 go.uber.org/automaxprocs v1.5.3 - golang.org/x/sync v0.5.0 + golang.org/x/sync v0.7.0 ) require ( github.com/andybalholm/brotli v1.0.5 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.1.3 // indirect + github.com/bitonicnl/verify-signed-message v0.7.1 + github.com/btcsuite/btcd/btcec/v2 v2.3.3 // indirect github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f // indirect github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect github.com/cockroachdb/redact v1.1.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/decred/dcrd/crypto/blake256 v1.0.0 // indirect - github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/getsentry/sentry-go v0.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -45,6 +47,7 @@ require ( github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect + github.com/holiman/uint256 v1.2.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -75,10 +78,10 @@ require ( github.com/valyala/tcplisten v1.0.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.20.0 // indirect - golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/sys v0.17.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a97a063..8f49981 100644 --- a/go.sum +++ b/go.sum @@ -7,18 +7,23 @@ github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5 github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/bitonicnl/verify-signed-message v0.7.1 h1:1Qku9k9WgzobjqBY7tT3CLjWxtTJZxkYNhOV6QeCTjY= +github.com/bitonicnl/verify-signed-message v0.7.1/go.mod h1:PR60twfJIaHEo9Wb6eJBh8nBHEZIQQx8CvRwh0YmEPk= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= github.com/btcsuite/btcd v0.24.0 h1:gL3uHE/IaFj6fcZSu03SvqPMSx7s/dPzfpG/atRwWdo= github.com/btcsuite/btcd v0.24.0/go.mod h1:K4IDc1593s8jKXIF7yS7yCTSxrknB9z0STzc2j6XgE4= github.com/btcsuite/btcd/btcec/v2 v2.1.0/go.mod h1:2VzYrv4Gm4apmbVVsSq5bqf1Ec8v56E48Vt0Y/umPgA= -github.com/btcsuite/btcd/btcec/v2 v2.1.3 h1:xM/n3yIhHAhHy04z4i43C8p4ehixJZMsnrVJkgl+MTE= github.com/btcsuite/btcd/btcec/v2 v2.1.3/go.mod h1:ctjw4H1kknNJmRN4iP1R7bTQ+v3GJkZBd6mui8ZsAZE= +github.com/btcsuite/btcd/btcec/v2 v2.3.3 h1:6+iXlDKE8RMtKsvK0gshlXIuPbyWM/h84Ensb7o3sC0= +github.com/btcsuite/btcd/btcec/v2 v2.3.3/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= github.com/btcsuite/btcd/btcutil v1.0.0/go.mod h1:Uoxwv0pqYWhD//tfTiipkxNfdhG9UrLwaeswfjfdF0A= github.com/btcsuite/btcd/btcutil v1.1.0/go.mod h1:5OapHB7A2hBBWLm48mmw4MOHNJCcUBTwmWH/0Jn8VHE= github.com/btcsuite/btcd/btcutil v1.1.5 h1:+wER79R5670vs/ZusMTF1yTcRYE5GUsFbdjdisflzM8= github.com/btcsuite/btcd/btcutil v1.1.5/go.mod h1:PSZZ4UitpLBWzxGd5VGOrLnmOjtPP/a6HaFo12zMs00= +github.com/btcsuite/btcd/btcutil/psbt v1.1.9 h1:UmfOIiWMZcVMOLaN+lxbbLSuoINGS1WmK1TZNI0b4yk= +github.com/btcsuite/btcd/btcutil/psbt v1.1.9/go.mod h1:ehBEvU91lxSlXtA+zZz3iFYx7Yq9eqnKx4/kSrnsvMY= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.0/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 h1:59Kx4K6lzOW5w6nFlA0v5+lk/6sjybR934QNHSJZPTQ= @@ -50,10 +55,12 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/decred/dcrd/crypto/blake256 v1.0.0 h1:/8DMNYp9SGi5f0w7uCm6d6M4OU2rGFK09Y2A4Xv7EE0= github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= -github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= github.com/dhui/dktest v0.4.1 h1:/w+IWuDXVymg3IrRJCHHOkMK10m9aNVMOyD0X12YVTg= github.com/dhui/dktest v0.4.1/go.mod h1:DdOqcUpL7vgyP4GlF3X3w7HbSlz8cEQzwewPveYEQbA= @@ -97,8 +104,8 @@ github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEW github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= @@ -112,6 +119,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/holiman/uint256 v1.2.4 h1:jUc4Nk8fm9jZabQuqr2JzednajVmBpC+oiTiXZJEApU= +github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -221,8 +230,9 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= @@ -247,14 +257,14 @@ golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnf golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= -golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= -golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +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-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4= +golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -269,8 +279,8 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -283,19 +293,19 @@ golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= +golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/config/config.go b/internal/config/config.go index ad8edff..e5555b3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,8 @@ import ( runesconfig "github.com/gaze-network/indexer-network/modules/runes/config" "github.com/gaze-network/indexer-network/pkg/logger" "github.com/gaze-network/indexer-network/pkg/logger/slogx" + "github.com/gaze-network/indexer-network/pkg/middleware/requestcontext" + "github.com/gaze-network/indexer-network/pkg/middleware/requestlogger" "github.com/gaze-network/indexer-network/pkg/reportingclient" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -65,7 +67,9 @@ type Modules struct { } type HTTPServerConfig struct { - Port int `mapstructure:"port"` + Port int `mapstructure:"port"` + Logger requestlogger.Config `mapstructure:"logger"` + RequestIP requestcontext.WithClientIPConfig `mapstructure:"requestip"` } // Parse parse the configuration from environment variables diff --git a/modules/brc20/api/httphandler/get_balances_by_address.go b/modules/brc20/api/httphandler/get_balances_by_address.go new file mode 100644 index 0000000..73a97d6 --- /dev/null +++ b/modules/brc20/api/httphandler/get_balances_by_address.go @@ -0,0 +1,115 @@ +package httphandler + +import ( + "slices" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/pkg/btcutils" + "github.com/gaze-network/indexer-network/pkg/decimals" + "github.com/gofiber/fiber/v2" + "github.com/holiman/uint256" + "github.com/samber/lo" +) + +type getBalancesByAddressRequest struct { + Wallet string `params:"wallet"` + Id string `query:"id"` + BlockHeight uint64 `query:"blockHeight"` +} + +func (r getBalancesByAddressRequest) Validate() error { + var errList []error + if r.Wallet == "" { + errList = append(errList, errors.New("'wallet' is required")) + } + return errs.WithPublicMessage(errors.Join(errList...), "validation error") +} + +type balanceExtend struct { + Transferable *uint256.Int `json:"transferable"` + Available *uint256.Int `json:"available"` +} + +type balance struct { + Amount *uint256.Int `json:"amount"` + Id string `json:"id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Decimals uint16 `json:"decimals"` + Extend balanceExtend `json:"extend"` +} + +type getBalancesByAddressResult struct { + List []balance `json:"list"` + BlockHeight uint64 `json:"blockHeight"` +} + +type getBalancesByAddressResponse = common.HttpResponse[getBalancesByAddressResult] + +func (h *HttpHandler) GetBalancesByAddress(ctx *fiber.Ctx) (err error) { + var req getBalancesByAddressRequest + if err := ctx.ParamsParser(&req); err != nil { + return errors.WithStack(err) + } + if err := ctx.QueryParser(&req); err != nil { + return errors.WithStack(err) + } + if err := req.Validate(); err != nil { + return errors.WithStack(err) + } + + pkScript, err := btcutils.ToPkScript(h.network, req.Wallet) + if err != nil { + return errs.NewPublicError("unable to resolve pkscript from \"wallet\"") + } + + blockHeight := req.BlockHeight + if blockHeight == 0 { + blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext()) + if err != nil { + return errors.Wrap(err, "error during GetLatestBlock") + } + blockHeight = uint64(blockHeader.Height) + } + + balances, err := h.usecase.GetBalancesByPkScript(ctx.UserContext(), pkScript, blockHeight) + if err != nil { + return errors.Wrap(err, "error during GetBalancesByPkScript") + } + + ticks := lo.Keys(balances) + entries, err := h.usecase.GetTickEntryByTickBatch(ctx.UserContext(), ticks) + if err != nil { + return errors.Wrap(err, "error during GetTickEntryByTickBatch") + } + + balanceList := make([]balance, 0, len(balances)) + for id, b := range balances { + entry := entries[id] + balanceList = append(balanceList, balance{ + Amount: decimals.ToUint256(b.OverallBalance, entry.Decimals), + Id: id, + Name: entry.OriginalTick, + Symbol: entry.Tick, + Decimals: entry.Decimals, + Extend: balanceExtend{ + Transferable: decimals.ToUint256(b.OverallBalance.Sub(b.AvailableBalance), entry.Decimals), + Available: decimals.ToUint256(b.AvailableBalance, entry.Decimals), + }, + }) + } + slices.SortFunc(balanceList, func(i, j balance) int { + return j.Amount.Cmp(i.Amount) + }) + + resp := getBalancesByAddressResponse{ + Result: &getBalancesByAddressResult{ + BlockHeight: blockHeight, + List: balanceList, + }, + } + + return errors.WithStack(ctx.JSON(resp)) +} diff --git a/modules/brc20/api/httphandler/get_balances_by_address_batch.go b/modules/brc20/api/httphandler/get_balances_by_address_batch.go new file mode 100644 index 0000000..1bce8f0 --- /dev/null +++ b/modules/brc20/api/httphandler/get_balances_by_address_batch.go @@ -0,0 +1,125 @@ +package httphandler + +import ( + "context" + "slices" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/pkg/btcutils" + "github.com/gaze-network/indexer-network/pkg/decimals" + "github.com/gofiber/fiber/v2" + "github.com/samber/lo" + "golang.org/x/sync/errgroup" +) + +type getBalancesByAddressBatchRequest struct { + Queries []getBalancesByAddressRequest `json:"queries"` +} + +func (r getBalancesByAddressBatchRequest) Validate() error { + var errList []error + for _, query := range r.Queries { + if query.Wallet == "" { + errList = append(errList, errors.Errorf("queries[%d]: 'wallet' is required")) + } + } + return errs.WithPublicMessage(errors.Join(errList...), "validation error") +} + +type getBalancesByAddressBatchResult struct { + List []*getBalancesByAddressResult `json:"list"` +} + +type getBalancesByAddressBatchResponse = common.HttpResponse[getBalancesByAddressBatchResult] + +func (h *HttpHandler) GetBalancesByAddressBatch(ctx *fiber.Ctx) (err error) { + var req getBalancesByAddressBatchRequest + if err := ctx.BodyParser(&req); err != nil { + return errors.WithStack(err) + } + if err := req.Validate(); err != nil { + return errors.WithStack(err) + } + + var latestBlockHeight uint64 + blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext()) + if err != nil { + return errors.Wrap(err, "error during GetLatestBlock") + } + latestBlockHeight = uint64(blockHeader.Height) + + processQuery := func(ctx context.Context, query getBalancesByAddressRequest) (*getBalancesByAddressResult, error) { + pkScript, err := btcutils.ToPkScript(h.network, query.Wallet) + if err != nil { + return nil, errs.NewPublicError("unable to resolve pkscript from \"wallet\"") + } + + blockHeight := query.BlockHeight + if blockHeight == 0 { + blockHeight = latestBlockHeight + } + + balances, err := h.usecase.GetBalancesByPkScript(ctx, pkScript, blockHeight) + if err != nil { + return nil, errors.Wrap(err, "error during GetBalancesByPkScript") + } + + balanceRuneIds := lo.Keys(balances) + entries, err := h.usecase.GetTickEntryByTickBatch(ctx, balanceRuneIds) + if err != nil { + return nil, errors.Wrap(err, "error during GetTickEntryByTickBatch") + } + + balanceList := make([]balance, 0, len(balances)) + for id, b := range balances { + entry := entries[id] + balanceList = append(balanceList, balance{ + Amount: decimals.ToUint256(b.OverallBalance, entry.Decimals), + Id: id, + Name: entry.OriginalTick, + Symbol: entry.Tick, + Decimals: entry.Decimals, + Extend: balanceExtend{ + Transferable: decimals.ToUint256(b.OverallBalance.Sub(b.AvailableBalance), entry.Decimals), + Available: decimals.ToUint256(b.AvailableBalance, entry.Decimals), + }, + }) + } + slices.SortFunc(balanceList, func(i, j balance) int { + return j.Amount.Cmp(i.Amount) + }) + + return &getBalancesByAddressResult{ + BlockHeight: blockHeight, + List: balanceList, + }, nil + } + + results := make([]*getBalancesByAddressResult, len(req.Queries)) + eg, ectx := errgroup.WithContext(ctx.UserContext()) + for i, query := range req.Queries { + i := i + query := query + eg.Go(func() error { + result, err := processQuery(ectx, query) + if err != nil { + return errors.Wrapf(err, "error during processQuery for query %d", i) + } + results[i] = result + return nil + }) + } + if err := eg.Wait(); err != nil { + return errors.WithStack(err) + } + + resp := getBalancesByAddressBatchResponse{ + Result: &getBalancesByAddressBatchResult{ + List: results, + }, + } + + return errors.WithStack(ctx.JSON(resp)) +} diff --git a/modules/brc20/api/httphandler/get_current_block.go b/modules/brc20/api/httphandler/get_current_block.go new file mode 100644 index 0000000..37adcd0 --- /dev/null +++ b/modules/brc20/api/httphandler/get_current_block.go @@ -0,0 +1,49 @@ +package httphandler + +import ( + "github.com/Cleverse/go-utilities/utils" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/core/types" + "github.com/gofiber/fiber/v2" +) + +// TODO: use modules/brc20/constants.go +var startingBlockHeader = map[common.Network]types.BlockHeader{ + common.NetworkMainnet: { + Height: 767429, + Hash: *utils.Must(chainhash.NewHashFromStr("00000000000000000002b35aef66eb15cd2b232a800f75a2f25cedca4cfe52c4")), + }, + common.NetworkTestnet: { + Height: 2413342, + Hash: *utils.Must(chainhash.NewHashFromStr("00000000000022e97030b143af785de812f836dd0651b6ac2b7dd9e90dc9abf9")), + }, +} + +type getCurrentBlockResult struct { + Hash string `json:"hash"` + Height int64 `json:"height"` +} + +type getCurrentBlockResponse = common.HttpResponse[getCurrentBlockResult] + +func (h *HttpHandler) GetCurrentBlock(ctx *fiber.Ctx) (err error) { + blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext()) + if err != nil { + if !errors.Is(err, errs.NotFound) { + return errors.Wrap(err, "error during get latest block") + } + blockHeader = startingBlockHeader[h.network] + } + + resp := getCurrentBlockResponse{ + Result: &getCurrentBlockResult{ + Hash: blockHeader.Hash.String(), + Height: blockHeader.Height, + }, + } + + return errors.WithStack(ctx.JSON(resp)) +} diff --git a/modules/brc20/api/httphandler/get_holders.go b/modules/brc20/api/httphandler/get_holders.go new file mode 100644 index 0000000..a389b53 --- /dev/null +++ b/modules/brc20/api/httphandler/get_holders.go @@ -0,0 +1,107 @@ +package httphandler + +import ( + "encoding/hex" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/pkg/btcutils" + "github.com/gaze-network/indexer-network/pkg/decimals" + "github.com/gofiber/fiber/v2" + "github.com/holiman/uint256" +) + +type getHoldersRequest struct { + Id string `params:"id"` + BlockHeight uint64 `query:"blockHeight"` +} + +func (r getHoldersRequest) Validate() error { + var errList []error + return errs.WithPublicMessage(errors.Join(errList...), "validation error") +} + +type holdingBalanceExtend struct { + Transferable *uint256.Int `json:"transferable"` + Available *uint256.Int `json:"available"` +} + +type holdingBalance struct { + Address string `json:"address"` + PkScript string `json:"pkScript"` + Amount *uint256.Int `json:"amount"` + Percent float64 `json:"percent"` + Extend holdingBalanceExtend `json:"extend"` +} + +type getHoldersResult struct { + BlockHeight uint64 `json:"blockHeight"` + TotalSupply *uint256.Int `json:"totalSupply"` + MintedAmount *uint256.Int `json:"mintedAmount"` + Decimals uint16 `json:"decimals"` + List []holdingBalance `json:"list"` +} + +type getHoldersResponse = common.HttpResponse[getHoldersResult] + +func (h *HttpHandler) GetHolders(ctx *fiber.Ctx) (err error) { + var req getHoldersRequest + if err := ctx.ParamsParser(&req); err != nil { + return errors.WithStack(err) + } + if err := ctx.QueryParser(&req); err != nil { + return errors.WithStack(err) + } + if err := req.Validate(); err != nil { + return errors.WithStack(err) + } + + blockHeight := req.BlockHeight + if blockHeight == 0 { + blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext()) + if err != nil { + return errors.Wrap(err, "error during GetLatestBlock") + } + blockHeight = uint64(blockHeader.Height) + } + + entry, err := h.usecase.GetTickEntryByTickAndHeight(ctx.UserContext(), req.Id, blockHeight) + if err != nil { + return errors.Wrap(err, "error during GetTickEntryByTickAndHeight") + } + holdingBalances, err := h.usecase.GetBalancesByTick(ctx.UserContext(), req.Id, blockHeight) + if err != nil { + return errors.Wrap(err, "error during GetBalancesByTick") + } + + list := make([]holdingBalance, 0, len(holdingBalances)) + for _, balance := range holdingBalances { + address, err := btcutils.PkScriptToAddress(balance.PkScript, h.network) + if err != nil { + return errors.Wrapf(err, "can't convert pkscript(%x) to address", balance.PkScript) + } + percent := balance.OverallBalance.Div(entry.TotalSupply) + list = append(list, holdingBalance{ + Address: address, + PkScript: hex.EncodeToString(balance.PkScript), + Amount: decimals.ToUint256(balance.OverallBalance, entry.Decimals), + Percent: percent.InexactFloat64(), + Extend: holdingBalanceExtend{ + Transferable: decimals.ToUint256(balance.OverallBalance.Sub(balance.AvailableBalance), entry.Decimals), + Available: decimals.ToUint256(balance.AvailableBalance, entry.Decimals), + }, + }) + } + + resp := getHoldersResponse{ + Result: &getHoldersResult{ + BlockHeight: blockHeight, + TotalSupply: decimals.ToUint256(entry.TotalSupply, entry.Decimals), // TODO: convert to wei + MintedAmount: decimals.ToUint256(entry.MintedAmount, entry.Decimals), // TODO: convert to wei + List: list, + }, + } + + return errors.WithStack(ctx.JSON(resp)) +} diff --git a/modules/brc20/api/httphandler/get_token_info.go b/modules/brc20/api/httphandler/get_token_info.go new file mode 100644 index 0000000..a53319a --- /dev/null +++ b/modules/brc20/api/httphandler/get_token_info.go @@ -0,0 +1,150 @@ +package httphandler + +import ( + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/modules/brc20/internal/entity" + "github.com/gaze-network/indexer-network/pkg/btcutils" + "github.com/gaze-network/indexer-network/pkg/decimals" + "github.com/gofiber/fiber/v2" + "github.com/holiman/uint256" + "github.com/samber/lo" + "golang.org/x/sync/errgroup" +) + +type getTokenInfoRequest struct { + Id string `params:"id"` + BlockHeight uint64 `query:"blockHeight"` +} + +func (r getTokenInfoRequest) Validate() error { + var errList []error + return errs.WithPublicMessage(errors.Join(errList...), "validation error") +} + +type tokenInfoExtend struct { + DeployedBy string `json:"deployedBy"` + LimitPerMint *uint256.Int `json:"limitPerMint"` + DeployInscriptionId string `json:"deployInscriptionId"` + DeployInscriptionNumber int64 `json:"deployInscriptionNumber"` + InscriptionStartNumber int64 `json:"inscriptionStartNumber"` + InscriptionEndNumber int64 `json:"inscriptionEndNumber"` +} + +type getTokenInfoResult struct { + Id string `json:"id"` + Name string `json:"name"` + Symbol string `json:"symbol"` + TotalSupply *uint256.Int `json:"totalSupply"` + CirculatingSupply *uint256.Int `json:"circulatingSupply"` + MintedAmount *uint256.Int `json:"mintedAmount"` + BurnedAmount *uint256.Int `json:"burnedAmount"` + Decimals uint16 `json:"decimals"` + DeployedAt uint64 `json:"deployedAt"` + DeployedAtHeight uint64 `json:"deployedAtHeight"` + CompletedAt *uint64 `json:"completedAt"` + CompletedAtHeight *uint64 `json:"completedAtHeight"` + HoldersCount int `json:"holdersCount"` + Extend tokenInfoExtend `json:"extend"` +} + +type getTokenInfoResponse = common.HttpResponse[getTokenInfoResult] + +func (h *HttpHandler) GetTokenInfo(ctx *fiber.Ctx) (err error) { + var req getTokenInfoRequest + if err := ctx.ParamsParser(&req); err != nil { + return errors.WithStack(err) + } + if err := ctx.QueryParser(&req); err != nil { + return errors.WithStack(err) + } + if err := req.Validate(); err != nil { + return errors.WithStack(err) + } + + blockHeight := req.BlockHeight + if blockHeight == 0 { + blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext()) + if err != nil { + return errors.Wrap(err, "error during GetLatestBlock") + } + blockHeight = uint64(blockHeader.Height) + } + + group, groupctx := errgroup.WithContext(ctx.UserContext()) + var ( + entry *entity.TickEntry + firstInscriptionNumber, lastInscriptionNumber int64 + deployEvent *entity.EventDeploy + holdingBalances []*entity.Balance + ) + group.Go(func() error { + deployEvent, err = h.usecase.GetDeployEventByTick(groupctx, req.Id) + if err != nil { + return errors.Wrap(err, "error during GetDeployEventByTick") + } + return nil + }) + group.Go(func() error { + // TODO: at block height to parameter. + firstInscriptionNumber, lastInscriptionNumber, err = h.usecase.GetFirstLastInscriptionNumberByTick(groupctx, req.Id) + if err != nil { + return errors.Wrap(err, "error during GetFirstLastInscriptionNumberByTick") + } + return nil + }) + group.Go(func() error { + entry, err = h.usecase.GetTickEntryByTickAndHeight(groupctx, req.Id, blockHeight) + if err != nil { + return errors.Wrap(err, "error during GetTickEntryByTickAndHeight") + } + return nil + }) + group.Go(func() error { + balances, err := h.usecase.GetBalancesByTick(groupctx, req.Id, blockHeight) + if err != nil { + return errors.Wrap(err, "error during GetBalancesByRuneId") + } + holdingBalances = lo.Filter(balances, func(b *entity.Balance, _ int) bool { + return !b.OverallBalance.IsZero() + }) + return nil + }) + if err := group.Wait(); err != nil { + return errors.WithStack(err) + } + + address, err := btcutils.PkScriptToAddress(deployEvent.PkScript, h.network) + if err != nil { + return errors.Wrapf(err, `error during PkScriptToAddress for pkscript: %x, network: %v`, deployEvent.PkScript, h.network) + } + + resp := getTokenInfoResponse{ + Result: &getTokenInfoResult{ + Id: entry.Tick, + Name: entry.OriginalTick, + Symbol: entry.Tick, + TotalSupply: decimals.ToUint256(entry.TotalSupply, entry.Decimals), + CirculatingSupply: decimals.ToUint256(entry.MintedAmount.Sub(entry.BurnedAmount), entry.Decimals), + MintedAmount: decimals.ToUint256(entry.MintedAmount, entry.Decimals), + BurnedAmount: decimals.ToUint256(entry.BurnedAmount, entry.Decimals), + Decimals: entry.Decimals, + DeployedAt: uint64(entry.DeployedAt.Unix()), + DeployedAtHeight: entry.DeployedAtHeight, + CompletedAt: lo.Ternary(entry.CompletedAt.IsZero(), nil, lo.ToPtr(uint64(entry.CompletedAt.Unix()))), + CompletedAtHeight: lo.Ternary(entry.CompletedAtHeight == 0, nil, lo.ToPtr(entry.CompletedAtHeight)), + HoldersCount: len(holdingBalances), + Extend: tokenInfoExtend{ + DeployedBy: address, + LimitPerMint: decimals.ToUint256(entry.LimitPerMint, entry.Decimals), + DeployInscriptionId: deployEvent.InscriptionId.String(), + DeployInscriptionNumber: deployEvent.InscriptionNumber, + InscriptionStartNumber: lo.Ternary(firstInscriptionNumber < 0, deployEvent.InscriptionNumber, firstInscriptionNumber), + InscriptionEndNumber: lo.Ternary(lastInscriptionNumber < 0, deployEvent.InscriptionNumber, lastInscriptionNumber), + }, + }, + } + + return errors.WithStack(ctx.JSON(resp)) +} diff --git a/modules/brc20/api/httphandler/get_transactions.go b/modules/brc20/api/httphandler/get_transactions.go new file mode 100644 index 0000000..24f8d8c --- /dev/null +++ b/modules/brc20/api/httphandler/get_transactions.go @@ -0,0 +1,454 @@ +package httphandler + +import ( + "bytes" + "cmp" + "encoding/hex" + "slices" + "strings" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/modules/brc20/internal/entity" + "github.com/gaze-network/indexer-network/pkg/btcutils" + "github.com/gaze-network/indexer-network/pkg/decimals" + "github.com/gofiber/fiber/v2" + "github.com/holiman/uint256" + "github.com/samber/lo" + "github.com/shopspring/decimal" + "golang.org/x/sync/errgroup" +) + +var ops = []string{"inscribe-deploy", "inscribe-mint", "inscribe-transfer", "transfer-transfer"} + +type getTransactionsRequest struct { + Wallet string `query:"wallet"` + Id string `query:"id"` + BlockHeight uint64 `query:"blockHeight"` + Op string `query:"op"` +} + +func (r getTransactionsRequest) Validate() error { + var errList []error + if r.Op != "" { + if !lo.Contains(ops, r.Op) { + errList = append(errList, errors.Errorf("invalid 'op' value: %s, supported values: %s", r.Op, strings.Join(ops, ", "))) + } + } + return errs.WithPublicMessage(errors.Join(errList...), "validation error") +} + +type txOpDeployArg struct { + Op string `json:"op"` + Tick string `json:"tick"` + Max decimal.Decimal `json:"max"` + Lim decimal.Decimal `json:"lim"` + Dec uint16 `json:"dec"` + SelfMint bool `json:"self_mint"` +} + +type txOpGeneralArg struct { + Op string `json:"op"` + Tick string `json:"tick"` + Amount decimal.Decimal `json:"amt"` +} + +type txOperation[T any] struct { + InscriptionId string `json:"inscriptionId"` + InscriptionNumber int64 `json:"inscriptionNumber"` + Op string `json:"op"` + Args T `json:"args"` +} + +type txOperationsDeploy struct { + txOperation[txOpDeployArg] + Address string `json:"address"` +} + +type txOperationsMint struct { + txOperation[txOpGeneralArg] + Address string `json:"address"` +} + +type txOperationsInscribeTransfer struct { + txOperation[txOpGeneralArg] + Address string `json:"address"` + OutputIndex uint32 `json:"outputIndex"` + Sats uint64 `json:"sats"` +} + +type txOperationsTransferTransfer struct { + txOperation[txOpGeneralArg] + FromAddress string `json:"fromAddress"` + ToAddress string `json:"toAddress"` +} + +type transactionExtend struct { + Operations []any `json:"operations"` +} + +type amountWithDecimal struct { + Amount *uint256.Int `json:"amount"` + Decimals uint16 `json:"decimals"` +} + +type txInputOutput struct { + PkScript string `json:"pkScript"` + Address string `json:"address"` + Id string `json:"id"` + Amount *uint256.Int `json:"amount"` + Decimals uint16 `json:"decimals"` + Index uint32 `json:"index"` +} + +type transaction struct { + TxHash chainhash.Hash `json:"txHash"` + BlockHeight uint64 `json:"blockHeight"` + Index uint32 `json:"index"` + Timestamp int64 `json:"timestamp"` + Inputs []txInputOutput `json:"inputs"` + Outputs []txInputOutput `json:"outputs"` + Mints map[string]amountWithDecimal `json:"mints"` + Burns map[string]amountWithDecimal `json:"burns"` + Extend transactionExtend `json:"extend"` +} + +type getTransactionsResult struct { + List []transaction `json:"list"` +} + +type getTransactionsResponse = common.HttpResponse[getTransactionsResult] + +func (h *HttpHandler) GetTransactions(ctx *fiber.Ctx) (err error) { + var req getTransactionsRequest + if err := ctx.QueryParser(&req); err != nil { + return errors.WithStack(err) + } + if err := req.Validate(); err != nil { + return errors.WithStack(err) + } + + var pkScript []byte + if req.Wallet != "" { + pkScript, err = btcutils.ToPkScript(h.network, req.Wallet) + if err != nil { + return errs.NewPublicError("unable to resolve pkscript from \"wallet\"") + } + } + + blockHeight := req.BlockHeight + // set blockHeight to the latest block height blockHeight, pkScript, and runeId are not provided + if blockHeight == 0 && pkScript == nil && req.Id == "" { + blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext()) + if err != nil { + return errors.Wrap(err, "error during GetLatestBlock") + } + blockHeight = uint64(blockHeader.Height) + } + + var ( + deployEvents []*entity.EventDeploy + mintEvents []*entity.EventMint + transferTransferEvents []*entity.EventTransferTransfer + inscribeTransferEvents []*entity.EventInscribeTransfer + ) + + group, groupctx := errgroup.WithContext(ctx.UserContext()) + + if req.Op == "" || req.Op == "inscribe-deploy" { + group.Go(func() error { + events, err := h.usecase.GetDeployEvents(groupctx, pkScript, req.Id, blockHeight) + deployEvents = events + return errors.Wrap(err, "error during get inscribe-deploy events") + }) + } + if req.Op == "" || req.Op == "inscribe-mint" { + group.Go(func() error { + events, err := h.usecase.GetMintEvents(groupctx, pkScript, req.Id, blockHeight) + mintEvents = events + return errors.Wrap(err, "error during get inscribe-mint events") + }) + } + if req.Op == "" || req.Op == "transfer-transfer" { + group.Go(func() error { + events, err := h.usecase.GetTransferTransferEvents(groupctx, pkScript, req.Id, blockHeight) + transferTransferEvents = events + return errors.Wrap(err, "error during get transfer-transfer events") + }) + } + if req.Op == "" || req.Op == "inscribe-transfer" { + group.Go(func() error { + events, err := h.usecase.GetInscribeTransferEvents(groupctx, pkScript, req.Id, blockHeight) + inscribeTransferEvents = events + return errors.Wrap(err, "error during get inscribe-transfer events") + }) + } + if err := group.Wait(); err != nil { + return errors.WithStack(err) + } + + allTicks := make([]string, 0, len(deployEvents)+len(mintEvents)+len(transferTransferEvents)+len(inscribeTransferEvents)) + allTicks = append(allTicks, lo.Map(deployEvents, func(event *entity.EventDeploy, _ int) string { return event.Tick })...) + allTicks = append(allTicks, lo.Map(mintEvents, func(event *entity.EventMint, _ int) string { return event.Tick })...) + allTicks = append(allTicks, lo.Map(transferTransferEvents, func(event *entity.EventTransferTransfer, _ int) string { return event.Tick })...) + allTicks = append(allTicks, lo.Map(inscribeTransferEvents, func(event *entity.EventInscribeTransfer, _ int) string { return event.Tick })...) + entries, err := h.usecase.GetTickEntryByTickBatch(ctx.UserContext(), lo.Uniq(allTicks)) + if err != nil { + return errors.Wrap(err, "error during GetTickEntryByTickBatch") + } + + rawTxList := make([]transaction, 0, len(deployEvents)+len(mintEvents)+len(transferTransferEvents)+len(inscribeTransferEvents)) + + // Deploy events + for _, event := range deployEvents { + address, err := btcutils.PkScriptToAddress(event.PkScript, h.network) + if err != nil { + return errors.Wrapf(err, `error during PkScriptToAddress for deploy event %s, pkscript: %x, network: %v`, event.TxHash, event.PkScript, h.network) + } + respTx := transaction{ + TxHash: event.TxHash, + BlockHeight: event.BlockHeight, + Index: event.TxIndex, + Timestamp: event.Timestamp.Unix(), + Mints: map[string]amountWithDecimal{}, + Burns: map[string]amountWithDecimal{}, + Extend: transactionExtend{ + Operations: []any{ + txOperationsDeploy{ + txOperation: txOperation[txOpDeployArg]{ + InscriptionId: event.InscriptionId.String(), + InscriptionNumber: event.InscriptionNumber, + Op: "deploy", + Args: txOpDeployArg{ + Op: "deploy", + Tick: event.Tick, + Max: event.TotalSupply, + Lim: event.LimitPerMint, + Dec: event.Decimals, + SelfMint: event.IsSelfMint, + }, + }, + Address: address, + }, + }, + }, + } + rawTxList = append(rawTxList, respTx) + } + + // Mint events + for _, event := range mintEvents { + entry := entries[event.Tick] + address, err := btcutils.PkScriptToAddress(event.PkScript, h.network) + if err != nil { + return errors.Wrapf(err, `error during PkScriptToAddress for deploy event %s, pkscript: %x, network: %v`, event.TxHash, event.PkScript, h.network) + } + amtWei := decimals.ToUint256(event.Amount, entry.Decimals) + respTx := transaction{ + TxHash: event.TxHash, + BlockHeight: event.BlockHeight, + Index: event.TxIndex, + Timestamp: event.Timestamp.Unix(), + Outputs: []txInputOutput{ + { + PkScript: hex.EncodeToString(event.PkScript), + Address: address, + Id: event.Tick, + Amount: amtWei, + Decimals: entry.Decimals, + Index: event.TxIndex, + }, + }, + Mints: map[string]amountWithDecimal{ + event.Tick: { + Amount: amtWei, + Decimals: entry.Decimals, + }, + }, + Extend: transactionExtend{ + Operations: []any{ + txOperationsMint{ + txOperation: txOperation[txOpGeneralArg]{ + InscriptionId: event.InscriptionId.String(), + InscriptionNumber: event.InscriptionNumber, + Op: "inscribe-mint", + Args: txOpGeneralArg{ + Op: "inscribe-mint", + Tick: event.Tick, + Amount: event.Amount, + }, + }, + Address: address, + }, + }, + }, + } + rawTxList = append(rawTxList, respTx) + } + + // Inscribe Transfer events + for _, event := range inscribeTransferEvents { + address, err := btcutils.PkScriptToAddress(event.PkScript, h.network) + if err != nil { + return errors.Wrapf(err, `error during PkScriptToAddress for deploy event %s, pkscript: %x, network: %v`, event.TxHash, event.PkScript, h.network) + } + respTx := transaction{ + TxHash: event.TxHash, + BlockHeight: event.BlockHeight, + Index: event.TxIndex, + Timestamp: event.Timestamp.Unix(), + Mints: map[string]amountWithDecimal{}, + Burns: map[string]amountWithDecimal{}, + Extend: transactionExtend{ + Operations: []any{ + txOperationsInscribeTransfer{ + txOperation: txOperation[txOpGeneralArg]{ + InscriptionId: event.InscriptionId.String(), + InscriptionNumber: event.InscriptionNumber, + Op: "inscribe-transfer", + Args: txOpGeneralArg{ + Op: "inscribe-transfer", + Tick: event.Tick, + Amount: event.Amount, + }, + }, + Address: address, + OutputIndex: event.SatPoint.OutPoint.Index, + Sats: event.SatsAmount, + }, + }, + }, + } + rawTxList = append(rawTxList, respTx) + } + + // Transfer Transfer events + for _, event := range transferTransferEvents { + entry := entries[event.Tick] + amntWei := decimals.ToUint256(event.Amount, entry.Decimals) + fromAddress, err := btcutils.PkScriptToAddress(event.FromPkScript, h.network) + if err != nil { + return errors.Wrapf(err, `error during PkScriptToAddress for deploy event %s, pkscript: %x, network: %v`, event.TxHash, event.FromPkScript, h.network) + } + toAddress := "" + if len(event.ToPkScript) > 0 && !bytes.Equal(event.ToPkScript, []byte{0x6a}) { + toAddress, err = btcutils.PkScriptToAddress(event.ToPkScript, h.network) + if err != nil { + return errors.Wrapf(err, `error during PkScriptToAddress for deploy event %s, pkscript: %x, network: %v`, event.TxHash, event.FromPkScript, h.network) + } + } + + // if toAddress is empty, it's a burn. + burns := map[string]amountWithDecimal{} + if len(toAddress) == 0 { + burns[event.Tick] = amountWithDecimal{ + Amount: amntWei, + Decimals: entry.Decimals, + } + } + + respTx := transaction{ + TxHash: event.TxHash, + BlockHeight: event.BlockHeight, + Index: event.TxIndex, + Timestamp: event.Timestamp.Unix(), + Inputs: []txInputOutput{ + { + PkScript: hex.EncodeToString(event.FromPkScript), + Address: fromAddress, + Id: event.Tick, + Amount: amntWei, + Decimals: entry.Decimals, + Index: event.ToOutputIndex, + }, + }, + Outputs: []txInputOutput{ + { + PkScript: hex.EncodeToString(event.ToPkScript), + Address: fromAddress, + Id: event.Tick, + Amount: amntWei, + Decimals: entry.Decimals, + Index: event.ToOutputIndex, + }, + }, + Mints: map[string]amountWithDecimal{}, + Burns: burns, + Extend: transactionExtend{ + Operations: []any{ + txOperationsTransferTransfer{ + txOperation: txOperation[txOpGeneralArg]{ + InscriptionId: event.InscriptionId.String(), + InscriptionNumber: event.InscriptionNumber, + Op: "transfer-transfer", + Args: txOpGeneralArg{ + Op: "transfer-transfer", + Tick: event.Tick, + Amount: event.Amount, + }, + }, + FromAddress: fromAddress, + ToAddress: toAddress, + }, + }, + }, + } + rawTxList = append(rawTxList, respTx) + } + + // merge brc-20 tx events that have the same tx hash + txList := make([]transaction, 0, len(rawTxList)) + groupedTxs := lo.GroupBy(rawTxList, func(tx transaction) chainhash.Hash { return tx.TxHash }) + for _, txs := range groupedTxs { + tx := txs[0] + if tx.Mints == nil { + tx.Mints = map[string]amountWithDecimal{} + } + if tx.Burns == nil { + tx.Burns = map[string]amountWithDecimal{} + } + for _, tx2 := range txs[1:] { + tx.Inputs = append(tx.Inputs, tx2.Inputs...) + tx.Outputs = append(tx.Outputs, tx2.Outputs...) + if len(tx2.Mints) > 0 { + return errors.Wrap(errs.InvalidState, "transaction can't have multiple mints") + } + for tick, tx2Ammt := range tx2.Burns { + if txAmmt, ok := tx.Burns[tick]; ok { + tx.Burns[tick] = amountWithDecimal{ + Amount: new(uint256.Int).Add(txAmmt.Amount, tx2Ammt.Amount), + Decimals: txAmmt.Decimals, + } + } else { + tx.Burns[tick] = tx2Ammt + } + } + tx.Extend.Operations = append(tx.Extend.Operations, tx2.Extend.Operations...) + } + slices.SortFunc(tx.Inputs, func(i, j txInputOutput) int { + return cmp.Compare(i.Index, j.Index) + }) + slices.SortFunc(tx.Outputs, func(i, j txInputOutput) int { + return cmp.Compare(i.Index, j.Index) + }) + txList = append(txList, tx) + } + + // sort by block height ASC, then index ASC + slices.SortFunc(txList, func(t1, t2 transaction) int { + if t1.BlockHeight != t2.BlockHeight { + return int(t1.BlockHeight - t2.BlockHeight) + } + return int(t1.Index - t2.Index) + }) + + resp := getTransactionsResponse{ + Result: &getTransactionsResult{ + List: txList, + }, + } + + return errors.WithStack(ctx.JSON(resp)) +} diff --git a/modules/brc20/api/httphandler/get_utxos_by_address.go b/modules/brc20/api/httphandler/get_utxos_by_address.go new file mode 100644 index 0000000..80ccd4f --- /dev/null +++ b/modules/brc20/api/httphandler/get_utxos_by_address.go @@ -0,0 +1,136 @@ +package httphandler + +import ( + "strings" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/modules/brc20/internal/entity" + "github.com/gaze-network/indexer-network/pkg/btcutils" + "github.com/gaze-network/indexer-network/pkg/decimals" + "github.com/gofiber/fiber/v2" + "github.com/holiman/uint256" + "github.com/samber/lo" +) + +type getUTXOsByAddressRequest struct { + Wallet string `params:"wallet"` + Id string `query:"id"` + BlockHeight uint64 `query:"blockHeight"` +} + +func (r getUTXOsByAddressRequest) Validate() error { + var errList []error + if r.Wallet == "" { + errList = append(errList, errors.New("'wallet' is required")) + } + return errs.WithPublicMessage(errors.Join(errList...), "validation error") +} + +type transferableInscription struct { + Ticker string `json:"ticker"` + Amount *uint256.Int `json:"amount"` + Decimals uint16 `json:"decimals"` +} + +type utxoExtend struct { + TransferableInscriptions []transferableInscription `json:"transferableInscriptions"` +} + +type utxo struct { + TxHash chainhash.Hash `json:"txHash"` + OutputIndex uint32 `json:"outputIndex"` + Extend utxoExtend `json:"extend"` +} + +type getUTXOsByAddressResult struct { + List []utxo `json:"list"` + BlockHeight uint64 `json:"blockHeight"` +} + +type getUTXOsByAddressResponse = common.HttpResponse[getUTXOsByAddressResult] + +func (h *HttpHandler) GetUTXOsByAddress(ctx *fiber.Ctx) (err error) { + var req getUTXOsByAddressRequest + if err := ctx.ParamsParser(&req); err != nil { + return errors.WithStack(err) + } + if err := ctx.QueryParser(&req); err != nil { + return errors.WithStack(err) + } + if err := req.Validate(); err != nil { + return errors.WithStack(err) + } + + pkScript, err := btcutils.ToPkScript(h.network, req.Wallet) + if err != nil { + return errs.NewPublicError("unable to resolve pkscript from \"wallet\"") + } + + blockHeight := req.BlockHeight + if blockHeight == 0 { + blockHeader, err := h.usecase.GetLatestBlock(ctx.UserContext()) + if err != nil { + return errors.Wrap(err, "error during GetLatestBlock") + } + blockHeight = uint64(blockHeader.Height) + } + + transferables, err := h.usecase.GetTransferableTransfersByPkScript(ctx.UserContext(), pkScript, blockHeight) + if err != nil { + return errors.Wrap(err, "error during GetTransferableTransfersByPkScript") + } + + transferableTicks := lo.Map(transferables, func(src *entity.EventInscribeTransfer, _ int) string { return src.Tick }) + entries, err := h.usecase.GetTickEntryByTickBatch(ctx.UserContext(), transferableTicks) + if err != nil { + return errors.Wrap(err, "error during GetTickEntryByTickBatch") + } + + groupedtransferableTi := lo.GroupBy(transferables, func(src *entity.EventInscribeTransfer) wire.OutPoint { return src.SatPoint.OutPoint }) + utxoList := make([]utxo, 0, len(groupedtransferableTi)) + for outPoint, transferables := range groupedtransferableTi { + transferableInscriptions := make([]transferableInscription, 0, len(transferables)) + for _, transferable := range transferables { + entry := entries[transferable.Tick] + transferableInscriptions = append(transferableInscriptions, transferableInscription{ + Ticker: transferable.Tick, + Amount: decimals.ToUint256(transferable.Amount, entry.Decimals), + Decimals: entry.Decimals, + }) + } + + utxoList = append(utxoList, utxo{ + TxHash: outPoint.Hash, + OutputIndex: outPoint.Index, + Extend: utxoExtend{ + TransferableInscriptions: transferableInscriptions, + }, + }) + } + + // TODO: filter tickers in pg query + // filter by req.Id if exists + { + utxoList = lo.Filter(utxoList, func(u utxo, _ int) bool { + for _, transferableInscriptions := range u.Extend.TransferableInscriptions { + if ok := strings.EqualFold(req.Id, transferableInscriptions.Ticker); ok { + return ok + } + } + return false + }) + } + + resp := getUTXOsByAddressResponse{ + Result: &getUTXOsByAddressResult{ + BlockHeight: blockHeight, + List: utxoList, + }, + } + + return errors.WithStack(ctx.JSON(resp)) +} diff --git a/modules/brc20/api/httphandler/httphandler.go b/modules/brc20/api/httphandler/httphandler.go new file mode 100644 index 0000000..77df416 --- /dev/null +++ b/modules/brc20/api/httphandler/httphandler.go @@ -0,0 +1,18 @@ +package httphandler + +import ( + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/modules/brc20/internal/usecase" +) + +type HttpHandler struct { + usecase *usecase.Usecase + network common.Network +} + +func New(network common.Network, usecase *usecase.Usecase) *HttpHandler { + return &HttpHandler{ + network: network, + usecase: usecase, + } +} diff --git a/modules/brc20/api/httphandler/routes.go b/modules/brc20/api/httphandler/routes.go new file mode 100644 index 0000000..8301705 --- /dev/null +++ b/modules/brc20/api/httphandler/routes.go @@ -0,0 +1,19 @@ +package httphandler + +import ( + "github.com/gofiber/fiber/v2" +) + +func (h *HttpHandler) Mount(router fiber.Router) error { + r := router.Group("/v2/brc20") + + r.Post("/balances/wallet/batch", h.GetBalancesByAddressBatch) + r.Get("/balances/wallet/:wallet", h.GetBalancesByAddress) + r.Get("/transactions", h.GetTransactions) + r.Get("/holders/:id", h.GetHolders) + r.Get("/info/:id", h.GetTokenInfo) + r.Get("/utxos/wallet/:wallet", h.GetUTXOsByAddress) + r.Get("/block", h.GetCurrentBlock) + + return nil +} diff --git a/modules/brc20/brc20.go b/modules/brc20/brc20.go index cc08d13..99bee5b 100644 --- a/modules/brc20/brc20.go +++ b/modules/brc20/brc20.go @@ -12,10 +12,15 @@ import ( "github.com/gaze-network/indexer-network/core/types" "github.com/gaze-network/indexer-network/internal/config" "github.com/gaze-network/indexer-network/internal/postgres" + "github.com/gaze-network/indexer-network/modules/brc20/api/httphandler" "github.com/gaze-network/indexer-network/modules/brc20/internal/datagateway" brc20postgres "github.com/gaze-network/indexer-network/modules/brc20/internal/repository/postgres" + "github.com/gaze-network/indexer-network/modules/brc20/internal/usecase" "github.com/gaze-network/indexer-network/pkg/btcclient" + "github.com/gaze-network/indexer-network/pkg/logger" + "github.com/gofiber/fiber/v2" "github.com/samber/do/v2" + "github.com/samber/lo" ) func New(injector do.Injector) (indexer.IndexerWorker, error) { @@ -66,6 +71,23 @@ func New(injector do.Injector) (indexer.IndexerWorker, error) { return nil, errors.WithStack(err) } + // Mount API + apiHandlers := lo.Uniq(conf.Modules.BRC20.APIHandlers) + for _, handler := range apiHandlers { + switch handler { // TODO: support more handlers (e.g. gRPC) + case "http": + httpServer := do.MustInvoke[*fiber.App](injector) + uc := usecase.New(brc20Dg, bitcoinClient) + httpHandler := httphandler.New(conf.Network, uc) + if err := httpHandler.Mount(httpServer); err != nil { + return nil, errors.Wrap(err, "can't mount API") + } + logger.InfoContext(ctx, "Mounted HTTP handler") + default: + return nil, errors.Wrapf(errs.Unsupported, "%q API handler is not supported", handler) + } + } + indexer := indexer.New(processor, bitcoinDatasource) return indexer, nil } diff --git a/modules/brc20/database/postgresql/migrations/000001_initialize_tables.up.sql b/modules/brc20/database/postgresql/migrations/000001_initialize_tables.up.sql index 59d6afb..c251caf 100644 --- a/modules/brc20/database/postgresql/migrations/000001_initialize_tables.up.sql +++ b/modules/brc20/database/postgresql/migrations/000001_initialize_tables.up.sql @@ -134,7 +134,7 @@ CREATE TABLE IF NOT EXISTS "brc20_balances" ( "pkscript" TEXT NOT NULL, "block_height" INT NOT NULL, "tick" TEXT NOT NULL, - "overall_balance" DECIMAL NOT NULL, + "overall_balance" DECIMAL NOT NULL, -- overall balance = available_balance + transferable_balance "available_balance" DECIMAL NOT NULL, PRIMARY KEY ("pkscript", "tick", "block_height") ); diff --git a/modules/brc20/database/postgresql/queries/data.sql b/modules/brc20/database/postgresql/queries/data.sql index af39961..403777a 100644 --- a/modules/brc20/database/postgresql/queries/data.sql +++ b/modules/brc20/database/postgresql/queries/data.sql @@ -1,3 +1,121 @@ +-- name: GetTransferableTransfersByPkScript :many +SELECT * +FROM "brc20_event_inscribe_transfers" +WHERE + pkscript = $1 + AND "brc20_event_inscribe_transfers"."block_height" <= $2 + AND NOT EXISTS ( + SELECT NULL + FROM "brc20_event_transfer_transfers" + WHERE "brc20_event_transfer_transfers"."inscription_id" = "brc20_event_inscribe_transfers"."inscription_id" + ) +ORDER BY "brc20_event_inscribe_transfers"."block_height" DESC; + +-- name: GetBalancesByPkScript :many +WITH balances AS ( + SELECT DISTINCT ON (tick) * FROM brc20_balances WHERE pkscript = $1 AND block_height <= $2 ORDER BY tick, overall_balance DESC +) +SELECT * FROM balances WHERE overall_balance > 0; + +-- name: GetBalancesByTick :many +WITH balances AS ( + SELECT DISTINCT ON (pkscript) * FROM brc20_balances WHERE tick = $1 AND block_height <= $2 ORDER BY pkscript, block_height DESC +) +SELECT * FROM balances WHERE overall_balance > 0; + +-- name: GetDeployEventByTick :one +SELECT * FROM brc20_event_deploys WHERE tick = $1; + +-- name: GetFirstLastInscriptionNumberByTick :one +SELECT + COALESCE(MIN("inscription_number"), -1)::BIGINT AS "first_inscription_number", + COALESCE(MAX("inscription_number"), -1)::BIGINT AS "last_inscription_number" +FROM ( + SELECT inscription_number FROM "brc20_event_mints" WHERE "brc20_event_mints"."tick" = $1 + UNION ALL + SELECT inscription_number FROM "brc20_event_inscribe_transfers" WHERE "brc20_event_inscribe_transfers"."tick" = $1 + UNION ALL + SELECT inscription_number FROM "brc20_event_transfer_transfers" WHERE "brc20_event_transfer_transfers"."tick" = $1 +) as events; +-- WITH +-- "first_mint" AS (SELECT "inscription_number" FROM "brc20_event_mints" WHERE "brc20_event_mints".tick = $1 ORDER BY "id" ASC LIMIT 1), +-- "latest_mint" AS (SELECT "inscription_number" FROM "brc20_event_mints" WHERE "brc20_event_mints".tick = $1 ORDER BY "id" DESC LIMIT 1), +-- "first_inscribe_transfer" AS (SELECT "inscription_number" FROM "brc20_event_inscribe_transfers" WHERE "brc20_event_inscribe_transfers".tick = $1 ORDER BY "id" ASC LIMIT 1), +-- "latest_inscribe_transfer" AS (SELECT "inscription_number" FROM "brc20_event_inscribe_transfers" WHERE "brc20_event_inscribe_transfers".tick = $1 ORDER BY "id" DESC LIMIT 1) +-- SELECT +-- COALESCE( +-- LEAST( +-- (SELECT "inscription_number" FROM "first_mint"), +-- (SELECT "inscription_number" FROM "first_inscribe_transfer") +-- ), +-- -1 +-- ) AS "first_inscription_number", +-- COALESCE( +-- GREATEST( +-- (SELECT "inscription_number" FROM "latest_mint"), +-- (SELECT "inscription_number" FROM "latest_inscribe_transfer") +-- ), +-- -1 +-- ) AS "last_inscription_number"; + +-- name: GetTickEntriesByTicksAndHeight :many +WITH "states" AS ( + -- select latest state + SELECT DISTINCT ON ("tick") * FROM "brc20_tick_entry_states" WHERE "tick" = ANY(@ticks::text[]) AND block_height <= @height ORDER BY "tick", "block_height" DESC +) +SELECT * FROM "brc20_tick_entries" + LEFT JOIN "states" ON "brc20_tick_entries"."tick" = "states"."tick" + WHERE "brc20_tick_entries"."tick" = ANY(@ticks::text[]) AND deployed_at_height <= @height;; + +-- name: GetDeployEvents :many +SELECT * FROM "brc20_event_deploys" +WHERE ( + @filter_pk_script::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter + OR pkscript = @pk_script + ) AND ( + @filter_ticker::BOOLEAN = FALSE -- if @filter_ticker is TRUE, apply ticker filter + OR tick = @ticker + ) AND ( + @block_height::INT = 0 OR block_height = @block_height::INT -- if @block_height > 0, apply block_height filter + ); + +-- name: GetMintEvents :many +SELECT * FROM "brc20_event_mints" +WHERE ( + @filter_pk_script::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter + OR pkscript = @pk_script + ) AND ( + @filter_ticker::BOOLEAN = FALSE -- if @filter_ticker is TRUE, apply ticker filter + OR tick = @ticker + ) AND ( + @block_height::INT = 0 OR block_height = @block_height::INT -- if @block_height > 0, apply block_height filter + ); + +-- name: GetInscribeTransferEvents :many +SELECT * FROM "brc20_event_inscribe_transfers" +WHERE ( + @filter_pk_script::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter + OR pkscript = @pk_script + ) AND ( + @filter_ticker::BOOLEAN = FALSE -- if @filter_ticker is TRUE, apply ticker filter + OR tick = @ticker + ) AND ( + @block_height::INT = 0 OR block_height = @block_height::INT -- if @block_height > 0, apply block_height filter + ); + +-- name: GetTransferTransferEvents :many +SELECT * FROM "brc20_event_transfer_transfers" +WHERE ( + @filter_pk_script::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter + OR from_pkscript = @pk_script + OR to_pkscript = @pk_script + ) AND ( + @filter_ticker::BOOLEAN = FALSE -- if @filter_ticker is TRUE, apply ticker filter + OR tick = @ticker + ) AND ( + @block_height::INT = 0 OR block_height = @block_height::INT -- if @block_height > 0, apply block_height filter + ); + -- name: GetLatestIndexedBlock :one SELECT * FROM "brc20_indexed_blocks" ORDER BY "height" DESC LIMIT 1; diff --git a/modules/brc20/internal/datagateway/brc20.go b/modules/brc20/internal/datagateway/brc20.go index 010965a..cdbf2f7 100644 --- a/modules/brc20/internal/datagateway/brc20.go +++ b/modules/brc20/internal/datagateway/brc20.go @@ -34,6 +34,15 @@ type BRC20ReaderDataGateway interface { GetTickEntriesByTicks(ctx context.Context, ticks []string) (map[string]*entity.TickEntry, error) GetEventInscribeTransfersByInscriptionIds(ctx context.Context, ids []ordinals.InscriptionId) (map[ordinals.InscriptionId]*entity.EventInscribeTransfer, error) GetLatestEventId(ctx context.Context) (int64, error) + GetBalancesByTick(ctx context.Context, tick string, blockHeight uint64) ([]*entity.Balance, error) + GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) (map[string]*entity.Balance, error) + GetTransferableTransfersByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) ([]*entity.EventInscribeTransfer, error) + GetDeployEventByTick(ctx context.Context, tick string) (*entity.EventDeploy, error) + GetFirstLastInscriptionNumberByTick(ctx context.Context, tick string) (first, last int64, err error) + GetDeployEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventDeploy, error) + GetMintEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventMint, error) + GetInscribeTransferEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventInscribeTransfer, error) + GetTransferTransferEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventTransferTransfer, error) } type BRC20WriterDataGateway interface { diff --git a/modules/brc20/internal/repository/postgres/brc20.go b/modules/brc20/internal/repository/postgres/brc20.go index 6533db1..df1eefe 100644 --- a/modules/brc20/internal/repository/postgres/brc20.go +++ b/modules/brc20/internal/repository/postgres/brc20.go @@ -2,6 +2,7 @@ package postgres import ( "context" + "encoding/hex" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" @@ -231,6 +232,179 @@ func (r *Repository) GetTickEntriesByTicks(ctx context.Context, ticks []string) return result, nil } +func (r *Repository) GetBalancesByTick(ctx context.Context, tick string, blockHeight uint64) ([]*entity.Balance, error) { + models, err := r.queries.GetBalancesByTick(ctx, gen.GetBalancesByTickParams{ + Tick: tick, + BlockHeight: int32(blockHeight), + }) + if err != nil { + return nil, errors.WithStack(err) + } + + result := make([]*entity.Balance, 0, len(models)) + for _, model := range models { + balance, err := mapBalanceModelToType(gen.Brc20Balance(model)) + if err != nil { + return nil, errors.Wrap(err, "failed to parse balance model") + } + result = append(result, &balance) + } + return result, nil +} + +func (r *Repository) GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) (map[string]*entity.Balance, error) { + models, err := r.queries.GetBalancesByPkScript(ctx, gen.GetBalancesByPkScriptParams{ + Pkscript: hex.EncodeToString(pkScript), + BlockHeight: int32(blockHeight), + }) + if err != nil { + return nil, errors.WithStack(err) + } + + result := make(map[string]*entity.Balance) + for _, model := range models { + balance, err := mapBalanceModelToType(gen.Brc20Balance(model)) + if err != nil { + return nil, errors.Wrap(err, "failed to parse balance model") + } + result[balance.Tick] = &balance + } + return result, nil +} + +func (r *Repository) GetTransferableTransfersByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) ([]*entity.EventInscribeTransfer, error) { + models, err := r.queries.GetTransferableTransfersByPkScript(ctx, gen.GetTransferableTransfersByPkScriptParams{ + Pkscript: hex.EncodeToString(pkScript), + BlockHeight: int32(blockHeight), + }) + if err != nil { + return nil, errors.WithStack(err) + } + + result := make([]*entity.EventInscribeTransfer, 0, len(models)) + for _, model := range models { + ent, err := mapEventInscribeTransferModelToType(model) + if err != nil { + return nil, errors.Wrap(err, "failed to parse event model") + } + result = append(result, &ent) + } + return result, nil +} + +func (r *Repository) GetDeployEventByTick(ctx context.Context, tick string) (*entity.EventDeploy, error) { + model, err := r.queries.GetDeployEventByTick(ctx, tick) + if err != nil { + return nil, errors.WithStack(err) + } + + ent, err := mapEventDeployModelToType(model) + if err != nil { + return nil, errors.Wrap(err, "failed to parse event model") + } + return &ent, nil +} + +func (r *Repository) GetFirstLastInscriptionNumberByTick(ctx context.Context, tick string) (first, last int64, err error) { + model, err := r.queries.GetFirstLastInscriptionNumberByTick(ctx, tick) + if err != nil { + return -1, -1, errors.WithStack(err) + } + return model.FirstInscriptionNumber, model.LastInscriptionNumber, nil +} + +func (r *Repository) GetDeployEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventDeploy, error) { + models, err := r.queries.GetDeployEvents(ctx, gen.GetDeployEventsParams{ + FilterPkScript: pkScript != nil, + PkScript: hex.EncodeToString(pkScript), + FilterTicker: tick != "", + Ticker: tick, + BlockHeight: int32(height), + }) + if err != nil { + return nil, errors.WithStack(err) + } + + result := make([]*entity.EventDeploy, 0, len(models)) + for _, model := range models { + ent, err := mapEventDeployModelToType(model) + if err != nil { + return nil, errors.Wrap(err, "failed to parse event model") + } + result = append(result, &ent) + } + return result, nil +} + +func (r *Repository) GetMintEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventMint, error) { + models, err := r.queries.GetMintEvents(ctx, gen.GetMintEventsParams{ + FilterPkScript: pkScript != nil, + PkScript: hex.EncodeToString(pkScript), + FilterTicker: tick != "", + Ticker: tick, + BlockHeight: int32(height), + }) + if err != nil { + return nil, errors.WithStack(err) + } + + result := make([]*entity.EventMint, 0, len(models)) + for _, model := range models { + ent, err := mapEventMintModelToType(model) + if err != nil { + return nil, errors.Wrap(err, "failed to parse event model") + } + result = append(result, &ent) + } + return result, nil +} + +func (r *Repository) GetInscribeTransferEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventInscribeTransfer, error) { + models, err := r.queries.GetInscribeTransferEvents(ctx, gen.GetInscribeTransferEventsParams{ + FilterPkScript: pkScript != nil, + PkScript: hex.EncodeToString(pkScript), + FilterTicker: tick != "", + Ticker: tick, + BlockHeight: int32(height), + }) + if err != nil { + return nil, errors.WithStack(err) + } + + result := make([]*entity.EventInscribeTransfer, 0, len(models)) + for _, model := range models { + ent, err := mapEventInscribeTransferModelToType(model) + if err != nil { + return nil, errors.Wrap(err, "failed to parse event model") + } + result = append(result, &ent) + } + return result, nil +} + +func (r *Repository) GetTransferTransferEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventTransferTransfer, error) { + models, err := r.queries.GetTransferTransferEvents(ctx, gen.GetTransferTransferEventsParams{ + FilterPkScript: pkScript != nil, + PkScript: hex.EncodeToString(pkScript), + FilterTicker: tick != "", + Ticker: tick, + BlockHeight: int32(height), + }) + if err != nil { + return nil, errors.WithStack(err) + } + + result := make([]*entity.EventTransferTransfer, 0, len(models)) + for _, model := range models { + ent, err := mapEventTransferTransferModelToType(model) + if err != nil { + return nil, errors.Wrap(err, "failed to parse event model") + } + result = append(result, &ent) + } + return result, nil +} + func (r *Repository) CreateIndexedBlock(ctx context.Context, block *entity.IndexedBlock) error { params := mapIndexedBlockTypeToParams(*block) if err := r.queries.CreateIndexedBlock(ctx, params); err != nil { diff --git a/modules/brc20/internal/repository/postgres/gen/data.sql.go b/modules/brc20/internal/repository/postgres/gen/data.sql.go index bc5ccc8..25b1182 100644 --- a/modules/brc20/internal/repository/postgres/gen/data.sql.go +++ b/modules/brc20/internal/repository/postgres/gen/data.sql.go @@ -203,6 +203,188 @@ func (q *Queries) GetBalancesBatchAtHeight(ctx context.Context, arg GetBalancesB return items, nil } +const getBalancesByPkScript = `-- name: GetBalancesByPkScript :many +WITH balances AS ( + SELECT DISTINCT ON (tick) pkscript, block_height, tick, overall_balance, available_balance FROM brc20_balances WHERE pkscript = $1 AND block_height <= $2 ORDER BY tick, overall_balance DESC +) +SELECT pkscript, block_height, tick, overall_balance, available_balance FROM balances WHERE overall_balance > 0 +` + +type GetBalancesByPkScriptParams struct { + Pkscript string + BlockHeight int32 +} + +type GetBalancesByPkScriptRow struct { + Pkscript string + BlockHeight int32 + Tick string + OverallBalance pgtype.Numeric + AvailableBalance pgtype.Numeric +} + +func (q *Queries) GetBalancesByPkScript(ctx context.Context, arg GetBalancesByPkScriptParams) ([]GetBalancesByPkScriptRow, error) { + rows, err := q.db.Query(ctx, getBalancesByPkScript, arg.Pkscript, arg.BlockHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetBalancesByPkScriptRow + for rows.Next() { + var i GetBalancesByPkScriptRow + if err := rows.Scan( + &i.Pkscript, + &i.BlockHeight, + &i.Tick, + &i.OverallBalance, + &i.AvailableBalance, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getBalancesByTick = `-- name: GetBalancesByTick :many +WITH balances AS ( + SELECT DISTINCT ON (pkscript) pkscript, block_height, tick, overall_balance, available_balance FROM brc20_balances WHERE tick = $1 AND block_height <= $2 ORDER BY pkscript, block_height DESC +) +SELECT pkscript, block_height, tick, overall_balance, available_balance FROM balances WHERE overall_balance > 0 +` + +type GetBalancesByTickParams struct { + Tick string + BlockHeight int32 +} + +type GetBalancesByTickRow struct { + Pkscript string + BlockHeight int32 + Tick string + OverallBalance pgtype.Numeric + AvailableBalance pgtype.Numeric +} + +func (q *Queries) GetBalancesByTick(ctx context.Context, arg GetBalancesByTickParams) ([]GetBalancesByTickRow, error) { + rows, err := q.db.Query(ctx, getBalancesByTick, arg.Tick, arg.BlockHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetBalancesByTickRow + for rows.Next() { + var i GetBalancesByTickRow + if err := rows.Scan( + &i.Pkscript, + &i.BlockHeight, + &i.Tick, + &i.OverallBalance, + &i.AvailableBalance, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getDeployEventByTick = `-- name: GetDeployEventByTick :one +SELECT id, inscription_id, inscription_number, tick, original_tick, tx_hash, block_height, tx_index, timestamp, pkscript, satpoint, total_supply, decimals, limit_per_mint, is_self_mint FROM brc20_event_deploys WHERE tick = $1 +` + +func (q *Queries) GetDeployEventByTick(ctx context.Context, tick string) (Brc20EventDeploy, error) { + row := q.db.QueryRow(ctx, getDeployEventByTick, tick) + var i Brc20EventDeploy + err := row.Scan( + &i.Id, + &i.InscriptionID, + &i.InscriptionNumber, + &i.Tick, + &i.OriginalTick, + &i.TxHash, + &i.BlockHeight, + &i.TxIndex, + &i.Timestamp, + &i.Pkscript, + &i.Satpoint, + &i.TotalSupply, + &i.Decimals, + &i.LimitPerMint, + &i.IsSelfMint, + ) + return i, err +} + +const getDeployEvents = `-- name: GetDeployEvents :many +SELECT id, inscription_id, inscription_number, tick, original_tick, tx_hash, block_height, tx_index, timestamp, pkscript, satpoint, total_supply, decimals, limit_per_mint, is_self_mint FROM "brc20_event_deploys" +WHERE ( + $1::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter + OR pkscript = $2 + ) AND ( + $3::BOOLEAN = FALSE -- if @filter_ticker is TRUE, apply ticker filter + OR tick = $4 + ) AND ( + $5::INT = 0 OR block_height = $5::INT -- if @block_height > 0, apply block_height filter + ) +` + +type GetDeployEventsParams struct { + FilterPkScript bool + PkScript string + FilterTicker bool + Ticker string + BlockHeight int32 +} + +func (q *Queries) GetDeployEvents(ctx context.Context, arg GetDeployEventsParams) ([]Brc20EventDeploy, error) { + rows, err := q.db.Query(ctx, getDeployEvents, + arg.FilterPkScript, + arg.PkScript, + arg.FilterTicker, + arg.Ticker, + arg.BlockHeight, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Brc20EventDeploy + for rows.Next() { + var i Brc20EventDeploy + if err := rows.Scan( + &i.Id, + &i.InscriptionID, + &i.InscriptionNumber, + &i.Tick, + &i.OriginalTick, + &i.TxHash, + &i.BlockHeight, + &i.TxIndex, + &i.Timestamp, + &i.Pkscript, + &i.Satpoint, + &i.TotalSupply, + &i.Decimals, + &i.LimitPerMint, + &i.IsSelfMint, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getEventInscribeTransfersByInscriptionIds = `-- name: GetEventInscribeTransfersByInscriptionIds :many SELECT id, inscription_id, inscription_number, tick, original_tick, tx_hash, block_height, tx_index, timestamp, pkscript, satpoint, output_index, sats_amount, amount FROM "brc20_event_inscribe_transfers" WHERE "inscription_id" = ANY($1::text[]) ` @@ -242,6 +424,31 @@ func (q *Queries) GetEventInscribeTransfersByInscriptionIds(ctx context.Context, return items, nil } +const getFirstLastInscriptionNumberByTick = `-- name: GetFirstLastInscriptionNumberByTick :one +SELECT + COALESCE(MIN("inscription_number"), -1)::BIGINT AS "first_inscription_number", + COALESCE(MAX("inscription_number"), -1)::BIGINT AS "last_inscription_number" +FROM ( + SELECT inscription_number FROM "brc20_event_mints" WHERE "brc20_event_mints"."tick" = $1 + UNION ALL + SELECT inscription_number FROM "brc20_event_inscribe_transfers" WHERE "brc20_event_inscribe_transfers"."tick" = $1 + UNION ALL + SELECT inscription_number FROM "brc20_event_transfer_transfers" WHERE "brc20_event_transfer_transfers"."tick" = $1 +) as events +` + +type GetFirstLastInscriptionNumberByTickRow struct { + FirstInscriptionNumber int64 + LastInscriptionNumber int64 +} + +func (q *Queries) GetFirstLastInscriptionNumberByTick(ctx context.Context, tick string) (GetFirstLastInscriptionNumberByTickRow, error) { + row := q.db.QueryRow(ctx, getFirstLastInscriptionNumberByTick, tick) + var i GetFirstLastInscriptionNumberByTickRow + err := row.Scan(&i.FirstInscriptionNumber, &i.LastInscriptionNumber) + return i, err +} + const getIndexedBlockByHeight = `-- name: GetIndexedBlockByHeight :one SELECT height, hash, event_hash, cumulative_event_hash FROM "brc20_indexed_blocks" WHERE "height" = $1 ` @@ -258,6 +465,68 @@ func (q *Queries) GetIndexedBlockByHeight(ctx context.Context, height int32) (Br return i, err } +const getInscribeTransferEvents = `-- name: GetInscribeTransferEvents :many +SELECT id, inscription_id, inscription_number, tick, original_tick, tx_hash, block_height, tx_index, timestamp, pkscript, satpoint, output_index, sats_amount, amount FROM "brc20_event_inscribe_transfers" +WHERE ( + $1::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter + OR pkscript = $2 + ) AND ( + $3::BOOLEAN = FALSE -- if @filter_ticker is TRUE, apply ticker filter + OR tick = $4 + ) AND ( + $5::INT = 0 OR block_height = $5::INT -- if @block_height > 0, apply block_height filter + ) +` + +type GetInscribeTransferEventsParams struct { + FilterPkScript bool + PkScript string + FilterTicker bool + Ticker string + BlockHeight int32 +} + +func (q *Queries) GetInscribeTransferEvents(ctx context.Context, arg GetInscribeTransferEventsParams) ([]Brc20EventInscribeTransfer, error) { + rows, err := q.db.Query(ctx, getInscribeTransferEvents, + arg.FilterPkScript, + arg.PkScript, + arg.FilterTicker, + arg.Ticker, + arg.BlockHeight, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Brc20EventInscribeTransfer + for rows.Next() { + var i Brc20EventInscribeTransfer + if err := rows.Scan( + &i.Id, + &i.InscriptionID, + &i.InscriptionNumber, + &i.Tick, + &i.OriginalTick, + &i.TxHash, + &i.BlockHeight, + &i.TxIndex, + &i.Timestamp, + &i.Pkscript, + &i.Satpoint, + &i.OutputIndex, + &i.SatsAmount, + &i.Amount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getInscriptionEntriesByIds = `-- name: GetInscriptionEntriesByIds :many WITH "states" AS ( -- select latest state @@ -532,6 +801,67 @@ func (q *Queries) GetLatestProcessorStats(ctx context.Context) (Brc20ProcessorSt return i, err } +const getMintEvents = `-- name: GetMintEvents :many +SELECT id, inscription_id, inscription_number, tick, original_tick, tx_hash, block_height, tx_index, timestamp, pkscript, satpoint, amount, parent_id FROM "brc20_event_mints" +WHERE ( + $1::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter + OR pkscript = $2 + ) AND ( + $3::BOOLEAN = FALSE -- if @filter_ticker is TRUE, apply ticker filter + OR tick = $4 + ) AND ( + $5::INT = 0 OR block_height = $5::INT -- if @block_height > 0, apply block_height filter + ) +` + +type GetMintEventsParams struct { + FilterPkScript bool + PkScript string + FilterTicker bool + Ticker string + BlockHeight int32 +} + +func (q *Queries) GetMintEvents(ctx context.Context, arg GetMintEventsParams) ([]Brc20EventMint, error) { + rows, err := q.db.Query(ctx, getMintEvents, + arg.FilterPkScript, + arg.PkScript, + arg.FilterTicker, + arg.Ticker, + arg.BlockHeight, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Brc20EventMint + for rows.Next() { + var i Brc20EventMint + if err := rows.Scan( + &i.Id, + &i.InscriptionID, + &i.InscriptionNumber, + &i.Tick, + &i.OriginalTick, + &i.TxHash, + &i.BlockHeight, + &i.TxIndex, + &i.Timestamp, + &i.Pkscript, + &i.Satpoint, + &i.Amount, + &i.ParentID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getTickEntriesByTicks = `-- name: GetTickEntriesByTicks :many WITH "states" AS ( -- select latest state @@ -595,3 +925,214 @@ func (q *Queries) GetTickEntriesByTicks(ctx context.Context, ticks []string) ([] } return items, nil } + +const getTickEntriesByTicksAndHeight = `-- name: GetTickEntriesByTicksAndHeight :many + +WITH "states" AS ( + -- select latest state + SELECT DISTINCT ON ("tick") tick, block_height, minted_amount, burned_amount, completed_at, completed_at_height FROM "brc20_tick_entry_states" WHERE "tick" = ANY($1::text[]) AND block_height <= $2 ORDER BY "tick", "block_height" DESC +) +SELECT brc20_tick_entries.tick, original_tick, total_supply, decimals, limit_per_mint, is_self_mint, deploy_inscription_id, deployed_at, deployed_at_height, states.tick, block_height, minted_amount, burned_amount, completed_at, completed_at_height FROM "brc20_tick_entries" + LEFT JOIN "states" ON "brc20_tick_entries"."tick" = "states"."tick" + WHERE "brc20_tick_entries"."tick" = ANY($1::text[]) AND deployed_at_height <= $2 +` + +type GetTickEntriesByTicksAndHeightParams struct { + Ticks []string + Height int32 +} + +type GetTickEntriesByTicksAndHeightRow struct { + Tick string + OriginalTick string + TotalSupply pgtype.Numeric + Decimals int16 + LimitPerMint pgtype.Numeric + IsSelfMint bool + DeployInscriptionID string + DeployedAt pgtype.Timestamp + DeployedAtHeight int32 + Tick_2 pgtype.Text + BlockHeight pgtype.Int4 + MintedAmount pgtype.Numeric + BurnedAmount pgtype.Numeric + CompletedAt pgtype.Timestamp + CompletedAtHeight pgtype.Int4 +} + +// WITH +// "first_mint" AS (SELECT "inscription_number" FROM "brc20_event_mints" WHERE "brc20_event_mints".tick = $1 ORDER BY "id" ASC LIMIT 1), +// "latest_mint" AS (SELECT "inscription_number" FROM "brc20_event_mints" WHERE "brc20_event_mints".tick = $1 ORDER BY "id" DESC LIMIT 1), +// "first_inscribe_transfer" AS (SELECT "inscription_number" FROM "brc20_event_inscribe_transfers" WHERE "brc20_event_inscribe_transfers".tick = $1 ORDER BY "id" ASC LIMIT 1), +// "latest_inscribe_transfer" AS (SELECT "inscription_number" FROM "brc20_event_inscribe_transfers" WHERE "brc20_event_inscribe_transfers".tick = $1 ORDER BY "id" DESC LIMIT 1) +// SELECT +// +// COALESCE( +// LEAST( +// (SELECT "inscription_number" FROM "first_mint"), +// (SELECT "inscription_number" FROM "first_inscribe_transfer") +// ), +// -1 +// ) AS "first_inscription_number", +// COALESCE( +// GREATEST( +// (SELECT "inscription_number" FROM "latest_mint"), +// (SELECT "inscription_number" FROM "latest_inscribe_transfer") +// ), +// -1 +// ) AS "last_inscription_number"; +func (q *Queries) GetTickEntriesByTicksAndHeight(ctx context.Context, arg GetTickEntriesByTicksAndHeightParams) ([]GetTickEntriesByTicksAndHeightRow, error) { + rows, err := q.db.Query(ctx, getTickEntriesByTicksAndHeight, arg.Ticks, arg.Height) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetTickEntriesByTicksAndHeightRow + for rows.Next() { + var i GetTickEntriesByTicksAndHeightRow + if err := rows.Scan( + &i.Tick, + &i.OriginalTick, + &i.TotalSupply, + &i.Decimals, + &i.LimitPerMint, + &i.IsSelfMint, + &i.DeployInscriptionID, + &i.DeployedAt, + &i.DeployedAtHeight, + &i.Tick_2, + &i.BlockHeight, + &i.MintedAmount, + &i.BurnedAmount, + &i.CompletedAt, + &i.CompletedAtHeight, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTransferTransferEvents = `-- name: GetTransferTransferEvents :many +SELECT id, inscription_id, inscription_number, tick, original_tick, tx_hash, block_height, tx_index, timestamp, from_pkscript, from_satpoint, from_input_index, to_pkscript, to_satpoint, to_output_index, spent_as_fee, amount FROM "brc20_event_transfer_transfers" +WHERE ( + $1::BOOLEAN = FALSE -- if @filter_pk_script is TRUE, apply pk_script filter + OR from_pkscript = $2 + OR to_pkscript = $2 + ) AND ( + $3::BOOLEAN = FALSE -- if @filter_ticker is TRUE, apply ticker filter + OR tick = $4 + ) AND ( + $5::INT = 0 OR block_height = $5::INT -- if @block_height > 0, apply block_height filter + ) +` + +type GetTransferTransferEventsParams struct { + FilterPkScript bool + PkScript string + FilterTicker bool + Ticker string + BlockHeight int32 +} + +func (q *Queries) GetTransferTransferEvents(ctx context.Context, arg GetTransferTransferEventsParams) ([]Brc20EventTransferTransfer, error) { + rows, err := q.db.Query(ctx, getTransferTransferEvents, + arg.FilterPkScript, + arg.PkScript, + arg.FilterTicker, + arg.Ticker, + arg.BlockHeight, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Brc20EventTransferTransfer + for rows.Next() { + var i Brc20EventTransferTransfer + if err := rows.Scan( + &i.Id, + &i.InscriptionID, + &i.InscriptionNumber, + &i.Tick, + &i.OriginalTick, + &i.TxHash, + &i.BlockHeight, + &i.TxIndex, + &i.Timestamp, + &i.FromPkscript, + &i.FromSatpoint, + &i.FromInputIndex, + &i.ToPkscript, + &i.ToSatpoint, + &i.ToOutputIndex, + &i.SpentAsFee, + &i.Amount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getTransferableTransfersByPkScript = `-- name: GetTransferableTransfersByPkScript :many +SELECT id, inscription_id, inscription_number, tick, original_tick, tx_hash, block_height, tx_index, timestamp, pkscript, satpoint, output_index, sats_amount, amount +FROM "brc20_event_inscribe_transfers" +WHERE + pkscript = $1 + AND "brc20_event_inscribe_transfers"."block_height" <= $2 + AND NOT EXISTS ( + SELECT NULL + FROM "brc20_event_transfer_transfers" + WHERE "brc20_event_transfer_transfers"."inscription_id" = "brc20_event_inscribe_transfers"."inscription_id" + ) +ORDER BY "brc20_event_inscribe_transfers"."block_height" DESC +` + +type GetTransferableTransfersByPkScriptParams struct { + Pkscript string + BlockHeight int32 +} + +func (q *Queries) GetTransferableTransfersByPkScript(ctx context.Context, arg GetTransferableTransfersByPkScriptParams) ([]Brc20EventInscribeTransfer, error) { + rows, err := q.db.Query(ctx, getTransferableTransfersByPkScript, arg.Pkscript, arg.BlockHeight) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Brc20EventInscribeTransfer + for rows.Next() { + var i Brc20EventInscribeTransfer + if err := rows.Scan( + &i.Id, + &i.InscriptionID, + &i.InscriptionNumber, + &i.Tick, + &i.OriginalTick, + &i.TxHash, + &i.BlockHeight, + &i.TxIndex, + &i.Timestamp, + &i.Pkscript, + &i.Satpoint, + &i.OutputIndex, + &i.SatsAmount, + &i.Amount, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/modules/brc20/internal/usecase/get_balances.go b/modules/brc20/internal/usecase/get_balances.go new file mode 100644 index 0000000..966fa22 --- /dev/null +++ b/modules/brc20/internal/usecase/get_balances.go @@ -0,0 +1,24 @@ +package usecase + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/modules/brc20/internal/entity" +) + +func (u *Usecase) GetBalancesByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) (map[string]*entity.Balance, error) { + balances, err := u.dg.GetBalancesByPkScript(ctx, pkScript, blockHeight) + if err != nil { + return nil, errors.Wrap(err, "error during GetBalancesByPkScript") + } + return balances, nil +} + +func (u *Usecase) GetBalancesByTick(ctx context.Context, tick string, blockHeight uint64) ([]*entity.Balance, error) { + balances, err := u.dg.GetBalancesByTick(ctx, tick, blockHeight) + if err != nil { + return nil, errors.Wrap(err, "failed to get balance by tick") + } + return balances, nil +} diff --git a/modules/brc20/internal/usecase/get_entry.go b/modules/brc20/internal/usecase/get_entry.go new file mode 100644 index 0000000..5c7bc84 --- /dev/null +++ b/modules/brc20/internal/usecase/get_entry.go @@ -0,0 +1,33 @@ +package usecase + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/modules/brc20/internal/entity" +) + +func (u *Usecase) GetTickEntryByTickBatch(ctx context.Context, ticks []string) (map[string]*entity.TickEntry, error) { + entries, err := u.dg.GetTickEntriesByTicks(ctx, ticks) + if err != nil { + return nil, errors.Wrap(err, "error during GetTickEntriesByTicks") + } + return entries, nil +} + +func (u *Usecase) GetTickEntryByTickAndHeight(ctx context.Context, tick string, blockHeight uint64) (*entity.TickEntry, error) { + entries, err := u.GetTickEntryByTickAndHeightBatch(ctx, []string{tick}, blockHeight) + if err != nil { + return nil, errors.WithStack(err) + } + entry, ok := entries[tick] + if !ok { + return nil, errors.Wrap(errs.NotFound, "entry not found") + } + return entry, nil +} + +func (u *Usecase) GetTickEntryByTickAndHeightBatch(ctx context.Context, ticks []string, blockHeight uint64) (map[string]*entity.TickEntry, error) { + return nil, nil +} diff --git a/modules/brc20/internal/usecase/get_inscription_number.go b/modules/brc20/internal/usecase/get_inscription_number.go new file mode 100644 index 0000000..dfd0f08 --- /dev/null +++ b/modules/brc20/internal/usecase/get_inscription_number.go @@ -0,0 +1,15 @@ +package usecase + +import ( + "context" + + "github.com/cockroachdb/errors" +) + +func (u *Usecase) GetFirstLastInscriptionNumberByTick(ctx context.Context, tick string) (int64, int64, error) { + first, last, err := u.dg.GetFirstLastInscriptionNumberByTick(ctx, tick) + if err != nil { + return -1, -1, errors.Wrap(err, "error during GetFirstLastInscriptionNumberByTick") + } + return first, last, nil +} diff --git a/modules/brc20/internal/usecase/get_latest_block.go b/modules/brc20/internal/usecase/get_latest_block.go new file mode 100644 index 0000000..8afd1c5 --- /dev/null +++ b/modules/brc20/internal/usecase/get_latest_block.go @@ -0,0 +1,16 @@ +package usecase + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/core/types" +) + +func (u *Usecase) GetLatestBlock(ctx context.Context) (types.BlockHeader, error) { + blockHeader, err := u.dg.GetLatestBlock(ctx) + if err != nil { + return types.BlockHeader{}, errors.Wrap(err, "failed to get latest block") + } + return blockHeader, nil +} diff --git a/modules/brc20/internal/usecase/get_tick_events.go b/modules/brc20/internal/usecase/get_tick_events.go new file mode 100644 index 0000000..1b30fab --- /dev/null +++ b/modules/brc20/internal/usecase/get_tick_events.go @@ -0,0 +1,16 @@ +package usecase + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/modules/brc20/internal/entity" +) + +func (u *Usecase) GetDeployEventByTick(ctx context.Context, tick string) (*entity.EventDeploy, error) { + result, err := u.dg.GetDeployEventByTick(ctx, tick) + if err != nil { + return nil, errors.Wrap(err, "error during GetDeployEventByTick") + } + return result, nil +} diff --git a/modules/brc20/internal/usecase/get_transactions.go b/modules/brc20/internal/usecase/get_transactions.go new file mode 100644 index 0000000..0131071 --- /dev/null +++ b/modules/brc20/internal/usecase/get_transactions.go @@ -0,0 +1,40 @@ +package usecase + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/modules/brc20/internal/entity" +) + +func (u *Usecase) GetDeployEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventDeploy, error) { + result, err := u.dg.GetDeployEvents(ctx, pkScript, tick, height) + if err != nil { + return nil, errors.Wrap(err, "error during GetDeployEvents") + } + return result, nil +} + +func (u *Usecase) GetMintEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventMint, error) { + result, err := u.dg.GetMintEvents(ctx, pkScript, tick, height) + if err != nil { + return nil, errors.Wrap(err, "error during GetMintEvents") + } + return result, nil +} + +func (u *Usecase) GetInscribeTransferEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventInscribeTransfer, error) { + result, err := u.dg.GetInscribeTransferEvents(ctx, pkScript, tick, height) + if err != nil { + return nil, errors.Wrap(err, "error during GetInscribeTransferEvents") + } + return result, nil +} + +func (u *Usecase) GetTransferTransferEvents(ctx context.Context, pkScript []byte, tick string, height uint64) ([]*entity.EventTransferTransfer, error) { + result, err := u.dg.GetTransferTransferEvents(ctx, pkScript, tick, height) + if err != nil { + return nil, errors.Wrap(err, "error during GetTransferTransfersEvents") + } + return result, nil +} diff --git a/modules/brc20/internal/usecase/get_transferable_transferss.go b/modules/brc20/internal/usecase/get_transferable_transferss.go new file mode 100644 index 0000000..775960a --- /dev/null +++ b/modules/brc20/internal/usecase/get_transferable_transferss.go @@ -0,0 +1,16 @@ +package usecase + +import ( + "context" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/modules/brc20/internal/entity" +) + +func (u *Usecase) GetTransferableTransfersByPkScript(ctx context.Context, pkScript []byte, blockHeight uint64) ([]*entity.EventInscribeTransfer, error) { + result, err := u.dg.GetTransferableTransfersByPkScript(ctx, pkScript, blockHeight) + if err != nil { + return nil, errors.Wrap(err, "error during GetTransferableTransfersByPkScript") + } + return result, nil +} diff --git a/modules/brc20/internal/usecase/usecase.go b/modules/brc20/internal/usecase/usecase.go new file mode 100644 index 0000000..acb081a --- /dev/null +++ b/modules/brc20/internal/usecase/usecase.go @@ -0,0 +1,18 @@ +package usecase + +import ( + "github.com/gaze-network/indexer-network/modules/brc20/internal/datagateway" + "github.com/gaze-network/indexer-network/pkg/btcclient" +) + +type Usecase struct { + dg datagateway.BRC20DataGateway + bitcoinClient btcclient.Contract +} + +func New(dg datagateway.BRC20DataGateway, bitcoinClient btcclient.Contract) *Usecase { + return &Usecase{ + dg: dg, + bitcoinClient: bitcoinClient, + } +} diff --git a/modules/runes/processor.go b/modules/runes/processor.go index 04b5a2e..0fcb528 100644 --- a/modules/runes/processor.go +++ b/modules/runes/processor.go @@ -31,6 +31,7 @@ type Processor struct { bitcoinClient btcclient.Contract network common.Network reportingClient *reportingclient.ReportingClient + cleanupFuncs []func(context.Context) error newRuneEntries map[runes.RuneId]*runes.RuneEntry newRuneEntryStates map[runes.RuneId]*runes.RuneEntry @@ -40,13 +41,14 @@ type Processor struct { newRuneTxs []*entity.RuneTransaction } -func NewProcessor(runesDg datagateway.RunesDataGateway, indexerInfoDg datagateway.IndexerInfoDataGateway, bitcoinClient btcclient.Contract, network common.Network, reportingClient *reportingclient.ReportingClient) *Processor { +func NewProcessor(runesDg datagateway.RunesDataGateway, indexerInfoDg datagateway.IndexerInfoDataGateway, bitcoinClient btcclient.Contract, network common.Network, reportingClient *reportingclient.ReportingClient, cleanupFuncs []func(context.Context) error) *Processor { return &Processor{ runesDg: runesDg, indexerInfoDg: indexerInfoDg, bitcoinClient: bitcoinClient, network: network, reportingClient: reportingClient, + cleanupFuncs: cleanupFuncs, newRuneEntries: make(map[runes.RuneId]*runes.RuneEntry), newRuneEntryStates: make(map[runes.RuneId]*runes.RuneEntry), newOutPointBalances: make(map[wire.OutPoint][]*entity.OutPointBalance), @@ -228,3 +230,13 @@ func (p *Processor) RevertData(ctx context.Context, from int64) error { } return nil } + +func (p *Processor) Shutdown(ctx context.Context) error { + var errs []error + for _, cleanup := range p.cleanupFuncs { + if err := cleanup(ctx); err != nil { + errs = append(errs, err) + } + } + return errors.WithStack(errors.Join(errs...)) +} diff --git a/modules/runes/runes.go b/modules/runes/runes.go index c89ba75..ab305dc 100644 --- a/modules/runes/runes.go +++ b/modules/runes/runes.go @@ -33,6 +33,7 @@ func New(injector do.Injector) (indexer.IndexerWorker, error) { runesDg runesdatagateway.RunesDataGateway indexerInfoDg runesdatagateway.IndexerInfoDataGateway ) + var cleanupFuncs []func(context.Context) error switch strings.ToLower(conf.Modules.Runes.Database) { case "postgresql", "postgres", "pg": pg, err := postgres.NewPool(ctx, conf.Modules.Runes.Postgres) @@ -42,7 +43,10 @@ func New(injector do.Injector) (indexer.IndexerWorker, error) { } return nil, errors.Wrap(err, "can't create Postgres connection pool") } - defer pg.Close() + cleanupFuncs = append(cleanupFuncs, func(ctx context.Context) error { + pg.Close() + return nil + }) runesRepo := runespostgres.NewRepository(pg) runesDg = runesRepo indexerInfoDg = runesRepo @@ -62,7 +66,7 @@ func New(injector do.Injector) (indexer.IndexerWorker, error) { return nil, errors.Wrapf(errs.Unsupported, "%q datasource is not supported", conf.Modules.Runes.Datasource) } - processor := NewProcessor(runesDg, indexerInfoDg, bitcoinClient, conf.Network, reportingClient) + processor := NewProcessor(runesDg, indexerInfoDg, bitcoinClient, conf.Network, reportingClient, cleanupFuncs) if err := processor.VerifyStates(ctx); err != nil { return nil, errors.WithStack(err) } diff --git a/pkg/btcutils/address.go b/pkg/btcutils/address.go new file mode 100644 index 0000000..a81812c --- /dev/null +++ b/pkg/btcutils/address.go @@ -0,0 +1,203 @@ +package btcutils + +import ( + "encoding/json" + "reflect" + + "github.com/Cleverse/go-utilities/utils" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/pkg/logger" + "github.com/gaze-network/indexer-network/pkg/logger/slogx" +) + +// IsAddress returns whether or not the passed string is a valid bitcoin address and valid supported type. +// +// NetParams is optional. If provided, we only check for that network, +// otherwise, we check for all supported networks. +func IsAddress(address string, defaultNet ...*chaincfg.Params) bool { + if len(address) == 0 { + return false + } + + // If defaultNet is provided, we only check for that network. + net, ok := utils.Optional(defaultNet) + if ok { + _, _, err := parseAddress(address, net) + return err == nil + } + + // Otherwise, we check for all supported networks. + for _, net := range supportedNetworks { + _, _, err := parseAddress(address, net) + if err == nil { + return true + } + } + return false +} + +// TODO: create GetAddressNetwork +// check `Bech32HRPSegwit` prefix or netID for P2SH/P2PKH is equal to `PubKeyHashAddrID/ScriptHashAddrID` + +// GetAddressType returns the address type of the passed address. +func GetAddressType(address string, net *chaincfg.Params) (AddressType, error) { + _, addrType, err := parseAddress(address, net) + return addrType, errors.WithStack(err) +} + +type Address struct { + decoded btcutil.Address + net *chaincfg.Params + encoded string + encodedType AddressType + scriptPubKey []byte +} + +// NewAddress creates a new address from the given address string. +// +// defaultNet is required if your address is P2SH or P2PKH (legacy or nested segwit) +// If your address is P2WSH, P2WPKH or P2TR, defaultNet is not required. +func NewAddress(address string, defaultNet ...*chaincfg.Params) Address { + addr, err := SafeNewAddress(address, defaultNet...) + if err != nil { + logger.Panic("can't create parse address", slogx.Error(err), slogx.String("package", "btcutils")) + } + return addr +} + +// SafeNewAddress creates a new address from the given address string. +// It returns an error if the address is invalid. +// +// defaultNet is required if your address is P2SH or P2PKH (legacy or nested segwit) +// If your address is P2WSH, P2WPKH or P2TR, defaultNet is not required. +func SafeNewAddress(address string, defaultNet ...*chaincfg.Params) (Address, error) { + net := utils.DefaultOptional(defaultNet, &chaincfg.MainNetParams) + + decoded, addrType, err := parseAddress(address, net) + if err != nil { + return Address{}, errors.Wrap(err, "can't parse address") + } + + scriptPubkey, err := txscript.PayToAddrScript(decoded) + if err != nil { + return Address{}, errors.Wrap(err, "can't get script pubkey") + } + + return Address{ + decoded: decoded, + net: net, + encoded: decoded.EncodeAddress(), + encodedType: addrType, + scriptPubKey: scriptPubkey, + }, nil +} + +// String returns the address string. +func (a Address) String() string { + return a.encoded +} + +// Type returns the address type. +func (a Address) Type() AddressType { + return a.encodedType +} + +// Decoded returns the btcutil.Address +func (a Address) Decoded() btcutil.Address { + return a.decoded +} + +// IsForNet returns whether or not the address is associated with the passed bitcoin network. +func (a Address) IsForNet(net *chaincfg.Params) bool { + return a.decoded.IsForNet(net) +} + +// ScriptAddress returns the raw bytes of the address to be used when inserting the address into a txout's script. +func (a Address) ScriptAddress() []byte { + return a.decoded.ScriptAddress() +} + +// Net returns the address network params. +func (a Address) Net() *chaincfg.Params { + return a.net +} + +// NetworkName +func (a Address) NetworkName() string { + return a.net.Name +} + +// ScriptPubKey or pubkey script +func (a Address) ScriptPubKey() []byte { + return a.scriptPubKey +} + +// Equal return true if addresses are equal +func (a Address) Equal(b Address) bool { + return a.encoded == b.encoded +} + +// MarshalText implements the encoding.TextMarshaler interface. +func (a Address) MarshalText() ([]byte, error) { + return []byte(a.encoded), nil +} + +// UnmarshalText implements the encoding.TextUnmarshaler interface. +func (a *Address) UnmarshalText(input []byte) error { + address := string(input) + addr, err := SafeNewAddress(address) + if err == nil { + *a = addr + return nil + } + return errors.Wrapf(errs.InvalidArgument, "invalid address `%s`", address) +} + +// MarshalJSON implements the json.Marshaler interface. +func (a Address) MarshalJSON() ([]byte, error) { + t, err := a.MarshalText() + if err != nil { + return nil, &json.MarshalerError{Type: reflect.TypeOf(a), Err: err} + } + b := make([]byte, len(t)+2) + b[0], b[len(b)-1] = '"', '"' // add quotes + copy(b[1:], t) + return b, nil +} + +// UnmarshalJSON parses a hash in hex syntax. +func (a *Address) UnmarshalJSON(input []byte) error { + if !(len(input) >= 2 && input[0] == '"' && input[len(input)-1] == '"') { + return &json.UnmarshalTypeError{Value: "non-string", Type: reflect.TypeOf(Address{})} + } + if err := a.UnmarshalText(input[1 : len(input)-1]); err != nil { + return err + } + return nil +} + +func parseAddress(address string, params *chaincfg.Params) (btcutil.Address, AddressType, error) { + decoded, err := btcutil.DecodeAddress(address, params) + if err != nil { + return nil, 0, errors.Wrapf(err, "can't decode address `%s` for network `%s`", address, params.Name) + } + + switch decoded.(type) { + case *btcutil.AddressWitnessPubKeyHash: + return decoded, AddressP2WPKH, nil + case *btcutil.AddressTaproot: + return decoded, AddressP2TR, nil + case *btcutil.AddressScriptHash: + return decoded, AddressP2SH, nil + case *btcutil.AddressPubKeyHash: + return decoded, AddressP2PKH, nil + case *btcutil.AddressWitnessScriptHash: + return decoded, AddressP2WSH, nil + default: + return nil, 0, errors.Wrap(errs.Unsupported, "unsupported address type") + } +} diff --git a/pkg/btcutils/address_bench_test.go b/pkg/btcutils/address_bench_test.go new file mode 100644 index 0000000..bf144ff --- /dev/null +++ b/pkg/btcutils/address_bench_test.go @@ -0,0 +1,80 @@ +package btcutils_test + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/gaze-network/indexer-network/pkg/btcutils" +) + +/* +NOTE: + +# Compare this benchmark to go-ethereum/common.Address utils +- go-ethereum/common.HexToAddress speed: 45 ns/op, 48 B/op, 1 allocs/op +- go-ethereum/common.IsHexAddress speed: 25 ns/op, 0 B/op, 0 allocs/op + +It's slower than go-ethereum/common.Address utils because ethereum wallet address is Hex string 20 bytes, +but Bitcoin has many types of address and each type has complex algorithm to solve (can't solve and validate address type directly from address string) + +20/Jan/2024 @Planxnx Macbook Air M1 16GB +BenchmarkIsAddress/specific-network/mainnet/P2WPKH-8 1776146 625.6 ns/op 120 B/op 3 allocs/op +BenchmarkIsAddress/specific-network/testnet3/P2WPKH-8 1917876 623.2 ns/op 120 B/op 3 allocs/op +BenchmarkIsAddress/specific-network/mainnet/P2TR-8 1330348 915.4 ns/op 160 B/op 3 allocs/op +BenchmarkIsAddress/specific-network/testnet3/P2TR-8 1235806 931.1 ns/op 160 B/op 3 allocs/op +BenchmarkIsAddress/specific-network/mainnet/P2WSH-8 1261730 960.9 ns/op 160 B/op 3 allocs/op +BenchmarkIsAddress/specific-network/testnet3/P2WSH-8 1307851 916.1 ns/op 160 B/op 3 allocs/op +BenchmarkIsAddress/specific-network/mainnet/P2SH-8 3081762 402.0 ns/op 192 B/op 8 allocs/op +BenchmarkIsAddress/specific-network/testnet3/P2SH-8 3245838 344.9 ns/op 176 B/op 7 allocs/op +BenchmarkIsAddress/specific-network/mainnet/P2PKH-8 2904252 410.4 ns/op 184 B/op 8 allocs/op +BenchmarkIsAddress/specific-network/testnet3/P2PKH-8 3522332 342.8 ns/op 176 B/op 7 allocs/op +BenchmarkIsAddress/automate-network/mainnet/P2WPKH-8 1882059 637.6 ns/op 120 B/op 3 allocs/op +BenchmarkIsAddress/automate-network/testnet3/P2WPKH-8 1626151 664.8 ns/op 120 B/op 3 allocs/op +BenchmarkIsAddress/automate-network/mainnet/P2TR-8 1250253 952.1 ns/op 160 B/op 3 allocs/op +BenchmarkIsAddress/automate-network/testnet3/P2TR-8 1257901 993.7 ns/op 160 B/op 3 allocs/op +BenchmarkIsAddress/automate-network/mainnet/P2WSH-8 1000000 1005 ns/op 160 B/op 3 allocs/op +BenchmarkIsAddress/automate-network/testnet3/P2WSH-8 1209108 971.2 ns/op 160 B/op 3 allocs/op +BenchmarkIsAddress/automate-network/mainnet/P2SH-8 1869075 625.0 ns/op 268 B/op 9 allocs/op +BenchmarkIsAddress/automate-network/testnet3/P2SH-8 779496 1609 ns/op 694 B/op 17 allocs/op +BenchmarkIsAddress/automate-network/mainnet/P2PKH-8 1924058 650.6 ns/op 259 B/op 9 allocs/op +BenchmarkIsAddress/automate-network/testnet3/P2PKH-8 721510 1690 ns/op 694 B/op 17 allocs/op +*/ +func BenchmarkIsAddress(b *testing.B) { + cases := []btcutils.Address{ + /* P2WPKH */ btcutils.NewAddress("bc1qfpgdxtpl7kz5qdus2pmexyjaza99c28q8uyczh", &chaincfg.MainNetParams), + /* P2WPKH */ btcutils.NewAddress("tb1qfpgdxtpl7kz5qdus2pmexyjaza99c28qd6ltey", &chaincfg.TestNet3Params), + /* P2TR */ btcutils.NewAddress("bc1p7h87kqsmpzatddzhdhuy9gmxdpvn5kvar6hhqlgau8d2ffa0pa3qvz5d38", &chaincfg.MainNetParams), + /* P2TR */ btcutils.NewAddress("tb1p7h87kqsmpzatddzhdhuy9gmxdpvn5kvar6hhqlgau8d2ffa0pa3qm2zztg", &chaincfg.TestNet3Params), + /* P2WSH */ btcutils.NewAddress("bc1qeklep85ntjz4605drds6aww9u0qr46qzrv5xswd35uhjuj8ahfcqgf6hak", &chaincfg.MainNetParams), + /* P2WSH */ btcutils.NewAddress("tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", &chaincfg.TestNet3Params), + /* P2SH */ btcutils.NewAddress("3Ccte7SJz71tcssLPZy3TdWz5DTPeNRbPw", &chaincfg.MainNetParams), + /* P2SH */ btcutils.NewAddress("2NCxMvHPTduZcCuUeAiWUpuwHga7Y66y9XJ", &chaincfg.TestNet3Params), + /* P2PKH */ btcutils.NewAddress("1KrRZSShVkdc8J71CtY4wdw46Rx3BRLKyH", &chaincfg.MainNetParams), + /* P2PKH */ btcutils.NewAddress("migbBPcDajPfffrhoLpYFTQNXQFbWbhpz3", &chaincfg.TestNet3Params), + } + + b.Run("specific-network", func(b *testing.B) { + for _, c := range cases { + b.Run(c.NetworkName()+"/"+c.Type().String(), func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = btcutils.IsAddress(c.String(), c.Net()) + } + }) + } + }) + + b.Run("automate-network", func(b *testing.B) { + for _, c := range cases { + b.Run(c.NetworkName()+"/"+c.Type().String(), func(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + ok := btcutils.IsAddress(c.String()) + if !ok { + b.Error("IsAddress returned false") + } + } + }) + } + }) +} diff --git a/pkg/btcutils/address_test.go b/pkg/btcutils/address_test.go new file mode 100644 index 0000000..d69f615 --- /dev/null +++ b/pkg/btcutils/address_test.go @@ -0,0 +1,363 @@ +package btcutils_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/gaze-network/indexer-network/pkg/btcutils" + "github.com/stretchr/testify/assert" +) + +func TestGetAddressType(t *testing.T) { + type Spec struct { + Address string + DefaultNet *chaincfg.Params + + ExpectedError error + ExpectedAddressType btcutils.AddressType + } + + specs := []Spec{ + { + Address: "bc1qfpgdxtpl7kz5qdus2pmexyjaza99c28q8uyczh", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2WPKH, + }, + { + Address: "tb1qfpgdxtpl7kz5qdus2pmexyjaza99c28qd6ltey", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2WPKH, + }, + { + Address: "bc1p7h87kqsmpzatddzhdhuy9gmxdpvn5kvar6hhqlgau8d2ffa0pa3qvz5d38", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2TR, + }, + { + Address: "tb1p7h87kqsmpzatddzhdhuy9gmxdpvn5kvar6hhqlgau8d2ffa0pa3qm2zztg", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2TR, + }, + { + Address: "3Ccte7SJz71tcssLPZy3TdWz5DTPeNRbPw", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2SH, + }, + { + Address: "1KrRZSShVkdc8J71CtY4wdw46Rx3BRLKyH", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2PKH, + }, + { + Address: "bc1qeklep85ntjz4605drds6aww9u0qr46qzrv5xswd35uhjuj8ahfcqgf6hak", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2WSH, + }, + { + Address: "migbBPcDajPfffrhoLpYFTQNXQFbWbhpz3", + DefaultNet: &chaincfg.TestNet3Params, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2PKH, + }, + { + Address: "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2WSH, + }, + { + Address: "2NCxMvHPTduZcCuUeAiWUpuwHga7Y66y9XJ", + DefaultNet: &chaincfg.TestNet3Params, + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2SH, + }, + } + + for _, spec := range specs { + t.Run(fmt.Sprintf("address:%s", spec.Address), func(t *testing.T) { + actualAddressType, actualError := btcutils.GetAddressType(spec.Address, spec.DefaultNet) + if spec.ExpectedError != nil { + assert.ErrorIs(t, actualError, spec.ExpectedError) + } else { + assert.Equal(t, spec.ExpectedAddressType, actualAddressType) + } + }) + } +} + +func TestNewAddress(t *testing.T) { + type Spec struct { + Address string + DefaultNet *chaincfg.Params + + ExpectedAddressType btcutils.AddressType + } + + specs := []Spec{ + { + Address: "bc1qfpgdxtpl7kz5qdus2pmexyjaza99c28q8uyczh", + // DefaultNet: &chaincfg.MainNetParams, // Optional + + ExpectedAddressType: btcutils.AddressP2WPKH, + }, + { + Address: "tb1qfpgdxtpl7kz5qdus2pmexyjaza99c28qd6ltey", + // DefaultNet: &chaincfg.MainNetParams, // Optional + + ExpectedAddressType: btcutils.AddressP2WPKH, + }, + { + Address: "bc1p7h87kqsmpzatddzhdhuy9gmxdpvn5kvar6hhqlgau8d2ffa0pa3qvz5d38", + // DefaultNet: &chaincfg.MainNetParams, // Optional + + ExpectedAddressType: btcutils.AddressP2TR, + }, + { + Address: "tb1p7h87kqsmpzatddzhdhuy9gmxdpvn5kvar6hhqlgau8d2ffa0pa3qm2zztg", + // DefaultNet: &chaincfg.MainNetParams, // Optional + + ExpectedAddressType: btcutils.AddressP2TR, + }, + { + Address: "bc1qeklep85ntjz4605drds6aww9u0qr46qzrv5xswd35uhjuj8ahfcqgf6hak", + // DefaultNet: &chaincfg.MainNetParams, // Optional + + ExpectedAddressType: btcutils.AddressP2WSH, + }, + { + Address: "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", + // DefaultNet: &chaincfg.MainNetParams, // Optional + + ExpectedAddressType: btcutils.AddressP2WSH, + }, + { + Address: "3Ccte7SJz71tcssLPZy3TdWz5DTPeNRbPw", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedAddressType: btcutils.AddressP2SH, + }, + { + Address: "2NCxMvHPTduZcCuUeAiWUpuwHga7Y66y9XJ", + DefaultNet: &chaincfg.TestNet3Params, + + ExpectedAddressType: btcutils.AddressP2SH, + }, + { + Address: "1KrRZSShVkdc8J71CtY4wdw46Rx3BRLKyH", + DefaultNet: &chaincfg.MainNetParams, + + ExpectedAddressType: btcutils.AddressP2PKH, + }, + { + Address: "migbBPcDajPfffrhoLpYFTQNXQFbWbhpz3", + DefaultNet: &chaincfg.TestNet3Params, + + ExpectedAddressType: btcutils.AddressP2PKH, + }, + } + + for _, spec := range specs { + t.Run(fmt.Sprintf("address:%s,type:%s", spec.Address, spec.ExpectedAddressType), func(t *testing.T) { + addr := btcutils.NewAddress(spec.Address, spec.DefaultNet) + + assert.Equal(t, spec.ExpectedAddressType, addr.Type()) + assert.Equal(t, spec.Address, addr.String()) + }) + } +} + +func TestIsAddress(t *testing.T) { + type Spec struct { + Address string + Expected bool + } + + specs := []Spec{ + { + Address: "bc1qfpgdxtpl7kz5qdus2pmexyjaza99c28q8uyczh", + + Expected: true, + }, + { + Address: "tb1qfpgdxtpl7kz5qdus2pmexyjaza99c28qd6ltey", + + Expected: true, + }, + { + Address: "bc1p7h87kqsmpzatddzhdhuy9gmxdpvn5kvar6hhqlgau8d2ffa0pa3qvz5d38", + + Expected: true, + }, + { + Address: "tb1p7h87kqsmpzatddzhdhuy9gmxdpvn5kvar6hhqlgau8d2ffa0pa3qm2zztg", + + Expected: true, + }, + { + Address: "bc1qeklep85ntjz4605drds6aww9u0qr46qzrv5xswd35uhjuj8ahfcqgf6hak", + + Expected: true, + }, + { + Address: "tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3q0sl5k7", + + Expected: true, + }, + { + Address: "3Ccte7SJz71tcssLPZy3TdWz5DTPeNRbPw", + + Expected: true, + }, + { + Address: "2NCxMvHPTduZcCuUeAiWUpuwHga7Y66y9XJ", + + Expected: true, + }, + { + Address: "1KrRZSShVkdc8J71CtY4wdw46Rx3BRLKyH", + + Expected: true, + }, + { + Address: "migbBPcDajPfffrhoLpYFTQNXQFbWbhpz3", + + Expected: true, + }, + { + Address: "", + + Expected: false, + }, + { + Address: "migbBPcDajPfffrhoLpYFTQNXQFbWbhpz2", + + Expected: false, + }, + { + Address: "bc1qfpgdxtpl7kz5qdus2pmexyjaza99c28q8uyczz", + + Expected: false, + }, + } + + for _, spec := range specs { + t.Run(fmt.Sprintf("address:%s", spec.Address), func(t *testing.T) { + ok := btcutils.IsAddress(spec.Address) + assert.Equal(t, spec.Expected, ok) + }) + } +} + +func TestAddressEncoding(t *testing.T) { + rawAddress := "bc1qfpgdxtpl7kz5qdus2pmexyjaza99c28q8uyczh" + address := btcutils.NewAddress(rawAddress, &chaincfg.MainNetParams) + + type Spec struct { + Data interface{} + Expected string + } + + specs := []Spec{ + { + Data: address, + Expected: fmt.Sprintf(`"%s"`, rawAddress), + }, + { + Data: map[string]interface{}{ + "address": rawAddress, + }, + Expected: fmt.Sprintf(`{"address":"%s"}`, rawAddress), + }, + } + + for i, spec := range specs { + t.Run(fmt.Sprint(i+1), func(t *testing.T) { + actual, err := json.Marshal(spec.Data) + assert.NoError(t, err) + assert.Equal(t, spec.Expected, string(actual)) + }) + } +} + +func TestAddressDecoding(t *testing.T) { + rawAddress := "bc1qfpgdxtpl7kz5qdus2pmexyjaza99c28q8uyczh" + address := btcutils.NewAddress(rawAddress, &chaincfg.MainNetParams) + + // Case #1: address is a string + t.Run("from_string", func(t *testing.T) { + input := fmt.Sprintf(`"%s"`, rawAddress) + expected := address + actual := btcutils.Address{} + + err := json.Unmarshal([]byte(input), &actual) + if !assert.NoError(t, err) { + t.FailNow() + } + assert.Equal(t, expected, actual) + }) + + // Case #2: address is a field of a struct + t.Run("from_field_string", func(t *testing.T) { + type Data struct { + Address btcutils.Address `json:"address"` + } + input := fmt.Sprintf(`{"address":"%s"}`, rawAddress) + expected := Data{Address: address} + actual := Data{} + err := json.Unmarshal([]byte(input), &actual) + if !assert.NoError(t, err) { + t.FailNow() + } + assert.Equal(t, expected, actual) + }) + + // Case #3: address is an element of an array + t.Run("from_array", func(t *testing.T) { + input := fmt.Sprintf(`["%s"]`, rawAddress) + expected := []btcutils.Address{address} + actual := []btcutils.Address{} + err := json.Unmarshal([]byte(input), &actual) + if !assert.NoError(t, err) { + t.FailNow() + } + assert.Equal(t, expected, actual) + }) + + // Case #4: not supported address type + t.Run("from_string/not_address", func(t *testing.T) { + input := fmt.Sprintf(`"%s"`, "THIS_IS_NOT_SUPPORTED_ADDRESS") + actual := btcutils.Address{} + err := json.Unmarshal([]byte(input), &actual) + assert.Error(t, err) + }) + + // Case #5: invalid field type + t.Run("from_number", func(t *testing.T) { + type Data struct { + Address btcutils.Address `json:"address"` + } + input := fmt.Sprintf(`{"address":%d}`, 123) + actual := Data{} + err := json.Unmarshal([]byte(input), &actual) + assert.Error(t, err) + }) +} diff --git a/pkg/btcutils/btc.go b/pkg/btcutils/btc.go new file mode 100644 index 0000000..612283a --- /dev/null +++ b/pkg/btcutils/btc.go @@ -0,0 +1,44 @@ +package btcutils + +import ( + "github.com/Cleverse/go-utilities/utils" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" +) + +var ( + // NullAddress is an address that script address is all zeros. + NullAddress = NewAddress("1111111111111111111114oLvT2", &chaincfg.MainNetParams) + + // NullHash is a hash that all bytes are zero. + NullHash = utils.Must(chainhash.NewHashFromStr("0000000000000000000000000000000000000000000000000000000000000000")) +) + +// TransactionType is the type of bitcoin transaction +// It's an alias of txscript.ScriptClass +type TransactionType = txscript.ScriptClass + +// AddressType is the type of bitcoin address. +// It's an alias of txscript.ScriptClass +type AddressType = txscript.ScriptClass + +// Types of bitcoin transaction +const ( + TransactionP2WPKH = txscript.WitnessV0PubKeyHashTy + TransactionP2TR = txscript.WitnessV1TaprootTy + TransactionTaproot = TransactionP2TR // Alias of P2TR + TransactionP2SH = txscript.ScriptHashTy + TransactionP2PKH = txscript.PubKeyHashTy + TransactionP2WSH = txscript.WitnessV0ScriptHashTy +) + +// Types of bitcoin address +const ( + AddressP2WPKH = txscript.WitnessV0PubKeyHashTy + AddressP2TR = txscript.WitnessV1TaprootTy + AddressTaproot = AddressP2TR // Alias of P2TR + AddressP2SH = txscript.ScriptHashTy + AddressP2PKH = txscript.PubKeyHashTy + AddressP2WSH = txscript.WitnessV0ScriptHashTy +) diff --git a/pkg/btcutils/btc_network.go b/pkg/btcutils/btc_network.go new file mode 100644 index 0000000..d961c49 --- /dev/null +++ b/pkg/btcutils/btc_network.go @@ -0,0 +1,23 @@ +package btcutils + +import ( + "github.com/btcsuite/btcd/chaincfg" +) + +var supportedNetworks = map[string]*chaincfg.Params{ + "mainnet": &chaincfg.MainNetParams, + "testnet": &chaincfg.TestNet3Params, +} + +// IsSupportedNetwork returns true if the given network is supported. +// +// TODO: create enum for network +func IsSupportedNetwork(network string) bool { + _, ok := supportedNetworks[network] + return ok +} + +// GetNetParams returns the *chaincfg.Params for the given network. +func GetNetParams(network string) *chaincfg.Params { + return supportedNetworks[network] +} diff --git a/pkg/btcutils/pk_script.go b/pkg/btcutils/pk_script.go new file mode 100644 index 0000000..c5f37fd --- /dev/null +++ b/pkg/btcutils/pk_script.go @@ -0,0 +1,54 @@ +package btcutils + +import ( + "github.com/Cleverse/go-utilities/utils" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/cockroachdb/errors" +) + +// NewPkScript creates a pubkey script(or witness program) from the given address string +// +// see: https://en.bitcoin.it/wiki/Script +func NewPkScript(address string, defaultNet ...*chaincfg.Params) ([]byte, error) { + net := utils.DefaultOptional(defaultNet, &chaincfg.MainNetParams) + decoded, _, err := parseAddress(address, net) + if err != nil { + return nil, errors.Wrap(err, "can't parse address") + } + scriptPubkey, err := txscript.PayToAddrScript(decoded) + if err != nil { + return nil, errors.Wrap(err, "can't get script pubkey") + } + return scriptPubkey, nil +} + +// GetAddressTypeFromPkScript returns the address type from the given pubkey script/script pubkey. +func GetAddressTypeFromPkScript(pkScript []byte, defaultNet ...*chaincfg.Params) (AddressType, error) { + net := utils.DefaultOptional(defaultNet, &chaincfg.MainNetParams) + scriptClass, _, _, err := txscript.ExtractPkScriptAddrs(pkScript, net) + if err != nil { + return txscript.NonStandardTy, errors.Wrap(err, "can't parse pkScript") + } + return scriptClass, nil +} + +// ExtractAddressFromPkScript extracts address from the given pubkey script/script pubkey. +// multi-signature script not supported +func ExtractAddressFromPkScript(pkScript []byte, defaultNet ...*chaincfg.Params) (Address, error) { + net := utils.DefaultOptional(defaultNet, &chaincfg.MainNetParams) + addrType, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, net) + if err != nil { + return Address{}, errors.Wrap(err, "can't parse pkScript") + } + if len(addrs) == 0 { + return Address{}, errors.New("can't extract address from pkScript") + } + return Address{ + decoded: addrs[0], + net: net, + encoded: addrs[0].EncodeAddress(), + encodedType: addrType, + scriptPubKey: pkScript, + }, nil +} diff --git a/pkg/btcutils/pk_script_test.go b/pkg/btcutils/pk_script_test.go new file mode 100644 index 0000000..f27a764 --- /dev/null +++ b/pkg/btcutils/pk_script_test.go @@ -0,0 +1,205 @@ +package btcutils_test + +import ( + "encoding/hex" + "fmt" + "testing" + + "github.com/Cleverse/go-utilities/utils" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/pkg/btcutils" + "github.com/stretchr/testify/assert" +) + +func TestNewPkScript(t *testing.T) { + anyError := errors.New("any error") + + type Spec struct { + Address string + DefaultNet *chaincfg.Params + ExpectedError error + ExpectedPkScript string // hex encoded + } + + specs := []Spec{ + { + Address: "some_invalid_address", + DefaultNet: &chaincfg.MainNetParams, + ExpectedError: anyError, + ExpectedPkScript: "", + }, + { + // P2WPKH + Address: "bc1qdx72th7e3z8zc5wdrdxweswfcne974pjneyjln", + DefaultNet: &chaincfg.MainNetParams, + ExpectedError: nil, + ExpectedPkScript: "001469bca5dfd9888e2c51cd1b4cecc1c9c4f25f5432", + }, + { + // P2WPKH + Address: "bc1q7cj6gz6t3d28qg7kxhrc7h5t3h0re34fqqalga", + DefaultNet: &chaincfg.MainNetParams, + ExpectedError: nil, + ExpectedPkScript: "0014f625a40b4b8b547023d635c78f5e8b8dde3cc6a9", + }, + { + // P2TR + Address: "bc1pfd0zw2jwlpn4xckpr3dxpt7x0gw6wetuftxvrc4dt2qgn9azjuus65fug6", + DefaultNet: &chaincfg.MainNetParams, + ExpectedError: nil, + ExpectedPkScript: "51204b5e272a4ef8675362c11c5a60afc67a1da7657c4accc1e2ad5a808997a29739", + }, + { + // P2TR + Address: "bc1pxpumml545tqum5afarzlmnnez2npd35nvf0j0vnrp88nemqsn54qle05sm", + DefaultNet: &chaincfg.MainNetParams, + ExpectedError: nil, + ExpectedPkScript: "51203079bdfe95a2c1cdd3a9e8c5fdce7912a616c693625f27b26309cf3cec109d2a", + }, + { + // P2SH + Address: "3Ccte7SJz71tcssLPZy3TdWz5DTPeNRbPw", + DefaultNet: &chaincfg.MainNetParams, + ExpectedError: nil, + ExpectedPkScript: "a91477e1a3d54f545d83869ae3a6b28b071422801d7b87", + }, + { + // P2PKH + Address: "1KrRZSShVkdc8J71CtY4wdw46Rx3BRLKyH", + DefaultNet: &chaincfg.MainNetParams, + ExpectedError: nil, + ExpectedPkScript: "76a914cecb25b53809991c7beef2d27bc2be49e78c684388ac", + }, + { + // P2WSH + Address: "bc1qeklep85ntjz4605drds6aww9u0qr46qzrv5xswd35uhjuj8ahfcqgf6hak", + DefaultNet: &chaincfg.MainNetParams, + ExpectedError: nil, + ExpectedPkScript: "0020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", + }, + } + + for _, spec := range specs { + t.Run(fmt.Sprintf("address:%s", spec.Address), func(t *testing.T) { + // Validate Expected PkScript + if spec.ExpectedError == nil { + { + expectedPkScriptRaw, err := hex.DecodeString(spec.ExpectedPkScript) + if err != nil { + t.Fatalf("can't decode expected pkscript %s, Reason: %s", spec.ExpectedPkScript, err) + } + expectedPkScript, err := txscript.ParsePkScript(expectedPkScriptRaw) + if err != nil { + t.Fatalf("invalid expected pkscript %s, Reason: %s", spec.ExpectedPkScript, err) + } + + expectedAddress, err := expectedPkScript.Address(spec.DefaultNet) + if err != nil { + t.Fatalf("can't get address from expected pkscript %s, Reason: %s", spec.ExpectedPkScript, err) + } + assert.Equal(t, spec.Address, expectedAddress.EncodeAddress()) + } + { + address, err := btcutil.DecodeAddress(spec.Address, spec.DefaultNet) + if err != nil { + t.Fatalf("can't decode address %s(%s),Reason: %s", spec.Address, spec.DefaultNet.Name, err) + } + + pkScript, err := txscript.PayToAddrScript(address) + if err != nil { + t.Fatalf("can't get pkscript from address %s(%s),Reason: %s", spec.Address, spec.DefaultNet.Name, err) + } + + pkScriptStr := hex.EncodeToString(pkScript) + assert.Equal(t, spec.ExpectedPkScript, pkScriptStr) + } + } + + pkScript, err := btcutils.NewPkScript(spec.Address, spec.DefaultNet) + if spec.ExpectedError == anyError { + assert.Error(t, err) + } else if spec.ExpectedError != nil { + assert.ErrorIs(t, err, spec.ExpectedError) + } else { + address, err := btcutils.SafeNewAddress(spec.Address, spec.DefaultNet) + if err != nil { + t.Fatalf("can't create address %s(%s),Reason: %s", spec.Address, spec.DefaultNet.Name, err) + } + + // ScriptPubKey from address and from NewPkScript should be the same + assert.Equal(t, address.ScriptPubKey(), pkScript) + + // Expected PkScript and New PkScript should be the same + pkScriptStr := hex.EncodeToString(pkScript) + assert.Equal(t, spec.ExpectedPkScript, pkScriptStr) + + // Can convert PkScript back to same address + acualPkScript, err := txscript.ParsePkScript(address.ScriptPubKey()) + if !assert.NoError(t, err) { + t.Fail() + } + assert.Equal(t, address.Decoded().String(), utils.Must(acualPkScript.Address(spec.DefaultNet)).String()) + } + }) + } +} + +func TestGetAddressTypeFromPkScript(t *testing.T) { + type Spec struct { + PubkeyScript string + + ExpectedError error + ExpectedAddressType btcutils.AddressType + } + + specs := []Spec{ + { + PubkeyScript: "0014602181cc89f7c9f54cb6d7607a3445e3e022895d", + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2WPKH, + }, + { + PubkeyScript: "5120ef8d59038dd51093fbfff794f658a07a3697b94d9e6d24e45b28abd88f10e33d", + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2TR, + }, + { + PubkeyScript: "a91416eef7e84fb9821db1341b6ccef1c4a4e5ec21e487", + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2SH, + }, + { + PubkeyScript: "76a914cecb25b53809991c7beef2d27bc2be49e78c684388ac", + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2PKH, + }, + { + PubkeyScript: "0020cdbf909e935c855d3e8d1b61aeb9c5e3c03ae8021b286839b1a72f2e48fdba70", + + ExpectedError: nil, + ExpectedAddressType: btcutils.AddressP2WSH, + }, + } + + for _, spec := range specs { + t.Run(fmt.Sprintf("PkScript:%s", spec.PubkeyScript), func(t *testing.T) { + pkScript, err := hex.DecodeString(spec.PubkeyScript) + if err != nil { + t.Fail() + } + actualAddressType, actualError := btcutils.GetAddressTypeFromPkScript(pkScript) + if spec.ExpectedError != nil { + assert.ErrorIs(t, actualError, spec.ExpectedError) + } else { + assert.Equal(t, spec.ExpectedAddressType, actualAddressType) + } + }) + } +} diff --git a/pkg/btcutils/pkscript.go b/pkg/btcutils/pkscript.go new file mode 100644 index 0000000..d08a0b3 --- /dev/null +++ b/pkg/btcutils/pkscript.go @@ -0,0 +1,63 @@ +package btcutils + +import ( + "encoding/hex" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/gaze-network/indexer-network/common" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/pkg/errors" +) + +// ToPkScript converts a string of address or pkscript to bytes of pkscript +func ToPkScript(network common.Network, from string) ([]byte, error) { + if from == "" { + return nil, errors.Wrap(errs.InvalidArgument, "empty input") + } + + defaultNet, err := func() (*chaincfg.Params, error) { + switch network { + case common.NetworkMainnet: + return &chaincfg.MainNetParams, nil + case common.NetworkTestnet: + return &chaincfg.TestNet3Params, nil + default: + return nil, errors.Wrap(errs.InvalidArgument, "invalid network") + } + }() + if err != nil { + return nil, err + } + + // attempt to parse as address + address, err := btcutil.DecodeAddress(from, defaultNet) + if err == nil { + pkScript, err := txscript.PayToAddrScript(address) + if err != nil { + return nil, errors.Wrap(err, "error converting address to pkscript") + } + return pkScript, nil + } + + // attempt to parse as pkscript + pkScript, err := hex.DecodeString(from) + if err != nil { + return nil, errors.Wrap(err, "error decoding pkscript") + } + + return pkScript, nil +} + +// PkScriptToAddress returns the address from the given pkScript. If the pkScript is invalid or not standard, it returns empty string. +func PkScriptToAddress(pkScript []byte, network common.Network) (string, error) { + _, addrs, _, err := txscript.ExtractPkScriptAddrs(pkScript, network.ChainParams()) + if err != nil { + return "", errors.Wrap(err, "error extracting addresses from pkscript") + } + if len(addrs) != 1 { + return "", errors.New("invalid number of addresses extracted from pkscript") + } + return addrs[0].EncodeAddress(), nil +} diff --git a/pkg/btcutils/psbtutils/encoding.go b/pkg/btcutils/psbtutils/encoding.go new file mode 100644 index 0000000..457ce08 --- /dev/null +++ b/pkg/btcutils/psbtutils/encoding.go @@ -0,0 +1,92 @@ +package psbtutils + +import ( + "bytes" + "encoding/base64" + "encoding/hex" + + "github.com/Cleverse/go-utilities/utils" + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common/errs" +) + +const ( + // default psbt encoding is hex + DefaultEncoding = EncodingHex +) + +type Encoding string + +const ( + EncodingBase64 Encoding = "base64" + EncodingHex Encoding = "hex" +) + +// DecodeString decodes a psbt hex/base64 string into a psbt.Packet +// +// encoding is optional, default is EncodingHex +func DecodeString(psbtStr string, encoding ...Encoding) (*psbt.Packet, error) { + pC, err := Decode([]byte(psbtStr), encoding...) + return pC, errors.WithStack(err) +} + +// Decode decodes a psbt hex/base64 byte into a psbt.Packet +// +// encoding is optional, default is EncodingHex +func Decode(psbtB []byte, encoding ...Encoding) (*psbt.Packet, error) { + enc, ok := utils.Optional(encoding) + if !ok { + enc = DefaultEncoding + } + + var ( + psbtBytes []byte + err error + ) + + switch enc { + case EncodingBase64, "b64": + psbtBytes = make([]byte, base64.StdEncoding.DecodedLen(len(psbtB))) + _, err = base64.StdEncoding.Decode(psbtBytes, psbtB) + case EncodingHex: + psbtBytes = make([]byte, hex.DecodedLen(len(psbtB))) + _, err = hex.Decode(psbtBytes, psbtB) + default: + return nil, errors.Wrap(errs.Unsupported, "invalid encoding") + } + if err != nil { + return nil, errors.Wrap(err, "can't decode psbt string") + } + + pC, err := psbt.NewFromRawBytes(bytes.NewReader(psbtBytes), false) + if err != nil { + return nil, errors.Wrap(err, "can't create psbt from given psbt") + } + + return pC, nil +} + +// EncodeToString encodes a psbt.Packet into a psbt hex/base64 string +// +// encoding is optional, default is EncodingHex +func EncodeToString(pC *psbt.Packet, encoding ...Encoding) (string, error) { + enc, ok := utils.Optional(encoding) + if !ok { + enc = DefaultEncoding + } + + var buf bytes.Buffer + if err := pC.Serialize(&buf); err != nil { + return "", errors.Wrap(err, "can't serialize psbt") + } + + switch enc { + case EncodingBase64, "b64": + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil + case EncodingHex: + return hex.EncodeToString(buf.Bytes()), nil + default: + return "", errors.Wrap(errs.Unsupported, "invalid encoding") + } +} diff --git a/pkg/btcutils/psbtutils/fee.go b/pkg/btcutils/psbtutils/fee.go new file mode 100644 index 0000000..6c59036 --- /dev/null +++ b/pkg/btcutils/psbtutils/fee.go @@ -0,0 +1,110 @@ +package psbtutils + +import ( + "math" + + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/common/errs" + "github.com/gaze-network/indexer-network/pkg/btcutils" +) + +// TxFee returns satoshis fee of a transaction given the fee rate (sat/vB) +// and the number of inputs and outputs. +func TxFee(feeRate int64, p *psbt.Packet) (int64, error) { + size, err := PSBTSize(p) + if err != nil { + return 0, errors.Wrap(err, "psbt size") + } + return int64(math.Ceil(size * float64(feeRate))), nil +} + +func PredictTxFee(feeRate int64, inputs, outputs int) int64 { + /** + TODO: handle edge cases like: + 1. when we predict that we need to use unnecessary UTXOs + 2. when we predict that we need to use more value than user have, but user do have enough for the actual transaction + + Idea for solving this: + - When trying to find the best UTXOs to use, we: + - Will not reject when user's balance is not enough, instead we will return all UTXOs even if it's not enough. + - Will be okay returning excessive UTXOs (say we predict we need 10K satoshis, but actually we only need 5K satoshis, then we will return UTXOs enough for 10K satoshis) + - And then we: + - Construct the actual PSBT, then select UTXOs to use accordingly, + - If the user's balance is not enough, then we will return an error, + - Or if when we predict we expect to use more UTXOs than the actual transaction, then we will just use what's needed. + */ + size := defaultOverhead + 148*float64(inputs) + 43*float64(outputs) + return int64(math.Ceil(size * float64(feeRate))) +} + +type txSize struct { + Overhead float64 + Inputs float64 + Outputs float64 +} + +const defaultOverhead = 10.5 + +// Transaction Virtual Sizes Bytes +// +// Reference: https://bitcoinops.org/en/tools/calc-size/ +var txSizes = map[btcutils.TransactionType]txSize{ + btcutils.TransactionP2WPKH: { + Inputs: 68, + Outputs: 31, + }, + btcutils.TransactionP2TR: { + Inputs: 57.5, + Outputs: 43, + }, + btcutils.TransactionP2SH: { + Inputs: 91, + Outputs: 32, + }, + btcutils.TransactionP2PKH: { + Inputs: 148, + Outputs: 34, + }, + btcutils.TransactionP2WSH: { + Inputs: 104.5, + Outputs: 43, + }, +} + +func PSBTSize(psbt *psbt.Packet) (float64, error) { + if err := psbt.SanityCheck(); err != nil { + return 0, errors.Wrap(errors.Join(err, errs.InvalidArgument), "psbt sanity check") + } + + inputs := map[btcutils.TransactionType]int{} + outputs := map[btcutils.TransactionType]int{} + + for _, input := range psbt.Inputs { + addrType, err := btcutils.GetAddressTypeFromPkScript(input.WitnessUtxo.PkScript) + if err != nil { + return 0, errors.Wrap(err, "get address type from pk script") + } + inputs[addrType]++ + } + + for _, output := range psbt.UnsignedTx.TxOut { + addrType, err := btcutils.GetAddressTypeFromPkScript(output.PkScript) + if err != nil { + return 0, errors.Wrap(err, "get address type from pk script") + } + outputs[addrType]++ + } + + totalSize := defaultOverhead + for txType, txSizeData := range txSizes { + if inputCount, ok := inputs[txType]; ok { + totalSize += txSizeData.Inputs * float64(inputCount) + } + if outputCount, ok := outputs[txType]; ok { + totalSize += txSizeData.Outputs * float64(outputCount) + } + } + + return totalSize, nil +} diff --git a/pkg/btcutils/psbtutils/fee_test.go b/pkg/btcutils/psbtutils/fee_test.go new file mode 100644 index 0000000..cd67316 --- /dev/null +++ b/pkg/btcutils/psbtutils/fee_test.go @@ -0,0 +1,131 @@ +package psbtutils_test + +import ( + "fmt" + "math" + "testing" + + "github.com/gaze-network/indexer-network/pkg/btcutils/psbtutils" + "github.com/stretchr/testify/assert" +) + +func TestPSBTSize(t *testing.T) { + type Spec struct { + Name string + PSBTString string + ExpectedError error + ExpectedSize float64 + } + + specs := []Spec{ + { + Name: "3-inputs-3-outputs-taproot", + PSBTString: "70736274ff0100fd06010100000003866c72cfeef533940eaee49b68778e6223914ea671411ec387bdb61f620889910000000000ffffffff866c72cfeef533940eaee49b68778e6223914ea671411ec387bdb61f620889910100000000ffffffff866c72cfeef533940eaee49b68778e6223914ea671411ec387bdb61f620889910200000000ffffffff03b0040000000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f22020000000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f4d370f00000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f000000000001012b58020000000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f0001012b58020000000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f0001012bcb3c0f00000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f00000000", + ExpectedError: nil, + ExpectedSize: 312, + }, + { + Name: "mixed-segwit-taproot", + PSBTString: "70736274ff0100fd230202000000061f34960fef4e73c3c4c023f303c16e06f0eebb268bc0d3bac99fa78c031a45b90300000000ffffffff1f34960fef4e73c3c4c023f303c16e06f0eebb268bc0d3bac99fa78c031a45b90400000000ffffffff21c8ec368f2aff1a7baf4964e4070f52e7247ae39edfbda3976f8df4da1b72a00000000000ffffffff969e65b705e3d5071f1743a63381b3aa1ec31e1dbbbd63ab594a19ca399a58af0000000000ffffffffcca5cfd28bd6c54a851d97d029560b3047f7c6482fda7b2f2603d56ade8c95890000000000ffffffff1f34960fef4e73c3c4c023f303c16e06f0eebb268bc0d3bac99fa78c031a45b90500000000ffffffff0908070000000000001600144850d32c3ff585403790507793125d174a5c28e022020000000000001600144850d32c3ff585403790507793125d174a5c28e022020000000000001600144850d32c3ff585403790507793125d174a5c28e0b03600000000000016001459805fc1fdb9f05e190db569987c95c4f9deaa532a680000000000002251203a9ddeb6a2a327fed0f50d18778b28168e3ddb7fdfd4b05f4e438c9174d76a8d58020000000000001600144850d32c3ff585403790507793125d174a5c28e058020000000000001600144850d32c3ff585403790507793125d174a5c28e058020000000000001600144850d32c3ff585403790507793125d174a5c28e0b21f1e00000000001600144850d32c3ff585403790507793125d174a5c28e0000000000001011f58020000000000001600144850d32c3ff585403790507793125d174a5c28e00001011f58020000000000001600144850d32c3ff585403790507793125d174a5c28e00001011f58020000000000001600144850d32c3ff585403790507793125d174a5c28e00001011f220200000000000016001459805fc1fdb9f05e190db569987c95c4f9deaa53010304830000000001012b22020000000000002251203a9ddeb6a2a327fed0f50d18778b28168e3ddb7fdfd4b05f4e438c9174d76a8d010304830000000001011f06432000000000001600144850d32c3ff585403790507793125d174a5c28e000000000000000000000", + ExpectedError: nil, + ExpectedSize: 699, + }, + { + Name: "segwit-transfer-to-legacy", + PSBTString: "70736274ff010074020000000124ba4becfc732f3b4729784a3dd0cc2494ae890d826377fd98aeb0607feb1ace0100000000ffffffff0210270000000000001976a91422bae94117be666b593916527d55bdaf030d756e88ac25f62e000000000016001476d1e072c9b8a18fa1e4be697c175e0c642026ac000000000001011fc51d2f000000000016001476d1e072c9b8a18fa1e4be697c175e0c642026ac01086b024730440220759df9d109298a1ef69b9faa1786f4118f0d4d63a68cd2061e217b6090573f62022053ffa117fc21e5bf20e7d16bb786de52dc0214c9a21af87b4e92a639ef66e997012103e0cb213a46a68b1f463a4858635ee44694ce4b512788833d629840341b1219c9000000", + ExpectedError: nil, + ExpectedSize: 143.5, + }, + } + + for _, spec := range specs { + t.Run(spec.Name, func(t *testing.T) { + p, err := psbtutils.DecodeString(spec.PSBTString) + assert.NoError(t, err) + size, err := psbtutils.PSBTSize(p) + if spec.ExpectedError != nil { + assert.ErrorIs(t, err, spec.ExpectedError) + } else { + assert.Equal(t, spec.ExpectedSize, size) + } + }) + } +} + +func TestPredictTxFee(t *testing.T) { + type Spec struct { + FeeRate int64 + InputsCount int + OutputsCount int + ExpectedFee int64 + } + + specs := []Spec{ + { + FeeRate: 100, + InputsCount: 1, + OutputsCount: 1, + ExpectedFee: int64(math.Ceil((10.5 + 148 + 43) * 100)), + }, + { + FeeRate: 1, + InputsCount: 99, + OutputsCount: 99, + ExpectedFee: int64(math.Ceil((10.5 + (99 * 148) + (99 * 43)) * 1)), + }, + } + + for _, spec := range specs { + t.Run(fmt.Sprintf("feeRate=%d:inputs=%d:outputs=%d", spec.FeeRate, spec.InputsCount, spec.OutputsCount), func(t *testing.T) { + fee := psbtutils.PredictTxFee(spec.FeeRate, spec.InputsCount, spec.OutputsCount) + assert.Equal(t, spec.ExpectedFee, fee) + }) + } +} + +func TestTxFee(t *testing.T) { + type Spec struct { + Name string + FeeRate int64 + PSBTString string + ExpectedError error + ExpectedFee int64 + } + + specs := []Spec{ + { + Name: "3-inputs-3-outputs-taproot", + FeeRate: 10, + PSBTString: "70736274ff0100fd06010100000003866c72cfeef533940eaee49b68778e6223914ea671411ec387bdb61f620889910000000000ffffffff866c72cfeef533940eaee49b68778e6223914ea671411ec387bdb61f620889910100000000ffffffff866c72cfeef533940eaee49b68778e6223914ea671411ec387bdb61f620889910200000000ffffffff03b0040000000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f22020000000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f4d370f00000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f000000000001012b58020000000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f0001012b58020000000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f0001012bcb3c0f00000000002251205b954b2f91ded08c553551037bc71265a69a7586855ba4fdcf785a2494f0c37f00000000", + ExpectedError: nil, + ExpectedFee: 312 * 10, + }, + { + Name: "mixed-segwit-taproot", + FeeRate: 20, + PSBTString: "70736274ff0100fd230202000000061f34960fef4e73c3c4c023f303c16e06f0eebb268bc0d3bac99fa78c031a45b90300000000ffffffff1f34960fef4e73c3c4c023f303c16e06f0eebb268bc0d3bac99fa78c031a45b90400000000ffffffff21c8ec368f2aff1a7baf4964e4070f52e7247ae39edfbda3976f8df4da1b72a00000000000ffffffff969e65b705e3d5071f1743a63381b3aa1ec31e1dbbbd63ab594a19ca399a58af0000000000ffffffffcca5cfd28bd6c54a851d97d029560b3047f7c6482fda7b2f2603d56ade8c95890000000000ffffffff1f34960fef4e73c3c4c023f303c16e06f0eebb268bc0d3bac99fa78c031a45b90500000000ffffffff0908070000000000001600144850d32c3ff585403790507793125d174a5c28e022020000000000001600144850d32c3ff585403790507793125d174a5c28e022020000000000001600144850d32c3ff585403790507793125d174a5c28e0b03600000000000016001459805fc1fdb9f05e190db569987c95c4f9deaa532a680000000000002251203a9ddeb6a2a327fed0f50d18778b28168e3ddb7fdfd4b05f4e438c9174d76a8d58020000000000001600144850d32c3ff585403790507793125d174a5c28e058020000000000001600144850d32c3ff585403790507793125d174a5c28e058020000000000001600144850d32c3ff585403790507793125d174a5c28e0b21f1e00000000001600144850d32c3ff585403790507793125d174a5c28e0000000000001011f58020000000000001600144850d32c3ff585403790507793125d174a5c28e00001011f58020000000000001600144850d32c3ff585403790507793125d174a5c28e00001011f58020000000000001600144850d32c3ff585403790507793125d174a5c28e00001011f220200000000000016001459805fc1fdb9f05e190db569987c95c4f9deaa53010304830000000001012b22020000000000002251203a9ddeb6a2a327fed0f50d18778b28168e3ddb7fdfd4b05f4e438c9174d76a8d010304830000000001011f06432000000000001600144850d32c3ff585403790507793125d174a5c28e000000000000000000000", + ExpectedError: nil, + ExpectedFee: 699 * 20, + }, + { + Name: "segwit-transfer-to-legacy", + FeeRate: 99, + PSBTString: "70736274ff010074020000000124ba4becfc732f3b4729784a3dd0cc2494ae890d826377fd98aeb0607feb1ace0100000000ffffffff0210270000000000001976a91422bae94117be666b593916527d55bdaf030d756e88ac25f62e000000000016001476d1e072c9b8a18fa1e4be697c175e0c642026ac000000000001011fc51d2f000000000016001476d1e072c9b8a18fa1e4be697c175e0c642026ac01086b024730440220759df9d109298a1ef69b9faa1786f4118f0d4d63a68cd2061e217b6090573f62022053ffa117fc21e5bf20e7d16bb786de52dc0214c9a21af87b4e92a639ef66e997012103e0cb213a46a68b1f463a4858635ee44694ce4b512788833d629840341b1219c9000000", + ExpectedError: nil, + ExpectedFee: int64(math.Ceil((143.5) * 99)), + }, + } + + for _, spec := range specs { + t.Run(spec.Name, func(t *testing.T) { + p, err := psbtutils.DecodeString(spec.PSBTString) + assert.NoError(t, err) + fee, err := psbtutils.TxFee(spec.FeeRate, p) + if spec.ExpectedError != nil { + assert.ErrorIs(t, err, spec.ExpectedError) + } else { + assert.Equal(t, spec.ExpectedFee, fee) + } + }) + } +} diff --git a/pkg/btcutils/psbtutils/is_ready.go b/pkg/btcutils/psbtutils/is_ready.go new file mode 100644 index 0000000..f775759 --- /dev/null +++ b/pkg/btcutils/psbtutils/is_ready.go @@ -0,0 +1,35 @@ +package psbtutils + +import ( + "github.com/btcsuite/btcd/btcutil/psbt" + "github.com/btcsuite/btcd/wire" + "github.com/cockroachdb/errors" + "github.com/samber/lo" +) + +func IsReadyPSBT(pC *psbt.Packet, feeRate int64) (bool, error) { + // if input = output + fee then it's ready + + // Calculate tx fee + fee, err := TxFee(feeRate, pC) + if err != nil { + return false, errors.Wrap(err, "calculate fee") + } + + // sum total input and output + totalInputValue := lo.SumBy(pC.Inputs, func(input psbt.PInput) int64 { return input.WitnessUtxo.Value }) + totalOutputValue := lo.SumBy(pC.UnsignedTx.TxOut, func(txout *wire.TxOut) int64 { return txout.Value }) + fee + + // it's perfect match + if totalInputValue == totalOutputValue { + return true, nil + } + + // if input is more than output + fee but not more than 1000 satoshi, + // then it's ready + if totalInputValue > totalOutputValue && totalInputValue-totalOutputValue < 1000 { + return true, nil + } + + return false, nil +} diff --git a/pkg/btcutils/signature.go b/pkg/btcutils/signature.go new file mode 100644 index 0000000..33634e6 --- /dev/null +++ b/pkg/btcutils/signature.go @@ -0,0 +1,21 @@ +package btcutils + +import ( + "github.com/Cleverse/go-utilities/utils" + verifier "github.com/bitonicnl/verify-signed-message/pkg" + "github.com/btcsuite/btcd/chaincfg" + "github.com/cockroachdb/errors" +) + +func VerifySignature(address string, message string, sigBase64 string, defaultNet ...*chaincfg.Params) error { + net := utils.DefaultOptional(defaultNet, &chaincfg.MainNetParams) + _, err := verifier.VerifyWithChain(verifier.SignedMessage{ + Address: address, + Message: message, + Signature: sigBase64, + }, net) + if err != nil { + return errors.WithStack(err) + } + return nil +} diff --git a/pkg/btcutils/signature_test.go b/pkg/btcutils/signature_test.go new file mode 100644 index 0000000..304746a --- /dev/null +++ b/pkg/btcutils/signature_test.go @@ -0,0 +1,69 @@ +package btcutils + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/stretchr/testify/assert" +) + +func TestVerifySignature(t *testing.T) { + { + message := "Test123" + address := "18J72YSM9pKLvyXX1XAjFXA98zeEvxBYmw" + signature := "Gzhfsw0ItSrrTCChykFhPujeTyAcvVxiXwywxpHmkwFiKuUR2ETbaoFcocmcSshrtdIjfm8oXlJoTOLosZp3Yc8=" + network := &chaincfg.MainNetParams + + err := VerifySignature(address, message, signature, network) + assert.NoError(t, err) + } + { + address := "tb1qr97cuq4kvq7plfetmxnl6kls46xaka78n2288z" + message := "The outage comes at a time when bitcoin has been fast approaching new highs not seen since June 26, 2019." + signature := "H/bSByRH7BW1YydfZlEx9x/nt4EAx/4A691CFlK1URbPEU5tJnTIu4emuzkgZFwC0ptvKuCnyBThnyLDCqPqT10=" + network := &chaincfg.TestNet3Params + + err := VerifySignature(address, message, signature, network) + assert.NoError(t, err) + } + { + // Missmatch address + address := "tb1qp7y2ywgrv8a4t9h47yphtgj8w759rk6vgd9ran" + message := "The outage comes at a time when bitcoin has been fast approaching new highs not seen since June 26, 2019." + signature := "H/bSByRH7BW1YydfZlEx9x/nt4EAx/4A691CFlK1URbPEU5tJnTIu4emuzkgZFwC0ptvKuCnyBThnyLDCqPqT10=" + network := &chaincfg.TestNet3Params + + err := VerifySignature(address, message, signature, network) + assert.Error(t, err) + } + { + // Missmatch signature + address := "tb1qr97cuq4kvq7plfetmxnl6kls46xaka78n2288z" + message := "The outage comes at a time when bitcoin has been fast approaching new highs not seen since June 26, 2019." + signature := "Gzhfsw0ItSrrTCChykFhPujeTyAcvVxiXwywxpHmkwFiKuUR2ETbaoFcocmcSshrtdIjfm8oXlJoTOLosZp3Yc8=" + network := &chaincfg.TestNet3Params + + err := VerifySignature(address, message, signature, network) + assert.Error(t, err) + } + { + // Missmatch message + address := "tb1qr97cuq4kvq7plfetmxnl6kls46xaka78n2288z" + message := "Hello World" + signature := "H/bSByRH7BW1YydfZlEx9x/nt4EAx/4A691CFlK1URbPEU5tJnTIu4emuzkgZFwC0ptvKuCnyBThnyLDCqPqT10=" + network := &chaincfg.TestNet3Params + + err := VerifySignature(address, message, signature, network) + assert.Error(t, err) + } + { + // Missmatch network + address := "tb1qr97cuq4kvq7plfetmxnl6kls46xaka78n2288z" + message := "The outage comes at a time when bitcoin has been fast approaching new highs not seen since June 26, 2019." + signature := "H/bSByRH7BW1YydfZlEx9x/nt4EAx/4A691CFlK1URbPEU5tJnTIu4emuzkgZFwC0ptvKuCnyBThnyLDCqPqT10=" + network := &chaincfg.MainNetParams + + err := VerifySignature(address, message, signature, network) + assert.Error(t, err) + } +} diff --git a/pkg/btcutils/transaction.go b/pkg/btcutils/transaction.go new file mode 100644 index 0000000..843ecab --- /dev/null +++ b/pkg/btcutils/transaction.go @@ -0,0 +1,10 @@ +package btcutils + +const ( + // TxVersion is the current latest supported transaction version. + TxVersion = 2 + + // MaxTxInSequenceNum is the maximum sequence number the sequence field + // of a transaction input can be. + MaxTxInSequenceNum uint32 = 0xffffffff +) diff --git a/pkg/decimals/decimals.go b/pkg/decimals/decimals.go new file mode 100644 index 0000000..4719a1f --- /dev/null +++ b/pkg/decimals/decimals.go @@ -0,0 +1,106 @@ +package decimals + +import ( + "math" + "math/big" + "reflect" + + "github.com/Cleverse/go-utilities/utils" + "github.com/gaze-network/indexer-network/pkg/logger" + "github.com/gaze-network/indexer-network/pkg/logger/slogx" + "github.com/gaze-network/uint128" + "github.com/holiman/uint256" + "github.com/jackc/pgx/v5/pgtype" + "github.com/shopspring/decimal" + "golang.org/x/exp/constraints" +) + +const ( + DefaultDivPrecision = 36 +) + +func init() { + decimal.DivisionPrecision = DefaultDivPrecision +} + +// MustFromString convert string to decimal.Decimal. Panic if error +// string must be a valid number, not NaN, Inf or empty string. +func MustFromString(s string) decimal.Decimal { + return utils.Must(decimal.NewFromString(s)) +} + +// ToDecimal convert any type to decimal.Decimal (safety floating point) +func ToDecimal[T constraints.Integer](ivalue any, decimals T) decimal.Decimal { + value := new(big.Int) + switch v := ivalue.(type) { + case string: + value.SetString(v, 10) + case *big.Int: + value = v + case int64: + value = big.NewInt(v) + case int, int8, int16, int32: + rValue := reflect.ValueOf(v) + value.SetInt64(rValue.Int()) + case uint64: + value = big.NewInt(0).SetUint64(v) + case uint, uint8, uint16, uint32: + rValue := reflect.ValueOf(v) + value.SetUint64(rValue.Uint()) + case []byte: + value.SetBytes(v) + case uint128.Uint128: + value = v.Big() + case uint256.Int: + value = v.ToBig() + case *uint256.Int: + value = v.ToBig() + } + + switch { + case int64(decimals) > math.MaxInt32: + logger.Panic("ToDecimal: decimals is too big, should be equal less than 2^31-1", slogx.Any("decimals", decimals)) + case int64(decimals) < math.MinInt32+1: + logger.Panic("ToDecimal: decimals is too small, should be greater than -2^31", slogx.Any("decimals", decimals)) + } + + return decimal.NewFromBigInt(value, -int32(decimals)) +} + +// ToBigInt convert any type to *big.Int +func ToBigInt(iamount any, decimals uint16) *big.Int { + amount := decimal.NewFromFloat(0) + switch v := iamount.(type) { + case string: + amount, _ = decimal.NewFromString(v) + case float64: + amount = decimal.NewFromFloat(v) + case float32: + amount = decimal.NewFromFloat32(v) + case int64: + amount = decimal.NewFromInt(v) + case int, int8, int16, int32: + rValue := reflect.ValueOf(v) + amount = decimal.NewFromInt(rValue.Int()) + case decimal.Decimal: + amount = v + case *decimal.Decimal: + amount = *v + case big.Float: + amount, _ = decimal.NewFromString(v.String()) + case *big.Float: + amount, _ = decimal.NewFromString(v.String()) + case pgtype.Numeric: + amount = decimal.NewFromBigInt(v.Int, v.Exp) + } + return amount.Mul(PowerOfTen(decimals)).BigInt() +} + +// ToUint256 convert any type to *uint256.Int +func ToUint256(iamount any, decimals uint16) *uint256.Int { + result := new(uint256.Int) + if overflow := result.SetFromBig(ToBigInt(iamount, decimals)); overflow { + logger.Panic("ToUint256: overflow", slogx.Any("amount", iamount), slogx.Uint16("decimals", decimals)) + } + return result +} diff --git a/pkg/decimals/decimals_test.go b/pkg/decimals/decimals_test.go new file mode 100644 index 0000000..ad0510b --- /dev/null +++ b/pkg/decimals/decimals_test.go @@ -0,0 +1,86 @@ +package decimals + +import ( + "fmt" + "math" + "math/big" + "testing" + + "github.com/gaze-network/uint128" + "github.com/holiman/uint256" + "github.com/stretchr/testify/assert" +) + +func TestToDecimal(t *testing.T) { + t.Run("overflow_decimals", func(t *testing.T) { + assert.NotPanics(t, func() { ToDecimal(1, math.MaxInt32-1) }, "in-range decimals shouldn't panic") + assert.NotPanics(t, func() { ToDecimal(1, math.MinInt32+1) }, "in-range decimals shouldn't panic") + assert.Panics(t, func() { ToDecimal(1, math.MaxInt32+1) }, "out of range decimals should panic") + assert.Panics(t, func() { ToDecimal(1, math.MinInt32) }, "out of range decimals should panic") + }) + t.Run("check_supported_types", func(t *testing.T) { + testcases := []struct { + decimals uint16 + value uint64 + expected string + }{ + {0, 1, "1"}, + {1, 1, "0.1"}, + {2, 1, "0.01"}, + {3, 1, "0.001"}, + {18, 1, "0.000000000000000001"}, + {36, 1, "0.000000000000000000000000000000000001"}, + } + typesConv := []func(uint64) any{ + func(i uint64) any { return int(i) }, + func(i uint64) any { return int8(i) }, + func(i uint64) any { return int16(i) }, + func(i uint64) any { return int32(i) }, + func(i uint64) any { return int64(i) }, + func(i uint64) any { return uint(i) }, + func(i uint64) any { return uint8(i) }, + func(i uint64) any { return uint16(i) }, + func(i uint64) any { return uint32(i) }, + func(i uint64) any { return uint64(i) }, + func(i uint64) any { return fmt.Sprint(i) }, + func(i uint64) any { return new(big.Int).SetUint64(i) }, + func(i uint64) any { return new(uint128.Uint128).Add64(i) }, + func(i uint64) any { return uint256.NewInt(i) }, + } + for _, tc := range testcases { + t.Run(fmt.Sprintf("%d_%d", tc.decimals, tc.value), func(t *testing.T) { + for _, conv := range typesConv { + input := conv(tc.value) + t.Run(fmt.Sprintf("%T", input), func(t *testing.T) { + actual := ToDecimal(input, tc.decimals) + assert.Equal(t, tc.expected, actual.String()) + }) + } + }) + } + }) + + testcases := []struct { + decimals uint16 + value interface{} + expected string + }{ + {0, uint64(math.MaxUint64), "18446744073709551615"}, + {18, uint64(math.MaxUint64), "18.446744073709551615"}, + {36, uint64(math.MaxUint64), "0.000000000000000018446744073709551615"}, + /* max uint128 */ + {0, uint128.Max, "340282366920938463463374607431768211455"}, + {18, uint128.Max, "340282366920938463463.374607431768211455"}, + {36, uint128.Max, "340.282366920938463463374607431768211455"}, + /* max uint256 */ + {0, new(uint256.Int).SetAllOne(), "115792089237316195423570985008687907853269984665640564039457584007913129639935"}, + {18, new(uint256.Int).SetAllOne(), "115792089237316195423570985008687907853269984665640564039457.584007913129639935"}, + {36, new(uint256.Int).SetAllOne(), "115792089237316195423570985008687907853269.984665640564039457584007913129639935"}, + } + for _, tc := range testcases { + t.Run(fmt.Sprintf("%d_%s", tc.decimals, tc.value), func(t *testing.T) { + actual := ToDecimal(tc.value, tc.decimals) + assert.Equal(t, tc.expected, actual.String()) + }) + } +} diff --git a/pkg/decimals/power_of_ten.go b/pkg/decimals/power_of_ten.go new file mode 100644 index 0000000..fa5e766 --- /dev/null +++ b/pkg/decimals/power_of_ten.go @@ -0,0 +1,97 @@ +package decimals + +import ( + "github.com/shopspring/decimal" + "golang.org/x/exp/constraints" +) + +// max precision is 36 +const ( + minPowerOfTen = -DefaultDivPrecision + maxPowerOfTen = DefaultDivPrecision +) + +var powerOfTen = map[int64]decimal.Decimal{ + minPowerOfTen: MustFromString("0.000000000000000000000000000000000001"), + -35: MustFromString("0.00000000000000000000000000000000001"), + -34: MustFromString("0.0000000000000000000000000000000001"), + -33: MustFromString("0.000000000000000000000000000000001"), + -32: MustFromString("0.00000000000000000000000000000001"), + -31: MustFromString("0.0000000000000000000000000000001"), + -30: MustFromString("0.000000000000000000000000000001"), + -29: MustFromString("0.00000000000000000000000000001"), + -28: MustFromString("0.0000000000000000000000000001"), + -27: MustFromString("0.000000000000000000000000001"), + -26: MustFromString("0.00000000000000000000000001"), + -25: MustFromString("0.0000000000000000000000001"), + -24: MustFromString("0.000000000000000000000001"), + -23: MustFromString("0.00000000000000000000001"), + -22: MustFromString("0.0000000000000000000001"), + -21: MustFromString("0.000000000000000000001"), + -20: MustFromString("0.00000000000000000001"), + -19: MustFromString("0.0000000000000000001"), + -18: MustFromString("0.000000000000000001"), + -17: MustFromString("0.00000000000000001"), + -16: MustFromString("0.0000000000000001"), + -15: MustFromString("0.000000000000001"), + -14: MustFromString("0.00000000000001"), + -13: MustFromString("0.0000000000001"), + -12: MustFromString("0.000000000001"), + -11: MustFromString("0.00000000001"), + -10: MustFromString("0.0000000001"), + -9: MustFromString("0.000000001"), + -8: MustFromString("0.00000001"), + -7: MustFromString("0.0000001"), + -6: MustFromString("0.000001"), + -5: MustFromString("0.00001"), + -4: MustFromString("0.0001"), + -3: MustFromString("0.001"), + -2: MustFromString("0.01"), + -1: MustFromString("0.1"), + 0: MustFromString("1"), + 1: MustFromString("10"), + 2: MustFromString("100"), + 3: MustFromString("1000"), + 4: MustFromString("10000"), + 5: MustFromString("100000"), + 6: MustFromString("1000000"), + 7: MustFromString("10000000"), + 8: MustFromString("100000000"), + 9: MustFromString("1000000000"), + 10: MustFromString("10000000000"), + 11: MustFromString("100000000000"), + 12: MustFromString("1000000000000"), + 13: MustFromString("10000000000000"), + 14: MustFromString("100000000000000"), + 15: MustFromString("1000000000000000"), + 16: MustFromString("10000000000000000"), + 17: MustFromString("100000000000000000"), + 18: MustFromString("1000000000000000000"), + 19: MustFromString("10000000000000000000"), + 20: MustFromString("100000000000000000000"), + 21: MustFromString("1000000000000000000000"), + 22: MustFromString("10000000000000000000000"), + 23: MustFromString("100000000000000000000000"), + 24: MustFromString("1000000000000000000000000"), + 25: MustFromString("10000000000000000000000000"), + 26: MustFromString("100000000000000000000000000"), + 27: MustFromString("1000000000000000000000000000"), + 28: MustFromString("10000000000000000000000000000"), + 29: MustFromString("100000000000000000000000000000"), + 30: MustFromString("1000000000000000000000000000000"), + 31: MustFromString("10000000000000000000000000000000"), + 32: MustFromString("100000000000000000000000000000000"), + 33: MustFromString("1000000000000000000000000000000000"), + 34: MustFromString("10000000000000000000000000000000000"), + 35: MustFromString("100000000000000000000000000000000000"), + maxPowerOfTen: MustFromString("1000000000000000000000000000000000000"), +} + +// PowerOfTen optimized arithmetic performance for 10^n. +func PowerOfTen[T constraints.Integer](n T) decimal.Decimal { + nInt64 := int64(n) + if val, ok := powerOfTen[nInt64]; ok { + return val + } + return powerOfTen[1].Pow(decimal.NewFromInt(nInt64)) +} diff --git a/pkg/decimals/power_of_ten_test.go b/pkg/decimals/power_of_ten_test.go new file mode 100644 index 0000000..3d5c2c4 --- /dev/null +++ b/pkg/decimals/power_of_ten_test.go @@ -0,0 +1,44 @@ +package decimals + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPowerOfTen(t *testing.T) { + for n := int64(-36); n <= 36; n++ { + t.Run(fmt.Sprint(n), func(t *testing.T) { + expected := powerOfTenString(n) + actual := PowerOfTen(n) + assert.Equal(t, expected, actual.String()) + }) + } + t.Run("constants", func(t *testing.T) { + for n, p := range powerOfTen { + t.Run(p.String(), func(t *testing.T) { + require.False(t, p.IsZero(), "power of ten must not be zero") + actual := PowerOfTen(n) + assert.Equal(t, p, actual) + }) + } + }) +} + +// powerOfTenString add zero padding to power of ten string +func powerOfTenString(n int64) string { + s := "1" + if n < 0 { + for i := int64(0); i < -n-1; i++ { + s = "0" + s + } + s = "0." + s + } else { + for i := int64(0); i < n; i++ { + s = s + "0" + } + } + return s +} diff --git a/pkg/middleware/requestcontext/PROXY-IP.md b/pkg/middleware/requestcontext/PROXY-IP.md new file mode 100644 index 0000000..f91cf4e --- /dev/null +++ b/pkg/middleware/requestcontext/PROXY-IP.md @@ -0,0 +1,7 @@ +# Proxies IP Range Resources + +- Cloudflare - https://www.cloudflare.com/ips/ +- GCP Load Balancer - https://cloud.google.com/load-balancing/docs/health-check-concepts#ip-ranges +- GCP Compute Engine, Customer-usable external IP address ranges - https://www.gstatic.com/ipranges/cloud.json +- Other GCP Services - https://cloud.google.com/compute/docs/faq#networking +- Other Resources - https://github.com/lord-alfred/ipranges diff --git a/pkg/middleware/requestcontext/errors.go b/pkg/middleware/requestcontext/errors.go new file mode 100644 index 0000000..e1149ea --- /dev/null +++ b/pkg/middleware/requestcontext/errors.go @@ -0,0 +1,21 @@ +package requestcontext + +// requestcontextError implements error interface +var _ error = requestcontextError{} + +type requestcontextError struct { + err error + status int + message string +} + +func (r requestcontextError) Error() string { + if r.err != nil { + return r.err.Error() + } + return r.message +} + +func (r requestcontextError) Unwrap() error { + return r.err +} diff --git a/pkg/middleware/requestcontext/requestcontext.go b/pkg/middleware/requestcontext/requestcontext.go new file mode 100644 index 0000000..39d6622 --- /dev/null +++ b/pkg/middleware/requestcontext/requestcontext.go @@ -0,0 +1,44 @@ +package requestcontext + +import ( + "context" + "log/slog" + "net/http" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/pkg/logger" + "github.com/gofiber/fiber/v2" +) + +type Response struct { + Result any `json:"result"` + Error string `json:"error,omitempty"` +} + +type Option func(ctx context.Context, c *fiber.Ctx) (context.Context, error) + +func New(opts ...Option) fiber.Handler { + return func(c *fiber.Ctx) error { + var err error + ctx := c.UserContext() + for i, opt := range opts { + ctx, err = opt(ctx, c) + if err != nil { + rErr := requestcontextError{} + if errors.As(err, &rErr) { + return c.Status(rErr.status).JSON(Response{Error: rErr.message}) + } + + logger.ErrorContext(ctx, "failed to extract request context", + err, + slog.String("event", "requestcontext/error"), + slog.String("module", "requestcontext"), + slog.Int("optionIndex", i), + ) + return c.Status(http.StatusInternalServerError).JSON(Response{Error: "internal server error"}) + } + } + c.SetUserContext(ctx) + return c.Next() + } +} diff --git a/pkg/middleware/requestcontext/with_clientip.go b/pkg/middleware/requestcontext/with_clientip.go new file mode 100644 index 0000000..3814db5 --- /dev/null +++ b/pkg/middleware/requestcontext/with_clientip.go @@ -0,0 +1,150 @@ +package requestcontext + +import ( + "context" + "log/slog" + "net" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/pkg/logger" + "github.com/gofiber/fiber/v2" +) + +type clientIPKey struct{} + +type WithClientIPConfig struct { + // [Optional] TrustedProxiesIP is a list of all proxies IP ranges that's between the server and the client. + // + // If it's provided, it will walk backwards from the last IP in `X-Forwarded-For` header + // and use first IP that's not trusted proxy(not in the given IP ranges.) + // + // **If you want to use this option, you should provide all of probable proxies IP ranges.** + // + // This is lowest priority. + TrustedProxiesIP []string `env:"TRUSTED_PROXIES_IP" mapstructure:"trusted_proxies_ip"` + + // [Optional] TrustedHeader is a header name for getting client IP. (e.g. X-Real-IP, CF-Connecting-IP, etc.) + // + // This is highest priority, it will ignore rest of the options if it's provided. + TrustedHeader string `env:"TRUSTED_HEADER" mapstructure:"trusted_proxies_header"` + + // EnableRejectMalformedRequest return 403 Forbidden if the request is from proxies, but can't extract client IP + EnableRejectMalformedRequest bool `env:"ENABLE_REJECT_MALFORMED_REQUEST" envDefault:"false" mapstructure:"enable_reject_malformed_request"` +} + +// WithClientIP setup client IP context with XFF Spoofing prevention support. +// +// If request is from proxies, it will use first IP from `X-Forwarded-For` header by default. +func WithClientIP(config WithClientIPConfig) Option { + var trustedProxies trustedProxy + if len(config.TrustedProxiesIP) > 0 { + proxy, err := newTrustedProxy(config.TrustedProxiesIP) + if err != nil { + logger.Panic("Failed to parse trusted proxies", err) + } + trustedProxies = proxy + } + + return func(ctx context.Context, c *fiber.Ctx) (context.Context, error) { + // Extract client IP from given header + if config.TrustedHeader != "" { + headerIP := c.Get(config.TrustedHeader) + + // validate ip from header + if ip := net.ParseIP(headerIP); ip != nil { + return context.WithValue(ctx, clientIPKey{}, headerIP), nil + } + } + + // Extract client IP from XFF header + rawIPs := c.IPs() + ips := parseIPs(rawIPs) + + // If the request is directly from client, we can use direct remote IP address + if len(ips) == 0 { + return context.WithValue(ctx, clientIPKey{}, c.IP()), nil + } + + // Walk back and find first IP that's not trusted proxy + if len(trustedProxies) > 0 { + for i := len(ips) - 1; i >= 0; i-- { + if !trustedProxies.IsTrusted(ips[i]) { + return context.WithValue(ctx, clientIPKey{}, ips[i].String()), nil + } + } + + // If all IPs are trusted proxies, return first IP in XFF header + return context.WithValue(ctx, clientIPKey{}, rawIPs[0]), nil + } + + // Finally, if we can't extract client IP, return forbidden + if config.EnableRejectMalformedRequest { + logger.WarnContext(ctx, "IP Spoofing detected, returning 403 Forbidden", + slog.String("event", "requestcontext/ip_spoofing_detected"), + slog.String("module", "requestcontext/with_clientip"), + slog.String("ip", c.IP()), + slog.Any("ips", rawIPs), + ) + return nil, requestcontextError{ + status: fiber.StatusForbidden, + message: "not allowed to access", + } + } + + // Fallback to first IP in XFF header + return context.WithValue(ctx, clientIPKey{}, rawIPs[0]), nil + } +} + +// GetClientIP get clientIP from context. If not found, return empty string +// +// Warning: Request context should be setup before using this function +func GetClientIP(ctx context.Context) string { + if ip, ok := ctx.Value(clientIPKey{}).(string); ok { + return ip + } + return "" +} + +type trustedProxy []*net.IPNet + +// newTrustedProxy create a new trusted proxies instance for preventing IP spoofing (XFF Attacks) +func newTrustedProxy(ranges []string) (trustedProxy, error) { + nets, err := parseCIDRs(ranges) + if err != nil { + return nil, errors.WithStack(err) + } + return trustedProxy(nets), nil +} + +func (t trustedProxy) IsTrusted(ip net.IP) bool { + if ip == nil { + return false + } + for _, r := range t { + if r.Contains(ip) { + return true + } + } + return false +} + +func parseCIDRs(ranges []string) ([]*net.IPNet, error) { + nets := make([]*net.IPNet, 0, len(ranges)) + for _, r := range ranges { + _, ipnet, err := net.ParseCIDR(r) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse CIDR for %q", r) + } + nets = append(nets, ipnet) + } + return nets, nil +} + +func parseIPs(ranges []string) []net.IP { + ip := make([]net.IP, 0, len(ranges)) + for _, r := range ranges { + ip = append(ip, net.ParseIP(r)) + } + return ip +} diff --git a/pkg/middleware/requestcontext/with_requestid.go b/pkg/middleware/requestcontext/with_requestid.go new file mode 100644 index 0000000..64e71c9 --- /dev/null +++ b/pkg/middleware/requestcontext/with_requestid.go @@ -0,0 +1,47 @@ +package requestcontext + +import ( + "context" + + "github.com/gaze-network/indexer-network/pkg/logger" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/requestid" + fiberutils "github.com/gofiber/fiber/v2/utils" +) + +type requestIdKey struct{} + +// GetRequestId get requestId from context. If not found, return empty string +// +// Warning: Request context should be setup before using this function +func GetRequestId(ctx context.Context) string { + if id, ok := ctx.Value(requestIdKey{}).(string); ok { + return id + } + return "" +} + +func WithRequestId() Option { + return func(ctx context.Context, c *fiber.Ctx) (context.Context, error) { + // Try to get id from fiber context. + requestId, ok := c.Locals(requestid.ConfigDefault.ContextKey).(string) + if !ok || requestId == "" { + // Try to get id from request, else we generate one + requestId = c.Get(requestid.ConfigDefault.Header, fiberutils.UUID()) + + // Set new id to response header + c.Set(requestid.ConfigDefault.Header, requestId) + + // Add the request ID to locals (fasthttp UserValue storage) + c.Locals(requestid.ConfigDefault.ContextKey, requestId) + } + + // Add the request ID to context + ctx = context.WithValue(ctx, requestIdKey{}, requestId) + + // Add the requuest ID to context logger + ctx = logger.WithContext(ctx, "requestId", requestId) + + return ctx, nil + } +} diff --git a/pkg/middleware/requestlogger/requestlogger.go b/pkg/middleware/requestlogger/requestlogger.go new file mode 100644 index 0000000..98b5552 --- /dev/null +++ b/pkg/middleware/requestlogger/requestlogger.go @@ -0,0 +1,115 @@ +package requestlogger + +import ( + "log/slog" + "net/http" + "strings" + "time" + + "github.com/cockroachdb/errors" + "github.com/gaze-network/indexer-network/pkg/logger" + "github.com/gaze-network/indexer-network/pkg/middleware/requestcontext" + "github.com/gofiber/fiber/v2" +) + +type Config struct { + WithRequestHeader bool `env:"REQUEST_HEADER" envDefault:"false" mapstructure:"request_header"` + WithRequestQuery bool `env:"REQUEST_QUERY" envDefault:"false" mapstructure:"request_query"` + Disable bool `env:"DISABLE" envDefault:"false" mapstructure:"disable"` // Disable logger level `INFO` + HiddenRequestHeaders []string `env:"HIDDEN_REQUEST_HEADERS" mapstructure:"hidden_request_headers"` +} + +// New setup request context and information +func New(config Config) fiber.Handler { + hiddenRequestHeaders := make(map[string]struct{}, len(config.HiddenRequestHeaders)) + for _, header := range config.HiddenRequestHeaders { + hiddenRequestHeaders[strings.TrimSpace(strings.ToLower(header))] = struct{}{} + } + return func(c *fiber.Ctx) error { + start := time.Now() + + // Continue stack + err := c.Next() + + end := time.Now() + latency := end.Sub(start) + status := c.Response().StatusCode() + + baseAttrs := []slog.Attr{ + slog.String("event", "api_request"), + slog.Int64("latency", latency.Milliseconds()), + slog.String("latencyHuman", latency.String()), + } + + // prep request attributes + requestAttributes := []slog.Attr{ + slog.Time("time", start), + slog.String("method", c.Method()), + slog.String("host", c.Hostname()), + slog.String("path", c.Path()), + slog.String("route", c.Route().Path), + slog.String("ip", requestcontext.GetClientIP(c.UserContext())), + slog.String("remoteIP", c.Context().RemoteIP().String()), + slog.Any("x-forwarded-for", c.IPs()), + slog.String("user-agent", string(c.Context().UserAgent())), + slog.Any("params", c.AllParams()), + slog.Int("length", len((c.Body()))), + } + + // prep response attributes + responseAttributes := []slog.Attr{ + slog.Time("time", end), + slog.Int("status", status), + slog.Int("length", len(c.Response().Body())), + } + + // request query + if config.WithRequestQuery { + requestAttributes = append(requestAttributes, slog.String("query", string(c.Request().URI().QueryString()))) + } + + // request headers + if config.WithRequestHeader { + kv := []any{} + + for k, v := range c.GetReqHeaders() { + if _, found := hiddenRequestHeaders[strings.ToLower(k)]; found { + continue + } + kv = append(kv, slog.Any(k, v)) + } + + requestAttributes = append(requestAttributes, slog.Group("header", kv...)) + } + + level := slog.LevelInfo + if err != nil || status >= http.StatusInternalServerError { + level = slog.LevelError + + // error attributes + logErr := err + if logErr == nil { + logErr = fiber.NewError(status) + } + baseAttrs = append(baseAttrs, slog.Any("error", logErr)) + } + + if config.Disable && level == slog.LevelInfo { + return errors.WithStack(err) + } + + logger.LogAttrs(c.UserContext(), level, "Request Completed", append([]slog.Attr{ + { + Key: "request", + Value: slog.GroupValue(requestAttributes...), + }, + { + Key: "response", + Value: slog.GroupValue(responseAttributes...), + }, + }, baseAttrs...)..., + ) + + return errors.WithStack(err) + } +}