commit 2d4d7759e07d2d702eaa3d70f3c07fdf390c9456 Author: Ivaylo Ivanov Date: Thu Mar 2 15:28:43 2023 +0100 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..897864a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +out/ +Makefile +README.md +data/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad25213 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +out/ +data/ +megalinter-reports/ +.env diff --git a/.mega-linter.yml b/.mega-linter.yml new file mode 100644 index 0000000..ba4d003 --- /dev/null +++ b/.mega-linter.yml @@ -0,0 +1,10 @@ +--- +ADDITIONAL_EXCLUDED_DIRECTORIES: + - data/ +CONSOLE_REPORTER: true +DISABLE: + - SPELL + - MARKDOWN + - REPOSITORY +APPLY_FIXES: + - YAML_PRETTIER diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a3129e5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM docker.io/golang:1.19 as builder +WORKDIR /usr/local/src +COPY . . +RUN CGO_ENABLED=0 go build -o kermago + +FROM scratch +COPY --from=builder /usr/local/src/kermago /kermago +EXPOSE 18018 +ENTRYPOINT ["/kermago"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3cd027e --- /dev/null +++ b/Makefile @@ -0,0 +1,59 @@ +WORKDIR=$(shell pwd) + +.PHONY: all +all: build + +.PHONY: build +build: + cd $(WORKDIR) && go build -o out/kermago + +.PHONY: test +test: + cd $(WORKDIR) && go test -v -coverpkg=./... ./... + +.PHONY: format +format: + cd $(WORKDIR) && gofmt -l -s -w . + +.PHONY: clean +clean: + rm -rf /var/local/badkerma/*.json + +.PHONY: run +run: + cd $(WORKDIR) && go run . + +.PHONY: clean-run +clean-run: clean run + +.PHONY: debug +debug: + cd $(WORKDIR) && KERMA_DEBUG=true go run . + +.PHONY: clean-debug +clean-debug: clean debug + +.PHONY: docker-build +docker-build: + docker-compose build + +.PHONY: docker-push +docker-push: docker-build + docker-compose push + +.PHONY: docker-debug +docker-debug: docker-build + KERMA_DEBUG=true docker-compose up + +.PHONY: docker-run +docker-run: docker-build + docker-compose up -d + +.PHONY: docker-stop +docker-stop: + docker-compose down + +.PHONY: lint +lint: + docker pull docker.io/oxsecurity/megalinter:latest + docker run -e DEFAULT_WORKSPACE='/src' -v "$(shell pwd):/src" docker.io/oxsecurity/megalinter:latest diff --git a/README.md b/README.md new file mode 100644 index 0000000..be85218 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +KermaGo +=== + +Prerequisites +--- + +GNU Make and docker/Go installed on the machine + +Config via environment variables +--- + +| Name | Description | Default value | +|-|-|-| +`KERMA_DEBUG` | Run in debug mode | `false` | +`KERMA_IP_ADDR` | Public IP Address to return to other nodes (if node behind port-forwarding) | `3.126.74.45` | +`KERMA_PORT` | Port to listen on | 18018 | +`KERMA_CONN_TIMEOUT` | Connection timeout | 10 | +`KERMA_INITIAL_PEER_LIST` | Comma-separated string of initial peers to connect to | `""` | +`KERMA_STORE_BASE_DIR` | Base directory to store data. The docker container will mount this under `./data` in the current directory | `/var/local/badkerma` | +`KERMA_PEER_LIST_STORE` | Filename of the peer list | `peers.json` | +`KERMA_BLOCK_STORE` | Filename of the blockchain | `blocks.json` | +`KERMA_TRANSACTION_STORE` | Filename of the transactions | `transactions.json` | +`KERMA_PRIVATE_KEY_FILE` | Filename of the private key file | `private.pem` | + +You can create a `.env` file and docker will read the variables from there. + +**NOTE**: You **need** to set `KERMA_INITIAL_PEER_LIST` to a valid node address when running the node for the **first time** or **after you have cleaned the persistent store**. Thereafter, the node gossips a known node list and keeps it up-to-date. + +Run it locally +--- + +```bash +make run +``` + +Compile it locally +--- + +```bash +make build +``` + +Test it locally +--- + +```bash +make test +``` + +You can also test with netcat. Start the program and run: + +```bash +cat test.txt | nc localhost 18018 -p 50001 +``` + +Run in debug mode locally +--- + +```bash +make debug +``` + +Build the docker image +--- + +```bash +DOCKER_REGISTRY=localhost make docker-build +``` + +Run docker container in debug mode +--- + +```bash +DOCKER_REGISTRY=localhost make docker-debug +``` + +Run docker container in background +--- + +```bash +DOCKER_REGISTRY=localhost make docker-run +``` + +Stop running docker container +--- + +```bash +make docker-stop +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..71865d5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: "3" + +services: + badkerma: + image: ${DOCKER_REGISTRY}/badkerma:latest + volumes: + - ./data:${KERMA_STORE_BASE_DIR} + build: + context: . + environment: + KERMA_DEBUG: ${KERMA_DEBUG} + KERMA_IP_ADDR: ${KERMA_IP_ADDR} + KERMA_PORT: ${KERMA_PORT} + KERMA_CONN_TIMEOUT: ${KERMA_CONN_TIMEOUT} + KERMA_INITIAL_PEER_LIST: ${KERMA_INITIAL_PEER_LIST} + KERMA_STORE_BASE_DIR: ${KERMA_STORE_BASE_DIR} + KERMA_PEER_LIST_STORE: ${KERMA_PEER_LIST_STORE} + KERMA_BLOCK_STORE: ${KERMA_BLOCK_STORE} + KERMA_TRANSACTION_STORE: ${KERMA_TRANSACTION_STORE} + KERMA_PRIVATE_KEY_FILE: ${KERMA_PRIVATE_KEY_FILE} + ports: + - 18018:18018 + env_file: .env diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..357165a --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module kerma + +go 1.19 + +require ( + github.com/docker/go v1.5.1-1 + github.com/smartystreets/goconvey v1.7.2 + golang.org/x/exp v0.0.0-20221026153819-32f3d567a233 +) + +require ( + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect + github.com/smartystreets/assertions v1.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ba99dcb --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/docker/go v1.5.1-1 h1:hr4w35acWBPhGBXlzPoHpmZ/ygPjnmFVxGxxGnMyP7k= +github.com/docker/go v1.5.1-1/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= +github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/exp v0.0.0-20221026153819-32f3d567a233 h1:9bNbSKT4RPLEzne0Xh1v3NaNecsa1DKjkOuTbY6V9rI= +golang.org/x/exp v0.0.0-20221026153819-32f3d567a233/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= diff --git a/helpers/config.go b/helpers/config.go new file mode 100644 index 0000000..d844f2f --- /dev/null +++ b/helpers/config.go @@ -0,0 +1,98 @@ +// Package helpers provides useful helper structures for KermaGo +package helpers + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +const ( + ipAddress = "3.126.74.45" + port = 18018 + connTimeout = 10 + storeBaseDir = "/var/local/badkerma" + peerListStore = "peers.json" + blockStore = "blocks.json" + transactionStore = "transactions.json" + privateKeyFile = "private.pem" + listenAddr = "0.0.0.0" + initialPeerList = "" +) + +/* +A Config is a helper used for storing and obtaining the application configuration. +Supported configuration includes: +- IPAddress +- Port +- ListenAddr - address to listen on +- ConnTimeout - timeout for the client connections +- PeerListStore - filename for the peer list store +- BlockStore - filename for the blockchain store +- TransactionStore - filename for the transaction store +- PrivateKeyFile - filename for the private key +- InitialPeerList - initial peer list for building up +*/ +type Config struct { + IPAddress string + Port int + PeerListStore string + ConnTimeout int + PrivateKeyFile string + BlockStore string + TransactionStore string + ListenAddr string + InitialPeerList string +} + +/* +Construct fetches the configuration from the KERMA_* environment variables or sets sensible defaults +*/ +func (c *Config) Construct() { + c.IPAddress = ipAddress + c.Port = port + c.PeerListStore = peerListStore + c.ConnTimeout = connTimeout + c.PrivateKeyFile = privateKeyFile + c.BlockStore = blockStore + c.TransactionStore = transactionStore + c.InitialPeerList = initialPeerList + + baseDir := storeBaseDir + + if os.Getenv("KERMA_IP_ADDR") != "" { + c.IPAddress = os.Getenv("KERMA_IP_ADDR") + } + if os.Getenv("KERMA_PORT") != "" { + c.Port, _ = strconv.Atoi(os.Getenv("KERMA_PORT")) + } + if os.Getenv("KERMA_CONN_TIMEOUT") != "" { + c.ConnTimeout, _ = strconv.Atoi(os.Getenv("KERMA_CONN_TIMEOUT")) + } + if os.Getenv("KERMA_INITIAL_PEER_LIST") != "" { + c.InitialPeerList = os.Getenv("KERMA_INITIAL_PEER_LIST") + } + if os.Getenv("KERMA_STORE_BASE_DIR") != "" { + baseDir = os.Getenv("KERMA_STORE_BASE_DIR") + baseDir = strings.TrimSuffix(baseDir, "/") + } + if os.Getenv("KERMA_PEER_LIST_STORE") != "" { + c.PeerListStore = os.Getenv("KERMA_PEER_LIST_STORE") + } + if os.Getenv("KERMA_BLOCK_STORE") != "" { + c.BlockStore = os.Getenv("KERMA_BLOCK_STORE") + } + if os.Getenv("KERMA_TRANSACTION_STORE") != "" { + c.TransactionStore = os.Getenv("KERMA_TRANSACTION_STORE") + } + if os.Getenv("KERMA_PRIVATE_KEY_FILE") != "" { + c.PrivateKeyFile = os.Getenv("KERMA_PRIVATE_KEY_FILE") + } + + c.PeerListStore = baseDir + "/" + c.PeerListStore + c.BlockStore = baseDir + "/" + c.BlockStore + c.TransactionStore = baseDir + "/" + c.TransactionStore + c.PrivateKeyFile = baseDir + "/" + c.PrivateKeyFile + c.ListenAddr = listenAddr + ":" + fmt.Sprint(c.Port) +} diff --git a/helpers/logger.go b/helpers/logger.go new file mode 100644 index 0000000..3f89d77 --- /dev/null +++ b/helpers/logger.go @@ -0,0 +1,57 @@ +// Package helpers provides useful helper structures for KermaGo +package helpers + +import ( + "fmt" + "os" + "time" +) + +/* +A Logger is a helper used for logging messages to stdout and stderr +*/ +type Logger struct{} + +/* +Debug logs a debug message to stdout if the KERMA_DEBUG environment variable is set to true +*/ +func (l *Logger) Debug(msg string) { + if os.Getenv("KERMA_DEBUG") == "true" { + t := time.Now() + _, _ = fmt.Fprintln(os.Stdout, t.Format(time.RFC1123)+" [DEBUG] "+msg) + } + +} + +/* +Info logs an info message to stdout +*/ +func (l *Logger) Info(msg string) { + t := time.Now() + _, _ = fmt.Fprintln(os.Stdout, t.Format(time.RFC1123)+" [INFO] "+msg) +} + +/* +Warn logs a warning message to stderr +*/ +func (l *Logger) Warn(msg string) { + t := time.Now() + _, _ = fmt.Fprintln(os.Stderr, t.Format(time.RFC1123)+" [WARN] "+msg) +} + +/* +Error logs an error message to stderr +*/ +func (l *Logger) Error(msg string) { + t := time.Now() + _, _ = fmt.Fprintln(os.Stderr, t.Format(time.RFC1123)+" [ERROR] "+msg) +} + +/* +Fatal logs a fatal message to stderr and terminates the application +*/ +func (l *Logger) Fatal(msg string) { + t := time.Now() + _, _ = fmt.Fprintln(os.Stderr, t.Format(time.RFC1123)+" [FATAL] "+msg) + os.Exit(1) +} diff --git a/helpers/tests/validator_test.go b/helpers/tests/validator_test.go new file mode 100644 index 0000000..79e3f80 --- /dev/null +++ b/helpers/tests/validator_test.go @@ -0,0 +1,91 @@ +package tests + +import ( + "io/ioutil" + "kerma/helpers" + "os" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestValidator(t *testing.T) { + Convey("Given a new validator", t, func() { + var validator helpers.Validator + validator.Construct() + + Convey("When the IsValidFQDN method is called", func() { + Convey("When the input is an invalid FQDN, 'false' should be returned", func() { + So(validator.IsValidFQDN("!!!$"), ShouldEqual, false) + }) + + Convey("When the input is a valid FQDN, 'true' should be returned", func() { + So(validator.IsValidFQDN("example.com"), ShouldEqual, true) + So(validator.IsValidFQDN("sub.example.com"), ShouldEqual, true) + So(validator.IsValidFQDN("sub1.example.com"), ShouldEqual, true) + }) + }) + + Convey("When the IsValidIP method is called", func() { + Convey("When the input is an invalid IP, 'false' should be returned", func() { + So(validator.IsValidIP("!!!$"), ShouldEqual, false) + So(validator.IsValidIP("example.com"), ShouldEqual, false) + So(validator.IsValidIP("127.0.0.1"), ShouldEqual, false) + So(validator.IsValidIP(validator.Config.IPAddress), ShouldEqual, false) + }) + + Convey("When the input is a valid FQDN, 'true' should be returned", func() { + So(validator.IsValidIP("1.1.1.1"), ShouldEqual, true) + So(validator.IsValidIP("172.16.2.1"), ShouldEqual, true) + So(validator.IsValidIP("192.168.150.2"), ShouldEqual, true) + So(validator.IsValidIP("10.11.231.2"), ShouldEqual, true) + So(validator.IsValidIP("254.254.252.251"), ShouldEqual, true) + }) + }) + + Convey("When the IsValidPort method is called", func() { + Convey("When the input is an invalid port, 'false' should be returned", func() { + So(validator.IsValidPort("-1"), ShouldEqual, false) + So(validator.IsValidPort("123456"), ShouldEqual, false) + So(validator.IsValidPort("0"), ShouldEqual, false) + So(validator.IsValidPort("example.com"), ShouldEqual, false) + }) + + Convey("When the input is a valid port, 'true' should be returned", func() { + So(validator.IsValidPort("18018"), ShouldEqual, true) + So(validator.IsValidPort("65535"), ShouldEqual, true) + }) + }) + + Convey("When the IsValidPeerName method is called", func() { + Convey("When the input is an invalid peer name, 'false' should be returned", func() { + So(validator.IsValidPeerName("192.168.150.2:-1"), ShouldEqual, false) + So(validator.IsValidPeerName("172.16.2.1:123456"), ShouldEqual, false) + So(validator.IsValidPeerName("127.0.0.1:18018"), ShouldEqual, false) + So(validator.IsValidPeerName("example.com:111111"), ShouldEqual, false) + }) + + Convey("When the input is a valid peer name, 'true' should be returned", func() { + So(validator.IsValidPeerName("example.com:18018"), ShouldEqual, true) + So(validator.IsValidPeerName("sub.example.com:11111"), ShouldEqual, true) + So(validator.IsValidPeerName("172.16.2.1:18018"), ShouldEqual, true) + So(validator.IsValidPeerName("192.168.150.2:18018"), ShouldEqual, true) + So(validator.IsValidPeerName("10.11.231.2:18018"), ShouldEqual, true) + So(validator.IsValidPeerName("254.254.252.251:18018"), ShouldEqual, true) + }) + }) + + Convey("When the CheckIfFileExists method is called", func() { + Convey("When the input is a non-existent file, 'false' should be returned", func() { + So(validator.CheckIfFileExists("nonexisting.test"), ShouldEqual, false) + }) + + Convey("When the input is an existing file, 'true' should be returned", func() { + buff := make([]byte, 100) + ioutil.WriteFile("existing.test", buff, 0666) + So(validator.CheckIfFileExists("existing.test"), ShouldEqual, true) + os.Remove("existing.test") + }) + }) + }) +} diff --git a/helpers/validator.go b/helpers/validator.go new file mode 100644 index 0000000..7826020 --- /dev/null +++ b/helpers/validator.go @@ -0,0 +1,83 @@ +// Package helpers provides useful helper structures for KermaGo +package helpers + +import ( + "net" + "os" + "regexp" + "strconv" + "strings" +) + +/* +A Validator is a helper used to validate inputs based on a specified config +*/ +type Validator struct { + Config Config +} + +/* +Construct creates a new validator +*/ +func (v *Validator) Construct() { + v.Config.Construct() +} + +/* +IsValidFQDN checks if the string provided corresponds to a valid domain name +*/ +func (v *Validator) IsValidFQDN(fqdn string) bool { + // see: https://www.socketloop.com/tutorials/golang-use-regular-expression-to-validate-domain-name + RegExp := regexp.MustCompile(`^(([a-zA-Z]{1})|([a-zA-Z]{1}[a-zA-Z]{1})|([a-zA-Z]{1}[0-9]{1})|([0-9]{1}[a-zA-Z]{1})|([a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9]))\.([a-zA-Z]{2,6}|[a-zA-Z0-9-]{2,30}\.[a-zA-Z + ]{2,3})$`) + + return RegExp.MatchString(fqdn) +} + +/* +IsValidPeerName checks if the string provided is a valid peer name. This includes valid domain + port combinations, valid IP + port combinations as well as only domains and IPs with default port 18018 +*/ +func (v *Validator) IsValidPeerName(peerName string) bool { + addr := "" + port := "" + + if strings.Contains(peerName, ":") { + buf := strings.Split(peerName, ":") + addr = buf[0] + port = buf[1] + } else { + addr = peerName + port = "18018" + } + + if !v.IsValidIP(addr) { + return v.IsValidFQDN(addr) && v.IsValidPort(port) + } + return v.IsValidPort(port) +} + +/* +IsValidPort checks if the string provided is a port in the valid port range +*/ +func (v *Validator) IsValidPort(port string) bool { + portInt, err := strconv.ParseInt(port, 10, 32) + if err != nil { + return false + } + return portInt > 0 && portInt <= 65535 +} + +/* +IsValidIP checks if the string provided is a valid IP address. Valid IP addresses exclude 127.0.0.1 and the configured listen address +*/ +func (v *Validator) IsValidIP(ip string) bool { + return net.ParseIP(ip) != nil && ip != "127.0.0.1" && ip != v.Config.IPAddress +} + +/* +CheckIfFileExists checks if the path specified is an existing file +*/ +func (v *Validator) CheckIfFileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..9b002fb --- /dev/null +++ b/main.go @@ -0,0 +1,88 @@ +// Package main is the main package of the Kerma node +package main + +import ( + "kerma/helpers" + "kerma/models" + "kerma/utils" + "net" + "time" +) + +var logger helpers.Logger + +func main() { + var config helpers.Config + config.Construct() + + logger.Debug("!!!!! NODE RUNNING IN DEBUG MODE !!!!!") + + // Bind port + logger.Info("Listening on " + config.ListenAddr) + listener, err := net.Listen("tcp", config.ListenAddr) + + if err != nil { + logger.Fatal("Failed binding port: " + err.Error()) + } + + // Define our in-memory state + var state models.State + state.Construct() + + // Close listener on exit + defer listener.Close() + + // Accept connections and handle requests + logger.Info("Performing handshake with known peers") + for { + go initNode(&state) + startServer(&state, listener) + } +} + +func initNode(state *models.State) { + for _, peer := range state.PeerList { + if !state.CheckForHandshake(peer.Name) { + timeout := state.Config.ConnTimeout * int(time.Second) + d := net.Dialer{Timeout: time.Duration(timeout)} + conn, err := d.Dial("tcp", peer.Name) + + if err != nil { + logger.Error("Failed connecting to peer: " + err.Error()) + state.RemovePeerByName(peer.Name) + continue + } + + var handler utils.TCPHandler + var client utils.Client + handler.Construct(conn) + client.Construct(state, handler) + + client.InitHandshake(peer.Name) + client.DiscoverPeers(peer.Name) + client.DiscoverChainTip(peer.Name) + client.DiscoverMempool(peer.Name) + + var server utils.Server + server.Construct(state, handler) + go server.Handle() + go client.GossipTransactions(peer.Name) + } + } +} + +func startServer(state *models.State, l net.Listener) { + conn, err := l.Accept() + + if err != nil { + logger.Fatal("Failed listening on port: " + err.Error()) + } + + var handler utils.TCPHandler + var server utils.Server + + handler.Construct(conn) + server.Construct(state, handler) + + go server.Handle() +} diff --git a/models/block.go b/models/block.go new file mode 100644 index 0000000..86e4e54 --- /dev/null +++ b/models/block.go @@ -0,0 +1,231 @@ +// Package models provides the models for the KermaGo application +package models + +import ( + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "math" + "time" + + "github.com/docker/go/canonical/json" + "golang.org/x/exp/slices" + "golang.org/x/exp/utf8string" +) + +/* +A Block is a struct that represents a block as per the Kerma specification +*/ +type Block struct { + Type string `json:"type" binding:"required"` + Txids []string `json:"txids" binding:"required"` + Nonce string `json:"nonce" binding:"required"` + Previd *string `json:"previd"` + Created int64 `json:"created" binding:"required"` + Target string `json:"T" binding:"required"` + Miner string `json:"miner,omitempty"` + Note string `json:"note,omitempty"` + Height uint64 `json:"-"` + UTXOSet map[string]uint64 `json:"-"` +} + +/* +Hash returns the sha256 hash of the canonical JSON of the block +*/ +func (b *Block) Hash() (string, error) { + block, err := b.MarshalJson() + if err != nil { + return "", errors.New("could not parse block as json") + } + hashSum := sha256.Sum256(block) + + return hex.EncodeToString(hashSum[:]), nil +} + +/* +GetID returns the block id +*/ +func (b *Block) GetID() string { + id, err := b.Hash() + if err != nil { + return "" + } + + return id +} + +/* +GetType returns the type of the block +*/ +func (b *Block) GetType() string { + return b.Type +} + +/* +GetEntity returns the block +*/ +func (b *Block) GetEntity() interface{} { + return b +} + +/* +Validate is responsible for validating the block; returns an error if unsuccessful +*/ +func (b *Block) Validate() error { + if b.Type != "block" { + return errors.New("object not a block") + } + + if b.IsGenesisBlock() { + return nil + } + + if b.Previd == nil && !b.IsGenesisBlock() { + return errors.New("illegal genesis block detected") + } + + if b.Target != GetGenesisBlock().Target { + return errors.New("incorrect target supplied") + } + + if b.Created < GetGenesisBlock().Created { + return errors.New("timestamp before genesis block") + } + + // TODO: check if this should be millis + if b.Created > time.Now().UnixMilli() { + return errors.New("block created in the future") + } + + if b.Miner != "" { + if !utf8string.NewString(b.Miner).IsASCII() { + return errors.New("miner is not an ascii printable string") + } + + if len(b.Miner) > 128 { + return errors.New("miner length incorrect") + } + } + + if b.Note != "" { + if !utf8string.NewString(b.Note).IsASCII() { + return errors.New("note is not an ascii printable string") + } + + if len(b.Note) > 128 { + return errors.New("note length incorrect") + } + } + + _, err := hex.DecodeString(b.Nonce) + if err != nil { + return err + } + + _, err = hex.DecodeString(*b.Previd) + if err != nil { + return err + } + + bid, err := b.Hash() + if err != nil { + return err + } + + bidBytes, _ := hex.DecodeString(bid) + bint := binary.BigEndian.Uint64(bidBytes) + if err != nil { + return err + } + + tidBytes, _ := hex.DecodeString(b.Target) + tid := binary.BigEndian.Uint64(tidBytes) + if err != nil { + return err + } + + if bint >= tid { + return errors.New("proof-of-work equation not satisfied") + } + + return nil +} + +/* +String returns the canonical JSON of the block as a string +*/ +func (b *Block) String() string { + res, _ := b.MarshalJson() + return string(res) +} + +/* +ContainsTransaction checks if the block contains the specified transaction +*/ +func (b *Block) ContainsTransaction(txid string) bool { + return slices.Contains(b.Txids, txid) +} + +/* +MarshalJson returns the canonical JSON of the block +*/ +func (b *Block) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(b) +} + +/* +UnmarshalJSON creates a block from the input JSON byte array +*/ +func (b *Block) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + type tmp Block + err := json.Unmarshal(data, (*tmp)(b)) + if err != nil { + return err + } + + err = b.Validate() + if err != nil { + return err + } + + return nil +} + +/* +IsGenesisBlock checks if the block is the genesis block +*/ +func (b *Block) IsGenesisBlock() bool { + return b.String() == GetGenesisBlock().String() +} + +/* +GetGenesisBlock returns the genesis block +*/ +func GetGenesisBlock() *Block { + genesis := Block{ + Type: "block", + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Created: 1624219079, + Miner: "dionyziz", + Nonce: "0000000000000000000000000000000000000000000000000000002634878840", + Note: "The Economist 2021-06-20: Crypto-miners are probably to blame for the graphics-chip shortage", + Previd: nil, + Txids: []string{}, + Height: 0, + UTXOSet: map[string]uint64{}, + } + return &genesis +} + +/* +GetBlockReward returns the block reward +*/ +func GetBlockReward() uint64 { + // 5 * (10^13) + return 5 * uint64(math.Pow(10, 13)) +} diff --git a/models/chaintip.go b/models/chaintip.go new file mode 100644 index 0000000..e5c8ff5 --- /dev/null +++ b/models/chaintip.go @@ -0,0 +1,46 @@ +// Package models provides the models for the KermaGo application +package models + +import ( + "github.com/docker/go/canonical/json" +) + +/* +A Chaintip is an object that has a type "chaintip" and a block ID +*/ +type Chaintip struct { + Type string `json:"type" binding:"required"` + BlockID string `json:"blockid" binding:"required"` +} + +/* +Construct creates a new Chaintip object for this instance +*/ +func (c *Chaintip) Construct(bid string) { + c.Type = "chaintip" + c.BlockID = bid +} + +/* +MarshalJson returns the canonical json of the chaintip object +*/ +func (c *Chaintip) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(c) +} + +/* +UnmarshalJSON returns a new Chaintip object from a byte array +*/ +func (c *Chaintip) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + type tmp Chaintip + err := json.Unmarshal(data, (*tmp)(c)) + if err != nil { + return err + } + + return nil +} diff --git a/models/error.go b/models/error.go new file mode 100644 index 0000000..42005b5 --- /dev/null +++ b/models/error.go @@ -0,0 +1,27 @@ +// Package models provides the models for the KermaGo application +package models + +import "github.com/docker/go/canonical/json" + +/* +An Error represents a struct that has a type and an error message as a string +*/ +type Error struct { + Type string `json:"type" binding:"required"` + Error string `json:"error" binding:"required"` +} + +/* +Construct creates a new error object with the given string as message +*/ +func (e *Error) Construct(errorMsg string) { + e.Type = "error" + e.Error = errorMsg +} + +/* +MarshalJson returns the canonical json of the error object +*/ +func (e *Error) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(e) +} diff --git a/models/generic.go b/models/generic.go new file mode 100644 index 0000000..60b418f --- /dev/null +++ b/models/generic.go @@ -0,0 +1,39 @@ +// Package models provides the models for the KermaGo application +package models + +import "github.com/docker/go/canonical/json" + +/* +A Generic represents a struct that only has a type +*/ +type Generic struct { + Type string `json:"type" binding:"required"` +} + +/* +MarshalJson returns the canonical json of the generic object +*/ +func (g *Generic) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(g) +} + +/* +BuildPeerRequest creates a new generic object with type "getpeers" +*/ +func (g *Generic) BuildPeerRequest() { + g.Type = "getpeers" +} + +/* +BuildChainTipRequest creates a new generic object with type "getchaintip" +*/ +func (g *Generic) BuildChainTipRequest() { + g.Type = "getchaintip" +} + +/* +BuildMempoolRequest creates a new generic object with type "getmempool" +*/ +func (g *Generic) BuildMempoolRequest() { + g.Type = "getmempool" +} diff --git a/models/hello.go b/models/hello.go new file mode 100644 index 0000000..a1b4972 --- /dev/null +++ b/models/hello.go @@ -0,0 +1,69 @@ +// Package models provides the models for the KermaGo application +package models + +import ( + "errors" + "regexp" + + "github.com/docker/go/canonical/json" +) + +/* +A Hello represents a struct that has a type, a version and a user agent +*/ +type Hello struct { + Type string `json:"type" binding:"required"` + Version string `json:"version" binding:"required"` + Agent string `json:"agent"` +} + +/* +Construct creates a new Hello object for this instance +*/ +func (h *Hello) Construct() { + h.Type = "hello" + h.Version = "0.8.0" + h.Agent = "BadKerma Go Client 0.8.x" +} + +/* +MarshalJson returns the canonical json of the hello object +*/ +func (h *Hello) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(h) +} + +/* +UnmarshalJSON returns a new Hello request from a byte array +*/ +func (h *Hello) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + type tmp Hello + err := json.Unmarshal(data, (*tmp)(h)) + if err != nil { + return err + } + + if !h.verify() { + return errors.New("hello request not valid") + } + + return nil +} + +func (h *Hello) verify() bool { + if h.Type == "hello" && h.isValidVersion() { + return true + } + + return false +} + +func (h *Hello) isValidVersion() bool { + RegExp := regexp.MustCompile(`^0\.8\.[0-9]$`) + + return RegExp.MatchString(h.Version) +} diff --git a/models/interfaces.go b/models/interfaces.go new file mode 100644 index 0000000..e03208f --- /dev/null +++ b/models/interfaces.go @@ -0,0 +1,29 @@ +// Package models provides the models for the KermaGo application +package models + +/* +An IEntity is an interface that represents a Kerma entity - either a Block or a Transaction +*/ +type IEntity interface { + Hash() (string, error) + GetType() string + GetID() string + GetEntity() interface{} + Validate() error + String() string +} + +/* +An IObject is an interface that represents a Kerma object +*/ +type IObject interface { + GetObjectValue() (IEntity, error) + String() string +} + +/* +A CustomMarshaler is an interface that provides custom marshaling functions +*/ +type CustomMarshaler interface { + MarshalJson() ([]byte, error) +} diff --git a/models/mempool.go b/models/mempool.go new file mode 100644 index 0000000..e8097a7 --- /dev/null +++ b/models/mempool.go @@ -0,0 +1,57 @@ +// Package models provides the models for the KermaGo application +package models + +import ( + "errors" + + "github.com/docker/go/canonical/json" + "golang.org/x/exp/slices" +) + +/* +A Mempool represents a struct that has a type, and a list of tx ids +*/ +type Mempool struct { + Type string `json:"type" binding:"required"` + Txids []string `json:"txids" binding:"required"` +} + +/* +Construct creates a new Mempool object for this instance +*/ +func (m *Mempool) Construct(mempool []string) { + m.Type = "mempool" + m.Txids = slices.Clone(mempool) +} + +/* +MarshalJson returns the canonical json of the mempool object +*/ +func (m *Mempool) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(m) +} + +/* +UnmarshalJSON returns a new Mempool request from a byte array +*/ +func (m *Mempool) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + type tmp Mempool + err := json.Unmarshal(data, (*tmp)(m)) + if err != nil { + return err + } + + if !m.verify() { + return errors.New("mempool request not valid") + } + + return nil +} + +func (m *Mempool) verify() bool { + return m.Type == "mempool" +} diff --git a/models/object.go b/models/object.go new file mode 100644 index 0000000..2df29a0 --- /dev/null +++ b/models/object.go @@ -0,0 +1,128 @@ +// Package models provides the models for the KermaGo application +package models + +import ( + "errors" + + "github.com/docker/go/canonical/json" +) + +/* +An Object is a struct that represents a Kerma object. The object has a type and the object specification as a raw JSON +*/ +type Object struct { + Type string `json:"type" binding:"required"` + Object json.RawMessage `json:"object" binding:"required"` +} + +/* +An ObjectWrapper is a struct that wraps a Kerma object. The wrapper has a type and an object ID +*/ +type ObjectWrapper struct { + Type string `json:"type" binding:"required"` + ObjectID string `json:"objectid" binding:"required"` +} + +/* +BuildObjectRequest creates a new ObjectWrapper with type "getobject" and the supplied Object ID +*/ +func (o *ObjectWrapper) BuildObjectRequest(oid string) { + o.Type = "getobject" + o.ObjectID = oid +} + +/* +BuildGossipObject creates a new ObjectWrapper with type "ihaveobject" and the supplied Object ID +*/ +func (o *ObjectWrapper) BuildGossipObject(oid string) { + o.Type = "ihaveobject" + o.ObjectID = oid +} + +/* +MarshalJson returns the canonical JSON of the object wrapper +*/ +func (o *ObjectWrapper) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(o) +} + +/* +UnmarshalJSON creates a new ObjectWrapper from the input JSON byte array +*/ +func (o *ObjectWrapper) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + type tmp ObjectWrapper + err := json.Unmarshal(data, (*tmp)(o)) + if err != nil { + return err + } + + if o.Type != "getobject" && o.Type != "ihaveobject" { + return errors.New("inccorect type for object supplied") + } + + return nil +} + +/* +GetObjectValue returns the value of the object as an IEntity +*/ +func (o *Object) GetObjectValue() (IEntity, error) { + msg, err := o.Object.MarshalJSON() + if err != nil { + return nil, err + } + + var transaction Transaction + terr := transaction.UnmarshalJSON(msg) + if terr == nil { + return &transaction, nil + } + + var block Block + berr := block.UnmarshalJSON(msg) + if berr == nil { + return &block, nil + } + + return nil, berr +} + +/* +MarshalJson returns the canonical JSON of the object +*/ +func (o *Object) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(o) +} + +/* +UnmarshalJSON creates a new object from the input JSON byte array +*/ +func (o *Object) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + type tmp Object + err := json.Unmarshal(data, (*tmp)(o)) + if err != nil { + return err + } + + if o.Type != "object" { + return errors.New("inccorect type for object supplied") + } + + return nil +} + +/* +String returns the canonical JSON of the object as a string +*/ +func (o *Object) String() string { + res, _ := o.MarshalJson() + return string(res) +} diff --git a/models/state.go b/models/state.go new file mode 100644 index 0000000..55fa693 --- /dev/null +++ b/models/state.go @@ -0,0 +1,770 @@ +// Package models provides the models for the KermaGo application +package models + +import ( + "crypto/ed25519" + "crypto/x509" + "errors" + "kerma/helpers" + "os" + "strings" + + "github.com/docker/go/canonical/json" + + "golang.org/x/exp/maps" + "golang.org/x/exp/slices" +) + +/* +A State is an object that contains the current state of the application. +State-related fields include: +- PeerList - list of discovered peers +- Chain - chain of known valid blocks +- Transactions - hashmap of known valid transactions +- PrivateKey - ED25519 private key of the Kerma node +*/ +type State struct { + PeerList []Peer + Validator helpers.Validator + Config helpers.Config + Chain []Block + Transactions map[string]*Transaction + PrivateKey ed25519.PrivateKey + Logger helpers.Logger + Height uint64 +} + +/* +A Peer represents a peer on the Kerma network. It has a name and an indicator of whether there was a handshake +*/ +type Peer struct { + Name string + Active bool +} + +/* +A PeerListResponse represents an object with a type and a string array, containing the addresses of all known peers +*/ +type PeerListResponse struct { + Type string `json:"type" binding:"required"` + Peers []string `json:"peers"` +} + +/* +Construct creates a new state object +*/ +func (s *State) Construct() { + s.PeerList = []Peer{} + s.Validator.Construct() + s.Config.Construct() + + privKey, err := s.parsePKIFile() + if err != nil { + s.Logger.Warn(err.Error()) + s.Logger.Info("Generating new key...") + _, privKey, _ = ed25519.GenerateKey(nil) + } + s.PrivateKey = privKey + s.dumpPKIFile() + + s.Logger.Info("Reading known peers from dump file") + dumpErr := s.parsePeerListStore() + + if dumpErr != nil { + s.Logger.Warn(dumpErr.Error() + ", reverting to defaults...") + s.parseInitialPeerList() + } + + s.Logger.Info("Restoring previous known state") + s.Chain = []Block{} + s.Transactions = map[string]*Transaction{} + + terr := s.parseTransactionStore() + if terr != nil { + s.Logger.Debug(terr.Error()) + } + + berr := s.parseBlockStore() + if berr != nil { + s.Logger.Debug(berr.Error()) + } + + if len(s.Chain) == 0 { + s.Chain = append(s.Chain, *GetGenesisBlock()) + s.Height = 0 + } +} + +/* +============================== + + HANDSHAKE OPERATIONS + +============================== +*/ + +/* +CheckForHandshake checks whether the node has completed a handshake with the specified peer +*/ +func (s *State) CheckForHandshake(peerName string) bool { + for _, peer := range s.PeerList { + if peer.Name == peerName { + return peer.Active + } + } + return false +} + +/* +CompleteHandshake completes a handshake with the specified peer +*/ +func (s *State) CompleteHandshake(peerName string) { + peer := s.FindPeerByName(peerName) + + if peer != nil { + peer.Active = true + } else { + peer = &Peer{ + Name: peerName, + Active: true, + } + s.PeerList = append(s.PeerList, *peer) + } +} + +/* +====================== + + PEER OPERATIONS + +====================== +*/ + +/* +MarshalJson returns the canonical json of the PeerListResponse object +*/ +func (p *PeerListResponse) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(p) +} + +/* +FetchPeerListResponse returns a new PeerListResponse object from the local peer list +*/ +func (s *State) FetchPeerListResponse() PeerListResponse { + list := PeerListResponse{ + Type: "peers", + Peers: []string{}, + } + + for _, p := range s.PeerList { + list.Peers = append(list.Peers, p.Name) + } + + return list +} + +/* +FindPeerByName returns the specified peer from the peer list +*/ +func (s *State) FindPeerByName(peerName string) *Peer { + for p, peer := range s.PeerList { + if peer.Name == peerName { + return &s.PeerList[p] + } + } + return nil +} + +/* +RemovePeerByName removes the specified peer from the peer list +*/ +func (s *State) RemovePeerByName(peerName string) { + for i, peer := range s.PeerList { + if peer.Name == peerName { + slices.Delete(s.PeerList, i, i) + } + } + s.DumpPeerListStore() +} + +/* +ParsePeerListResponse parses the given PeerListResponse object to the local peer list +*/ +func (s *State) ParsePeerListResponse(p *PeerListResponse) { + for _, peer := range p.Peers { + if s.Validator.IsValidPeerName(peer) && s.FindPeerByName(peer) == nil { + p := Peer{ + Name: peer, + Active: false, + } + s.PeerList = append(s.PeerList, p) + } + } + s.DumpPeerListStore() +} + +/* +DumpPeerListStore stores the local peer list to a JSON file +*/ +func (s *State) DumpPeerListStore() { + list := []string{} + for _, p := range s.PeerList { + if s.Validator.IsValidPeerName(p.Name) { + list = append(list, p.Name) + } + } + file, _ := json.MarshalCanonical(list) + _ = os.WriteFile(s.Config.PeerListStore, file, 0644) +} + +func (s *State) parsePeerListStore() error { + if s.Validator.CheckIfFileExists(s.Config.PeerListStore) { + file, err := os.ReadFile(s.Config.PeerListStore) + if err != nil { + return errors.New("cannot read peer list store " + s.Config.PeerListStore) + } + peerList := []string{} + err = json.Unmarshal([]byte(file), &peerList) + if err != nil { + return errors.New("cannot parse peer list store at " + s.Config.PeerListStore) + } + for _, peer := range peerList { + if s.Validator.IsValidPeerName(peer) { + p := Peer{ + Name: peer, + Active: false, + } + s.PeerList = append(s.PeerList, p) + } + } + return nil + } + return errors.New("cannot find peer list store at " + s.Config.PeerListStore) +} + +func (s *State) parseInitialPeerList() { + s.PeerList = []Peer{} + + if strings.Contains(s.Config.InitialPeerList, ",") { + peers := strings.Split(s.Config.InitialPeerList, ",") + for _, peerName := range peers { + if s.Validator.IsValidPeerName(peerName) { + p := Peer{ + Name: peerName, + Active: false, + } + s.PeerList = append(s.PeerList, p) + } else { + s.Logger.Warn("Failed parsing the initial peer list. Please check your configuration") + } + } + } else { + if s.Validator.IsValidPeerName(s.Config.InitialPeerList) { + p := Peer{ + Name: s.Config.InitialPeerList, + Active: false, + } + s.PeerList = append(s.PeerList, p) + } else { + s.Logger.Warn("Failed parsing the initial peer list. Please check your configuration") + } + } +} + +/* +====================== + + PKI OPERATIONS + +====================== +*/ + +func (s *State) parsePKIFile() (ed25519.PrivateKey, error) { + privKeyBytes, err := os.ReadFile(s.Config.PrivateKeyFile) + if err != nil { + return nil, errors.New("cannot read private key file at " + s.Config.PrivateKeyFile) + } + + res, err := x509.ParsePKCS8PrivateKey(privKeyBytes) + if err != nil { + return nil, errors.New("cannot parse private key from " + s.Config.PrivateKeyFile) + } + + privKey := res.(ed25519.PrivateKey) + + return privKey, nil +} + +func (s *State) dumpPKIFile() { + privKeyBytes, _ := x509.MarshalPKCS8PrivateKey(s.PrivateKey) + + _ = os.WriteFile(s.Config.PrivateKeyFile, privKeyBytes, 0600) +} + +/* +====================== + + BLOCK OPERATIONS + +====================== +*/ + +func (s *State) validateBlock(b *Block) error { + err := b.Validate() + if err != nil { + return err + } + + if b.IsGenesisBlock() { + return nil + } + + bid := b.GetID() + + prevBlock := s.Chain[len(s.Chain)-1] + prevHash, err := prevBlock.Hash() + + if err != nil { + return err + } + + if *b.Previd != prevHash || prevBlock.Created > b.Created { + return errors.New("invalid chain extension, block invalid: " + bid) + } + + cbid := "invalid" + for i, txid := range b.Txids { + tx := s.Transactions[txid] + if tx != nil { + err = tx.Validate() + if err != nil { + return errors.New("detected invalid transaction, block invalid: " + bid) + } + + if tx.IsCoinbaseTx() { + if i != 0 { + return errors.New("invalid coinbase transaction, block invalid: " + bid) + } + + if tx.Outputs[0].Value > s.getTransactionFeesSum(b)+GetBlockReward() { + return errors.New("law of conservation for coinbase transaction broken, block invalid: " + bid) + } + + txBlock := s.findBlockForTransaction(txid) + if txBlock != nil && *tx.Height != txBlock.Height { + return errors.New("incorrect height on coinbase transaction, block invalid: " + bid) + } + + cbid, _ = tx.Hash() + + } else { + if cbid != "invalid" && tx.IsTxInput(cbid) { + return errors.New("coinbase transaction cannot be spent in same block, block invalid: " + bid) + } + } + } + } + + return nil +} + +/* +AppendToChain adds the specified block to the chain +*/ +func (s *State) AppendToChain(b *Block) error { + if len(s.Chain) == 0 { + s.Chain = append(s.Chain, *GetGenesisBlock()) + s.Height = 0 + } + + bid, err := b.Hash() + if err != nil { + s.Logger.Debug(err.Error()) + return err + } + + if s.GetBlock(bid) != nil { + s.Logger.Debug("Block already in chain: " + bid) + return nil + } + + s.Logger.Debug("Validating block: " + bid) + err = s.validateBlock(b) + if err != nil { + return err + } + + if b.Previd != nil { + prevBlock := s.GetBlock(*b.Previd) + b.Height = prevBlock.Height + 1 + } else { + if b.IsGenesisBlock() { + b.Height = GetGenesisBlock().Height + } else { + return errors.New("invalid genesis block") + } + } + + if b.Height > s.Height { + s.Logger.Info("New longest chain extension found: " + bid) + s.Height = b.Height + err = s.computeUTXOSet(b) + + if err == nil { + s.Chain = append(s.Chain, *b) + for _, txid := range b.Txids { + s.confirmTransaction(txid) + } + s.DumpTransactionStore() + } else { + s.Logger.Debug(err.Error()) + return err + } + } + return nil +} + +func (s *State) computeUTXOSet(b *Block) error { + if b.IsGenesisBlock() { + b.UTXOSet = GetGenesisBlock().UTXOSet + return nil + } + + s.Logger.Debug("Computing UTXO set for block") + prevBlock := s.GetBlock(*b.Previd) + + b.UTXOSet = make(map[string]uint64) + maps.Copy(prevBlock.UTXOSet, b.UTXOSet) + + for _, txid := range b.Txids { + s.Logger.Debug("Computing UTXO for transaction " + txid) + transaction := s.GetTransaction(txid) + err := transaction.Validate() + + if err != nil { + return errors.New("invalid utxo set for block " + b.GetID() + ", transaction " + txid) + } + + if transaction.IsCoinbaseTx() { + output := transaction.Outputs[0] + b.UTXOSet[output.Pubkey] = output.Value + } else { + for _, input := range transaction.Inputs { + output := s.getOutputFromTransactionInput(&input) + var balance uint64 + balance = output.Value + if val, ok := b.UTXOSet[output.Pubkey]; ok { + balance = val + output.Value + } + + for _, o := range transaction.Outputs { + balance -= o.Value + } + + if balance > 0 { + b.UTXOSet[output.Pubkey] = balance + } else { + delete(b.UTXOSet, output.Pubkey) + } + } + } + } + + return nil +} + +/* +GetBlock returns the block specified by the supplied ID or nil +*/ +func (s *State) GetBlock(bid string) *Block { + for _, b := range s.Chain { + if bid == b.GetID() { + return &b + } + } + return nil +} + +/* +GetChainTip returns the tip of our local chain as an object +*/ +func (s *State) GetChainTip() *Block { + if len(s.Chain) == 0 { + return GetGenesisBlock() + } + + return &s.Chain[len(s.Chain)-1] +} + +func (s *State) getTransactionFeesSum(b *Block) uint64 { + var fees uint64 + fees = 0 + + for _, txid := range b.Txids { + fees += s.getTransactionFees(txid) + } + + return fees +} + +/* +GetMissingTransactionsInBlock returns the transaction ids from the block that are not present in the mempool +*/ +func (s *State) GetMissingTransactionsInBlock(b *Block) ([]string, error) { + err := b.Validate() + if err != nil { + return []string{}, err + } + + res := []string{} + + for _, txid := range b.Txids { + tx := s.Transactions[txid] + if tx == nil { + res = append(res, txid) + } + } + + return res, nil +} + +/* +DumpBlockStore stores the local block chain to a JSON file +*/ +func (s *State) DumpBlockStore() { + file, _ := json.MarshalCanonical(s.Chain) + _ = os.WriteFile(s.Config.BlockStore, file, 0644) +} + +func (s *State) parseBlockStore() error { + if s.Validator.CheckIfFileExists(s.Config.BlockStore) { + file, err := os.ReadFile(s.Config.BlockStore) + if err != nil { + return errors.New("cannot read block store " + s.Config.BlockStore) + } + chain := []Block{} + err = json.Unmarshal([]byte(file), &chain) + if err != nil { + return errors.New("cannot parse block store at " + s.Config.BlockStore) + } + + for i, block := range chain { + if block.IsGenesisBlock() { + continue + } + block.Height = uint64(i) + s.AppendToChain(&block) + } + return nil + } + return errors.New("cannot find block store at " + s.Config.BlockStore) +} + +/* +============================= + + TRANSACTION OPERATIONS + +============================= +*/ + +func (s *State) validateTransaction(t *Transaction) error { + txid, err := t.Hash() + + if err != nil { + s.Logger.Debug(err.Error()) + return err + } + + if len(t.Outputs) == 0 { + return errors.New("outputs cannot be empty, transaction invalid: " + txid) + } + + if t.Height != nil { + s.Logger.Debug("Processing a coinbase transaction: " + txid) + } else { + inputValues := 0 + outputValues := 0 + + if len(t.Inputs) == 0 { + return errors.New("inputs cannot be empty in a non-coinbase transaction, transaction invalid: " + txid) + } + + for _, input := range t.Inputs { + prevTransaction := s.Transactions[input.Outpoint.Txid] + if prevTransaction == nil { + return errors.New("could not find output transaction in pool, transaction invalid: " + txid) + } + + if len(prevTransaction.Outputs)-1 < int(input.Outpoint.Index) { + return errors.New("could not find output specified by index in pool, transaction invalid: " + txid) + } + + outpoint := prevTransaction.Outputs[int(input.Outpoint.Index)] + + if !t.VerifySign(input.Sig, outpoint.Pubkey) { + return errors.New("could not validate input signatures, transaction invalid: " + txid) + } + + inputValues += int(outpoint.Value) + } + + for _, output := range t.Outputs { + outputValues += int(output.Value) + } + + if inputValues != 0 && outputValues > inputValues { + return errors.New("overspending, transaction invalid: " + txid) + } + } + return nil +} + +/* +AppendTransaction adds the specified transaction to the store +*/ +func (s *State) AppendTransaction(t *Transaction) error { + txid, err := t.Hash() + if err != nil { + s.Logger.Debug(err.Error()) + return err + } + + s.Logger.Debug("Validating transaction: " + txid) + + err = s.validateTransaction(t) + if err != nil { + s.Logger.Debug(err.Error()) + return err + } + + s.Logger.Debug("Appending transaction to store: " + txid) + + if t.IsCoinbaseTx() { + t.Confirmed = true + } else { + t.Confirmed = false + } + + s.Transactions[txid] = t + return nil +} + +func (s *State) findBlockForTransaction(txid string) *Block { + for _, block := range s.Chain { + if block.ContainsTransaction(txid) { + return &block + } + } + return nil +} + +/* +GetTransaction returns the specified transaction from the store +*/ +func (s *State) GetTransaction(txid string) *Transaction { + if v, ok := s.Transactions[txid]; ok { + return v + } + return nil +} + +/* +GetMempoolTransactionIDs returns the list of transaction IDs in the mempool +*/ +func (s *State) GetMempoolTransactionIDs() []string { + res := []string{} + + for txid, tx := range s.Transactions { + if !tx.Confirmed { + res = append(res, txid) + } + } + + return res +} + +func (s *State) getConfirmedTransactions() map[string]*Transaction { + res := map[string]*Transaction{} + + for txid, tx := range s.Transactions { + if tx.Confirmed { + res[txid] = tx + } + } + + return res +} + +func (s *State) confirmTransaction(txid string) { + tx := s.GetTransaction(txid) + if tx != nil { + tx.Confirmed = true + } +} + +func (s *State) getTransactionFees(txid string) uint64 { + var fees uint64 + fees = 0 + + transaction := s.GetTransaction(txid) + + if transaction.IsCoinbaseTx() { + return 0 + } + + for _, input := range transaction.Inputs { + prevOutput := s.getOutputFromTransactionInput(&input) + fees += prevOutput.Value + } + + for _, output := range transaction.Outputs { + fees -= output.Value + } + + return fees +} + +func (s *State) getOutputFromTransactionInput(i *Input) *Output { + txid := i.Outpoint.Txid + index := i.Outpoint.Index + + outputTx := s.GetTransaction(txid) + for i, tx := range outputTx.Outputs { + if int64(i) == index { + return &tx + } + } + + return nil +} + +/* +DumpTransactionStore stores the local store to a JSON file +*/ +func (s *State) DumpTransactionStore() { + file, _ := json.MarshalCanonical(s.getConfirmedTransactions()) + _ = os.WriteFile(s.Config.TransactionStore, file, 0644) +} + +func (s *State) parseTransactionStore() error { + if s.Validator.CheckIfFileExists(s.Config.TransactionStore) { + file, err := os.ReadFile(s.Config.TransactionStore) + if err != nil { + return errors.New("cannot read transaction store " + s.Config.TransactionStore) + } + var pool map[string]*Transaction + + err = json.Unmarshal([]byte(file), &pool) + if err != nil { + return errors.New("cannot parse transaction store at " + s.Config.TransactionStore) + } + for id, transaction := range pool { + s.Logger.Debug("Appending local transaction to store: " + id + ", tx: " + transaction.String()) + transaction.Confirmed = true + s.Transactions[id] = transaction + } + return nil + } + return errors.New("cannot find transaction store at " + s.Config.TransactionStore) +} diff --git a/models/tests/block_test.go b/models/tests/block_test.go new file mode 100644 index 0000000..4ce3ad6 --- /dev/null +++ b/models/tests/block_test.go @@ -0,0 +1,292 @@ +package tests + +import ( + "crypto/sha256" + "encoding/hex" + "kerma/models" + "testing" + "time" + + "github.com/docker/go/canonical/json" + . "github.com/smartystreets/goconvey/convey" +) + +func TestBlock(t *testing.T) { + Convey("Given a new block", t, func() { + previd := "00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e" + txid := "1bb37b637d07100cd26fc063dfd4c39a7931cc88dae3417871219715a5e374af" + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: 1649827795114, + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "svatsan", + Note: "First block. Yayy, I have 50 bu now!!", + } + + Convey("When GetType is called, 'block' should be returned", func() { + So(block.GetType(), ShouldEqual, "block") + }) + + Convey("When GetEntity is called, the block should be returned", func() { + So(block.GetEntity(), ShouldResemble, &block) + }) + + Convey("When Validate is called, no error should be returned", func() { + So(block.Validate(), ShouldEqual, nil) + }) + + Convey("When ContainsTransaction is called with a correct id, 'true' should be returned", func() { + So(block.ContainsTransaction(txid), ShouldEqual, true) + }) + + Convey("When ContainsTransaction is called with an incorrect id, 'false' should be returned", func() { + So(block.ContainsTransaction("123"), ShouldEqual, false) + }) + + Convey("When Hash is called, the hash of the cannonical json and no error should be returned", func() { + blockJSON, _ := json.MarshalCanonical(block) + hashSum := sha256.Sum256(blockJSON) + + res, err := block.Hash() + So(hex.EncodeToString(hashSum[:]), ShouldEqual, res) + So(err, ShouldEqual, nil) + }) + }) + + Convey("Given the genesis block", t, func() { + block := models.GetGenesisBlock() + gid := "00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e" + + Convey("When GetID is called, the block id should be correct", func() { + bid := block.GetID() + So(bid, ShouldEqual, gid) + }) + + Convey("When Validate is called, no error should be returned", func() { + So(block.Validate(), ShouldBeNil) + }) + }) + + Convey("Given a new incorrect block", t, func() { + previd := "00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e" + txid := "1bb37b637d07100cd26fc063dfd4c39a7931cc88dae3417871219715a5e374af" + + Convey("When the block is a genesis", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: nil, + Created: 1649827795114, + Target: "reallyillegalgenesisblock", + Miner: "svatsan", + Note: "", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldEqual, "illegal genesis block detected") + }) + }) + + Convey("With an incorrect genesis", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: 1649827795114, + Target: "reallyillegalgenesisblock", + Miner: "svatsan", + Note: "", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldEqual, "incorrect target supplied") + }) + }) + + Convey("With a timestamp before genesis", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: 0, + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "svatsan", + Note: "", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldEqual, "timestamp before genesis block") + }) + }) + + Convey("With a timestamp in the future", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: time.Now().UnixMilli() + 10000000, + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "svatsan", + Note: "", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldEqual, "block created in the future") + }) + }) + + Convey("With a miner that is a non-ASCII printible string", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: time.Now().UnixMilli(), + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "Not@nħCIIStrIng", + Note: "", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldEqual, "miner is not an ascii printable string") + }) + }) + + Convey("With a miner that is more than 128 characters long", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: time.Now().UnixMilli(), + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "LongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongMinerName", + Note: "", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldEqual, "miner length incorrect") + }) + }) + + Convey("With a note that is a non-ASCII printible string", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: time.Now().UnixMilli(), + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "", + Note: "Not@nħCIIStrIng", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldEqual, "note is not an ascii printable string") + }) + }) + + Convey("With a note that is more than 128 characters long", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: time.Now().UnixMilli(), + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "", + Note: "LongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongLongNote", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldEqual, "note length incorrect") + }) + }) + + Convey("With a non-hex encoded nonce", func() { + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "AReallyIncorrectNonce", + Previd: &previd, + Created: time.Now().UnixMilli(), + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "", + Note: "", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldNotBeNil) + }) + }) + + Convey("With a non-hex encoded previd", func() { + incorrectPrevId := "incorrectprevid" + + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &incorrectPrevId, + Created: time.Now().UnixMilli(), + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "", + Note: "", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldNotBeNil) + }) + }) + + Convey("With an incorrect proof-of-work equation", func() { + previd := "00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e" + txid := "1bb37b637d07100cd26fc063dfd4c39a7931cc88dae3417871219715a5e37412" + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: 1649827795114, + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "svatsan", + Note: "First block. Yayy, I have 50 bu now!!", + } + + Convey("When Validate is called, an error should be returned", func() { + So(block.Validate().Error(), ShouldNotBeNil) + }) + }) + }) +} diff --git a/models/tests/chaintip_test.go b/models/tests/chaintip_test.go new file mode 100644 index 0000000..8c21a1b --- /dev/null +++ b/models/tests/chaintip_test.go @@ -0,0 +1,36 @@ +package tests + +import ( + "kerma/models" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestChaintip(t *testing.T) { + Convey("Given a new chaintip", t, func() { + blockID := models.GetGenesisBlock().GetID() + var chaintip models.Chaintip + + Convey("When the constructor is called with a value", func() { + chaintip.Construct(blockID) + + Convey("The value should be set", func() { + So(chaintip.Type, ShouldEqual, "chaintip") + So(chaintip.BlockID, ShouldEqual, blockID) + }) + }) + + Convey("When the MarshalJson is called", func() { + chaintip.Construct(blockID) + + Convey("The canonical json and no error should be returned", func() { + chaintipJSON, err := chaintip.MarshalJson() + canonJSON := `{"blockid":"` + blockID + `","type":"chaintip"}` + + So(string(chaintipJSON), ShouldEqualJSON, canonJSON) + So(err, ShouldBeNil) + }) + }) + }) +} diff --git a/models/tests/error_test.go b/models/tests/error_test.go new file mode 100644 index 0000000..bdc1667 --- /dev/null +++ b/models/tests/error_test.go @@ -0,0 +1,24 @@ +package tests + +import ( + "kerma/models" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestError(t *testing.T) { + Convey("Given a new error", t, func() { + errString := "testing error" + var err models.Error + + Convey("When the constructor is called with a value", func() { + err.Construct(errString) + + Convey("The value should be set", func() { + So(err.Type, ShouldEqual, "error") + So(err.Error, ShouldEqual, errString) + }) + }) + }) +} diff --git a/models/tests/generic_test.go b/models/tests/generic_test.go new file mode 100644 index 0000000..06059f0 --- /dev/null +++ b/models/tests/generic_test.go @@ -0,0 +1,22 @@ +package tests + +import ( + "kerma/models" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestGeneric(t *testing.T) { + Convey("Given a new generic request", t, func() { + var generic models.Generic + + Convey("When the BuildPeerRequest method is called", func() { + generic.BuildPeerRequest() + + Convey("The type should be 'getpeers'", func() { + So(generic.Type, ShouldEqual, "getpeers") + }) + }) + }) +} diff --git a/models/tests/hello_test.go b/models/tests/hello_test.go new file mode 100644 index 0000000..34943b5 --- /dev/null +++ b/models/tests/hello_test.go @@ -0,0 +1,36 @@ +package tests + +import ( + "errors" + "kerma/models" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestHello(t *testing.T) { + Convey("Given a new hello request", t, func() { + var hello models.Hello + + Convey("When the Construct method is called", func() { + hello.Construct() + + Convey("A correct hello request should be created", func() { + So(hello.Type, ShouldEqual, "hello") + So(hello.Version, ShouldEqual, "0.8.0") + So(hello.Agent, ShouldEqual, "BadKerma Go Client 0.8.x") + }) + }) + }) + + Convey("When an invalid request is created", t, func() { + var hello models.Hello + + msgJSON := `{"type": "hello", "version": "0.9.0"}` + + Convey("When UnmarshalJSON is called, it should return an error", func() { + err := errors.New("hello request not valid") + So(hello.UnmarshalJSON([]byte(msgJSON)), ShouldResemble, err) + }) + }) +} diff --git a/models/tests/mempool_test.go b/models/tests/mempool_test.go new file mode 100644 index 0000000..0aa4454 --- /dev/null +++ b/models/tests/mempool_test.go @@ -0,0 +1,55 @@ +package tests + +import ( + "kerma/models" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestMempool(t *testing.T) { + Convey("Given a new regular transaction", t, func() { + transaction := models.Transaction{ + Type: "transaction", + Inputs: []models.Input{ + { + Outpoint: models.Outpoint{ + Index: 0, + Txid: "2fb7adb654b373e85c6b5c596cc110dcb6643ee138768f4aa947e9ddb7d91f8d", + }, + Sig: "1bc4c05ec180932f08b95a8b5be308bb7b90c4d047720c4953440ea7cf56ba38b7e3b52ae586b594a6ae6649d8be0ae3d6944ffe9a7c5894622c33b9df276909", + }, + }, + Outputs: []models.Output{ + { + Pubkey: "857debb2084fc8c87dec10d305993e781d9c9dbf6a81762b2f245095ae6b8fb9", + Value: 50, + }, + }, + } + + txid := transaction.GetID() + var mempool models.Mempool + + Convey("When the constructor is called with a value", func() { + mempool.Construct([]string{txid}) + + Convey("The value should be set", func() { + So(mempool.Type, ShouldEqual, "mempool") + So(mempool.Txids[0], ShouldEqual, txid) + }) + }) + + Convey("When the MarshalJson is called", func() { + mempool.Construct([]string{txid}) + + Convey("The canonical json and no error should be returned", func() { + mempoolJSON, err := mempool.MarshalJson() + canonJSON := `{"txids":["` + txid + `"],"type":"mempool"}` + + So(string(mempoolJSON), ShouldEqualJSON, canonJSON) + So(err, ShouldBeNil) + }) + }) + }) +} diff --git a/models/tests/object_test.go b/models/tests/object_test.go new file mode 100644 index 0000000..0c3fed9 --- /dev/null +++ b/models/tests/object_test.go @@ -0,0 +1,147 @@ +package tests + +import ( + "crypto/sha256" + "encoding/hex" + "kerma/models" + "testing" + + "github.com/docker/go/canonical/json" + . "github.com/smartystreets/goconvey/convey" +) + +func TestObject(t *testing.T) { + Convey("Given a new object wrapper", t, func() { + var wrapper models.ObjectWrapper + toHash := "thisstringwillbehashed" + hashSum := sha256.Sum256([]byte(toHash)) + oid := hex.EncodeToString(hashSum[:]) + + Convey("When the BuildObjectRequest method is called", func() { + wrapper.BuildObjectRequest(oid) + + Convey("The object wrapper should be a valid 'getobject' object", func() { + So(wrapper.Type, ShouldEqual, "getobject") + So(wrapper.ObjectID, ShouldEqual, oid) + }) + }) + + Convey("When the BuildGossipObject method is called", func() { + wrapper.BuildGossipObject(oid) + + Convey("The object wrapper should be a valid 'ihaveobject' object", func() { + So(wrapper.Type, ShouldEqual, "ihaveobject") + So(wrapper.ObjectID, ShouldEqual, oid) + }) + }) + }) + + Convey("Given a new object", t, func() { + var object models.Object + + Convey("When the object is a valid transaction", func() { + raw := json.RawMessage(` + { + "height":1, + "outputs":[ + { + "pubkey":"62b7c521cd9211579cf70fd4099315643767b96711febaa5c76dc3daf27c281c", + "value":50000000000000 + } + ], + "type":"transaction" + }`) + + height := uint64(1) + + object = models.Object{ + Type: "object", + Object: raw, + } + + transaction := models.Transaction{ + Type: "transaction", + Height: &height, + Outputs: []models.Output{ + { + Pubkey: "62b7c521cd9211579cf70fd4099315643767b96711febaa5c76dc3daf27c281c", + Value: 50000000000000, + }, + }, + } + + Convey("Calling GetObjectValue should return the transaction and no error", func() { + res, err := object.GetObjectValue() + So(res, ShouldResemble, &transaction) + So(err, ShouldEqual, nil) + + Convey("Calling String should return the string representation of the transaction", func() { + So(res.String(), ShouldEqual, transaction.String()) + }) + }) + }) + + Convey("When the object is a valid block", func() { + previd := "00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e" + raw := json.RawMessage(` + { + "type": "block", + "txids": [ + "1bb37b637d07100cd26fc063dfd4c39a7931cc88dae3417871219715a5e374af" + ], + "nonce": "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + "previd": "` + previd + `", + "created": 1649827795114, + "T": "00000002af000000000000000000000000000000000000000000000000000000", + "miner": "svatsan", + "note": "First block. Yayy, I have 50 bu now!!" + }`) + + object = models.Object{ + Type: "object", + Object: raw, + } + + block := models.Block{ + Type: "block", + Txids: []string{ + "1bb37b637d07100cd26fc063dfd4c39a7931cc88dae3417871219715a5e374af", + }, + Nonce: "c5ee71be4ca85b160d352923a84f86f44b7fc4fe60002214bc1236ceedc5c615", + Previd: &previd, + Created: 1649827795114, + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "svatsan", + Note: "First block. Yayy, I have 50 bu now!!", + } + + Convey("Calling GetObjectValue should return the block and no error", func() { + res, err := object.GetObjectValue() + So(err, ShouldEqual, nil) + So(res, ShouldResemble, &block) + + Convey("Calling String should return the string representation of the block", func() { + So(res.String(), ShouldEqual, block.String()) + }) + }) + }) + + Convey("When the object is a random object", func() { + raw := json.RawMessage(` + { + "key": "value" + }`) + + object = models.Object{ + Type: "object", + Object: raw, + } + + Convey("Calling GetObjectValue should return a nil object and an error", func() { + res, err := object.GetObjectValue() + So(res, ShouldEqual, nil) + So(err, ShouldNotEqual, nil) + }) + }) + }) +} diff --git a/models/tests/state_test.go b/models/tests/state_test.go new file mode 100644 index 0000000..3c9d426 --- /dev/null +++ b/models/tests/state_test.go @@ -0,0 +1,421 @@ +package tests + +import ( + "kerma/helpers" + "kerma/models" + "os" + "testing" + + "github.com/docker/go/canonical/json" + . "github.com/smartystreets/goconvey/convey" +) + +func TestState(t *testing.T) { + // Prepare + testDir := t.TempDir() + t.Setenv("KERMA_STORE_BASE_DIR", testDir) + t.Setenv("KERMA_INITIAL_PEER_LIST", "example.com:18018") + + var validator helpers.Validator + var config helpers.Config + + validator.Construct() + config.Construct() + + peerList := []models.Peer{ + { + Name: "example.com:18018", + Active: false, + }, + } + + Convey("Given a new state object", t, func() { + var state models.State + + Convey("When the Construct method is called", func() { + state.Construct() + + Convey("A valid object should be created", func() { + So(state.Validator, ShouldResemble, validator) + So(state.Config, ShouldResemble, config) + So(state.PeerList, ShouldResemble, peerList) + So(state.Chain, ShouldResemble, []models.Block{*models.GetGenesisBlock()}) + So(state.Transactions, ShouldResemble, map[string]*models.Transaction{}) + }) + }) + + state.Construct() + Convey("When the CompleteHandshake method is called", func() { + Convey("When the peer is already in the list, it should be marked as active", func() { + state.CompleteHandshake("example.com:18018") + So(state.PeerList[0].Active, ShouldEqual, true) + }) + + Convey("When the peer is not in the list, it should be created and marked as active", func() { + state.CompleteHandshake("valid.host:18018") + So(state.PeerList[1].Name, ShouldEqual, "valid.host:18018") + So(state.PeerList[1].Active, ShouldEqual, true) + }) + }) + + Convey("When the CheckForHandshake method is called", func() { + Convey("When there is a finished handshake, 'true' should be returned", func() { + state.PeerList[0].Active = true + So(state.CheckForHandshake("example.com:18018"), ShouldEqual, true) + }) + + Convey("When there is no finished handshake, 'false' should be returned", func() { + So(state.CheckForHandshake("missing.host:18018"), ShouldEqual, false) + }) + }) + + Convey("When the FetchPeerListResponse method is called, the list of peers should be returned", func() { + res := state.FetchPeerListResponse() + + peerListResponse := models.PeerListResponse{ + Type: "peers", + Peers: []string{ + "example.com:18018", + }, + } + + So(res, ShouldResemble, peerListResponse) + }) + + Convey("When the FindPeerByName method is called", func() { + Convey("When the peer exists, it should be returned", func() { + res := state.FindPeerByName("example.com:18018") + So(res, ShouldEqual, &state.PeerList[0]) + }) + + Convey("When the peer does not exist, nil should be returned", func() { + res := state.FindPeerByName("example1.com:18018") + So(res, ShouldEqual, nil) + }) + }) + + Convey("When the RemovePeerByName method is called", func() { + SkipConvey("When the peer exists, it should be removed", func() { + prevLen := len(state.PeerList) + state.RemovePeerByName("example.com:18018") + So(len(state.PeerList), ShouldEqual, prevLen-1) + }) + + Convey("When the peer does not exist, nothing should be done", func() { + prevLen := len(state.PeerList) + state.RemovePeerByName("example1.com:18018") + So(len(state.PeerList), ShouldEqual, prevLen) + }) + }) + + Convey("When the ParsePeerListResponse method is called", func() { + Convey("With a non-empty input, all new peers should be added to the peer list", func() { + peerListResponse := models.PeerListResponse{ + Type: "peers", + Peers: []string{ + "sub.example.com:18018", + "sub1.example.com:18018", + }, + } + + state.ParsePeerListResponse(&peerListResponse) + So(state.FindPeerByName("sub.example.com:18018"), ShouldResemble, &models.Peer{Name: "sub.example.com:18018", Active: false}) + So(state.FindPeerByName("sub1.example.com:18018"), ShouldResemble, &models.Peer{Name: "sub1.example.com:18018", Active: false}) + }) + + Convey("With an empty input, no new peers should be added to the peer list", func() { + peerListResponse := models.PeerListResponse{ + Type: "peers", + Peers: []string{}, + } + + prevLen := len(state.PeerList) + state.ParsePeerListResponse(&peerListResponse) + So(len(state.PeerList), ShouldEqual, prevLen) + }) + }) + + Convey("When the DumpPeerListStore method is called, the peer list should be saved to the peer list store file", func() { + state.DumpPeerListStore() + + res, oserr := os.Stat(state.Config.PeerListStore) + + file, _ := os.ReadFile(state.Config.PeerListStore) + peerList := []string{} + err := json.Unmarshal([]byte(file), &peerList) + + So(oserr, ShouldBeNil) + So(res.Size(), ShouldNotEqual, 0) + So(res.IsDir(), ShouldBeFalse) + So(err, ShouldBeNil) + So(len(peerList), ShouldEqual, 3) + So(peerList[0], ShouldEqual, "example.com:18018") + }) + + Convey("Given a correct block", func() { + previd := "00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e" + txid := "2a9458a2e75ed8bd0341b3cb2ab21015bbc13f21ea06229340a7b2b75720c4df" + height := uint64(1) + transaction := models.Transaction{ + Type: "transaction", + Height: &height, + Outputs: []models.Output{ + { + Pubkey: "f66c7d51551d344b74e071d3b988d2bc09c3ffa82857302620d14f2469cfbf60", + Value: 50000000000000, + }, + }, + } + + state.Transactions[txid] = &transaction + + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "000000000000000000000000000000000000000000000000000000009d8b60ea", + Previd: &previd, + Created: 1624220079, + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "Snekel testminer", + Note: "First block after genesis with CBTX", + } + + Convey("When AppendToChain is called", func() { + err := state.AppendToChain(&block) + + Convey("No error should be returned", func() { + So(err, ShouldBeNil) + }) + + Convey("When GetChainTip is called, the new block should be returned", func() { + So(state.GetChainTip(), ShouldResemble, &block) + }) + + Convey("When GetBlock is called, the new block should be returned", func() { + So(state.GetBlock(block.GetID()), ShouldResemble, &block) + }) + + Convey("When GetMissingTransactionsInBlock is called, an empty array and no error should be returned", func() { + res, err := state.GetMissingTransactionsInBlock(&block) + So(res, ShouldBeEmpty) + So(err, ShouldBeNil) + }) + + Convey("When the DumpBlockStore method is called, the blockchain should be saved to the blockchain store file", func() { + state.DumpBlockStore() + + res, oserr := os.Stat(state.Config.BlockStore) + + file, _ := os.ReadFile(state.Config.BlockStore) + chain := []models.Block{} + err := json.Unmarshal([]byte(file), &chain) + + // Set UTXO of both chain blocks and Height, as they are always recalculated + genesis := models.GetGenesisBlock() + genesis.UTXOSet = map[string]uint64(nil) + block.UTXOSet = map[string]uint64(nil) + block.Height = 0 + + So(oserr, ShouldBeNil) + So(res.Size(), ShouldNotEqual, 0) + So(res.IsDir(), ShouldBeFalse) + So(err, ShouldBeNil) + So(len(chain), ShouldEqual, 2) + So(&chain[0], ShouldResemble, genesis) + So(&chain[1], ShouldResemble, &block) + }) + }) + }) + + Convey("Given an incorrect block", func() { + previd := "00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e" + txid := "2a9458a2e75ed8bd0341b3cb2ab21015bbc13f21ea06229340a7b2b75720c4df" + height := uint64(1) + transaction := models.Transaction{ + Type: "transaction", + Height: &height, + Outputs: []models.Output{ + { + Pubkey: "f66c7d51551d344b74e071d3b988d2bc09c3ffa82857302620d14f2469cfbf60", + Value: 50000000000000, + }, + }, + } + + state.Transactions[txid] = &transaction + + block := models.Block{ + Type: "block", + Txids: []string{ + txid, + }, + Nonce: "000000000000000000000000000000000000000000000000000000009d8b60e2", + Previd: &previd, + Created: 1624220079, + Target: "00000002af000000000000000000000000000000000000000000000000000000", + Miner: "Snekel testminer", + Note: "First block after genesis with CBTX", + } + + Convey("When AppendToChain is called", func() { + err := state.AppendToChain(&block) + + Convey("An error should be returned", func() { + So(err, ShouldNotBeNil) + }) + }) + }) + + Convey("Given a valid coinbase transaction", func() { + height := uint64(1) + transaction := models.Transaction{ + Type: "transaction", + Height: &height, + Outputs: []models.Output{ + { + Pubkey: "62b7c521cd9211579cf70fd4099315643767b96711febaa5c76dc3daf27c281c", + Value: 50000000000000, + }, + }, + } + + Convey("When AppendTransaction is called", func() { + err := state.AppendTransaction(&transaction) + + Convey("No error should be returned", func() { + So(err, ShouldBeNil) + }) + + Convey("GetTransaction should return the transaction", func() { + So(state.GetTransaction(transaction.GetID()), ShouldResemble, &transaction) + }) + + Convey("GetMempoolTransactionIDs should return an empty array", func() { + So(state.GetMempoolTransactionIDs(), ShouldBeEmpty) + }) + }) + }) + + Convey("Given an invalid coinbase transaction", func() { + height := uint64(1) + transaction := models.Transaction{ + Type: "transaction", + Height: &height, + Outputs: []models.Output{}, + } + + Convey("When AppendTransaction is called", func() { + err := state.AppendTransaction(&transaction) + + Convey("An error should be returned", func() { + So(err, ShouldNotBeNil) + }) + + Convey("GetMempoolTransactionIDs should return an empty array", func() { + So(state.GetMempoolTransactionIDs(), ShouldBeEmpty) + }) + }) + }) + + Convey("Given a valid regular transaction", func() { + height := uint64(1) + coinbase := models.Transaction{ + Type: "transaction", + Height: &height, + Confirmed: true, + Outputs: []models.Output{ + { + Pubkey: "f66c7d51551d344b74e071d3b988d2bc09c3ffa82857302620d14f2469cfbf60", + Value: 50000000000000, + }, + }, + } + + state.Transactions[coinbase.GetID()] = &coinbase + + transaction := models.Transaction{ + Type: "transaction", + Inputs: []models.Input{ + { + Outpoint: models.Outpoint{ + Index: 0, + Txid: "2a9458a2e75ed8bd0341b3cb2ab21015bbc13f21ea06229340a7b2b75720c4df", + }, + Sig: "49cc4f9a1fb9d600a7debc99150e7909274c8c74edd7ca183626dfe49eb4aa21c6ff0e4c5f0dc2a328ad6b8ba10bf7169d5f42993a94bf67e13afa943b749c0b", + }, + }, + Outputs: []models.Output{ + { + Pubkey: "c7c2c13afd02be7986dee0f4630df01abdbc950ea379055f1a423a6090f1b2b3", + Value: 50, + }, + }, + } + + Convey("When AppendTransaction is called", func() { + err := state.AppendTransaction(&transaction) + + Convey("No error should be returned", func() { + So(err, ShouldBeNil) + }) + + Convey("GetTransaction should return the transaction", func() { + So(state.GetTransaction(transaction.GetID()), ShouldResemble, &transaction) + }) + + Convey("GetMempoolTransactionIDs should return an array with the transaction", func() { + So(state.GetMempoolTransactionIDs(), ShouldNotBeEmpty) + So(len(state.GetMempoolTransactionIDs()), ShouldEqual, 1) + So(state.GetMempoolTransactionIDs()[0], ShouldEqual, transaction.GetID()) + }) + + Convey("When the DumpTransactionStore method is called, the confirmed transactions should be saved to the store file", func() { + state.DumpTransactionStore() + + res, oserr := os.Stat(state.Config.TransactionStore) + + file, _ := os.ReadFile(state.Config.TransactionStore) + store := map[string]*models.Transaction{} + err := json.Unmarshal([]byte(file), &store) + + // Set confirmed to false, as this is set in the parse function + coinbase.Confirmed = false + + So(oserr, ShouldBeNil) + So(res.Size(), ShouldNotEqual, 0) + So(res.IsDir(), ShouldBeFalse) + So(err, ShouldBeNil) + So(len(store), ShouldEqual, 1) + So(store[coinbase.GetID()], ShouldResemble, &coinbase) + }) + }) + }) + + Convey("Given an invalid regular transaction", func() { + transaction := models.Transaction{ + Type: "transaction", + Inputs: []models.Input{}, + Outputs: []models.Output{ + { + Pubkey: "c7c2c13afd02be7986dee0f4630df01abdbc950ea379055f1a423a6090f1b2b3", + Value: 50, + }, + }, + } + + Convey("When AppendTransaction is called", func() { + err := state.AppendTransaction(&transaction) + + Convey("An error should be returned", func() { + So(err, ShouldNotBeNil) + }) + + Convey("GetMempoolTransactionIDs should return an empty array", func() { + So(state.GetMempoolTransactionIDs(), ShouldBeEmpty) + }) + }) + }) + }) +} diff --git a/models/tests/transaction_test.go b/models/tests/transaction_test.go new file mode 100644 index 0000000..166188c --- /dev/null +++ b/models/tests/transaction_test.go @@ -0,0 +1,202 @@ +package tests + +import ( + "crypto/sha256" + "encoding/hex" + "kerma/models" + "testing" + + "github.com/docker/go/canonical/json" + . "github.com/smartystreets/goconvey/convey" +) + +func TestTransaction(t *testing.T) { + Convey("Given a new coinbase transaction", t, func() { + height := uint64(1) + transaction := models.Transaction{ + Type: "transaction", + Height: &height, + Outputs: []models.Output{ + { + Pubkey: "62b7c521cd9211579cf70fd4099315643767b96711febaa5c76dc3daf27c281c", + Value: 50000000000000, + }, + }, + } + + Convey("When GetType is called, 'transaction' should be returned", func() { + So(transaction.GetType(), ShouldEqual, "transaction") + }) + + Convey("When GetEntity is called, the transaction should be returned", func() { + So(transaction.GetEntity(), ShouldResemble, &transaction) + }) + + Convey("When Validate is called, no error should be returned", func() { + So(transaction.Validate(), ShouldEqual, nil) + }) + + Convey("When IsCoinbaseTx is called, 'true' should be returned", func() { + So(transaction.IsCoinbaseTx(), ShouldEqual, true) + }) + + Convey("When IsTxInput is called, 'false' should be returned", func() { + So(transaction.IsTxInput("dasdfasfds"), ShouldEqual, false) + }) + + Convey("When Hash is called, the hash of the cannonical json and no error should be returned", func() { + transactionJSON, _ := json.MarshalCanonical(transaction) + hashSum := sha256.Sum256(transactionJSON) + + res, err := transaction.Hash() + So(hex.EncodeToString(hashSum[:]), ShouldEqual, res) + So(err, ShouldEqual, nil) + }) + }) + + Convey("Given a new regular transaction", t, func() { + transaction := models.Transaction{ + Type: "transaction", + Inputs: []models.Input{ + { + Outpoint: models.Outpoint{ + Index: 0, + Txid: "2fb7adb654b373e85c6b5c596cc110dcb6643ee138768f4aa947e9ddb7d91f8d", + }, + Sig: "1bc4c05ec180932f08b95a8b5be308bb7b90c4d047720c4953440ea7cf56ba38b7e3b52ae586b594a6ae6649d8be0ae3d6944ffe9a7c5894622c33b9df276909", + }, + }, + Outputs: []models.Output{ + { + Pubkey: "857debb2084fc8c87dec10d305993e781d9c9dbf6a81762b2f245095ae6b8fb9", + Value: 50, + }, + }, + } + + Convey("When GetType is called, 'transaction' should be returned", func() { + So(transaction.GetType(), ShouldEqual, "transaction") + }) + + Convey("When GetEntity is called, the transaction should be returned", func() { + So(transaction.GetEntity(), ShouldResemble, &transaction) + }) + + Convey("When Validate is called, no error should be returned", func() { + So(transaction.Validate(), ShouldEqual, nil) + }) + + Convey("When IsCoinbaseTx is called, 'false' should be returned", func() { + So(transaction.IsCoinbaseTx(), ShouldEqual, false) + }) + + Convey("When IsTxInput is called with an input, 'true' should be returned", func() { + So(transaction.IsTxInput("2fb7adb654b373e85c6b5c596cc110dcb6643ee138768f4aa947e9ddb7d91f8d"), ShouldEqual, true) + }) + + Convey("When IsTxInput is called with a non-input, 'false' should be returned", func() { + So(transaction.IsTxInput("dasdfasfds"), ShouldEqual, false) + }) + + Convey("When Hash is called, the hash of the cannonical json and no error should be returned", func() { + transactionJSON, _ := json.MarshalCanonical(transaction) + hashSum := sha256.Sum256(transactionJSON) + + res, err := transaction.Hash() + So(hex.EncodeToString(hashSum[:]), ShouldEqual, res) + So(err, ShouldEqual, nil) + }) + + Convey("When VerifySign is called with a correct signature, 'true' should be returned", func() { + So(transaction.VerifySign("1bc4c05ec180932f08b95a8b5be308bb7b90c4d047720c4953440ea7cf56ba38b7e3b52ae586b594a6ae6649d8be0ae3d6944ffe9a7c5894622c33b9df276909", "57558a6dae91ac3ab8caf3f543eac9c51cba4ac680ba5ba0d81b5575dc06bc46"), ShouldEqual, true) + }) + + Convey("When VerifySign is called with an incorrect signature, 'false' should be returned", func() { + So(transaction.VerifySign("", ""), ShouldEqual, false) + }) + }) + + Convey("Given a new incorrect regular transaction", t, func() { + Convey("With two inputs pointing to the same outpoint", func() { + transaction := models.Transaction{ + Type: "transaction", + Inputs: []models.Input{ + { + Outpoint: models.Outpoint{ + Index: 0, + Txid: "2fb7adb654b373e85c6b5c596cc110dcb6643ee138768f4aa947e9ddb7d91f8d", + }, + Sig: "1bc4c05ec180932f08b95a8b5be308bb7b90c4d047720c4953440ea7cf56ba38b7e3b52ae586b594a6ae6649d8be0ae3d6944ffe9a7c5894622c33b9df276909", + }, + { + Outpoint: models.Outpoint{ + Index: 0, + Txid: "2fb7adb654b373e85c6b5c596cc110dcb6643ee138768f4aa947e9ddb7d91f8d", + }, + Sig: "1bc4c05ec180932f08b95a8b5be308bb7b90c4d047720c4953440ea7cf56ba38b7e3b52ae586b594a6ae6649d8be0ae3d6944ffe9a7c5894622c33b9df276909", + }, + }, + Outputs: []models.Output{ + { + Pubkey: "857debb2084fc8c87dec10d305993e781d9c9dbf6a81762b2f245095ae6b8fb9", + Value: 50, + }, + }, + } + + Convey("When Validate is called, an error should be returned", func() { + So(transaction.Validate().Error(), ShouldEqual, "multiple transaction inputs with same outpoint found") + }) + }) + + Convey("With negative outpoint index value", func() { + transaction := models.Transaction{ + Type: "transaction", + Inputs: []models.Input{ + { + Outpoint: models.Outpoint{ + Index: -10, + Txid: "2fb7adb654b373e85c6b5c596cc110dcb6643ee138768f4aa947e9ddb7d91f8d", + }, + Sig: "1bc4c05ec180932f08b95a8b5be308bb7b90c4d047720c4953440ea7cf56ba38b7e3b52ae586b594a6ae6649d8be0ae3d6944ffe9a7c5894622c33b9df276909", + }, + }, + Outputs: []models.Output{ + { + Pubkey: "857debb2084fc8c87dec10d305993e781d9c9dbf6a81762b2f245095ae6b8fb9", + Value: 50, + }, + }, + } + + Convey("When Validate is called, an error should be returned", func() { + So(transaction.Validate().Error(), ShouldEqual, "transaction input index cannot be negative") + }) + }) + + Convey("With a pubkey that is not hex string", func() { + transaction := models.Transaction{ + Type: "transaction", + Inputs: []models.Input{ + { + Outpoint: models.Outpoint{ + Index: 0, + Txid: "2fb7adb654b373e85c6b5c596cc110dcb6643ee138768f4aa947e9ddb7d91f8d", + }, + Sig: "1bc4c05ec180932f08b95a8b5be308bb7b90c4d047720c4953440ea7cf56ba38b7e3b52ae586b594a6ae6649d8be0ae3d6944ffe9a7c5894622c33b9df276909", + }, + }, + Outputs: []models.Output{ + { + Pubkey: "VeryInvalidPubkeyValueWOWThisIsSoInvalidOhMyGod", + Value: 50, + }, + }, + } + + Convey("When Validate is called, an error should be returned", func() { + So(transaction.Validate(), ShouldNotBeNil) + }) + }) + }) +} diff --git a/models/transaction.go b/models/transaction.go new file mode 100644 index 0000000..99f2bec --- /dev/null +++ b/models/transaction.go @@ -0,0 +1,251 @@ +// Package models provides the models for the KermaGo application +package models + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "errors" + "kerma/helpers" + "strconv" + + "github.com/docker/go/canonical/json" +) + +/* +An Outpoint is a struct that represents an outpoint as per the Kerma specification +*/ +type Outpoint struct { + Txid string `json:"txid" binding:"required"` + Index int64 `json:"index" binding:"required"` +} + +/* +An Input is a struct that represents an input as per the Kerma specification +*/ +type Input struct { + Outpoint Outpoint `json:"outpoint" binding:"required"` + Sig string `json:"sig" binding:"required"` +} + +type inputVerifier struct { + Outpoint Outpoint `json:"outpoint" binding:"required"` + Sig *string `json:"sig" binding:"required"` +} + +/* +An Output is a struct that represents an output as per the Kerma specification +*/ +type Output struct { + Pubkey string `json:"pubkey" binding:"required"` + Value uint64 `json:"value" binding:"required"` +} + +/* +A Transaction is a struct that represents a transaction as per the Kerma specification +*/ +type Transaction struct { + Type string `json:"type" binding:"required"` + Height *uint64 `json:"height,omitempty"` + Inputs []Input `json:"inputs,omitempty"` + Outputs []Output `json:"outputs,omitempty"` + Confirmed bool `json:"-"` +} + +type transactionVerifier struct { + Type string `json:"type" binding:"required"` + Inputs []inputVerifier `json:"inputs,omitempty"` + Outputs []Output `json:"outputs,omitempty"` +} + +/* +Hash returns the sha256 hash of the canonical JSON of the transaction +*/ +func (t *Transaction) Hash() (string, error) { + transaction, err := t.MarshalJson() + if err != nil { + return "", errors.New("could not parse transaction as json") + } + hashSum := sha256.Sum256(transaction) + + return hex.EncodeToString(hashSum[:]), nil +} + +/* +GetID returns the transaction id +*/ +func (b *Transaction) GetID() string { + id, err := b.Hash() + if err != nil { + return "" + } + + return id +} + +/* +GetType returns the type of the transaction +*/ +func (t *Transaction) GetType() string { + return t.Type +} + +/* +GetEntity returns the transaction +*/ +func (t *Transaction) GetEntity() interface{} { + return t +} + +/* +MarshalJson returns the canonical JSON of the transaction +*/ +func (t *Transaction) MarshalJson() ([]byte, error) { + return json.MarshalCanonical(t) +} + +/* +UnmarshalJSON creates a transaction from the input JSON byte array +*/ +func (t *Transaction) UnmarshalJSON(data []byte) error { + if len(data) == 0 { + return nil + } + + type tmp Transaction + err := json.Unmarshal(data, (*tmp)(t)) + if err != nil { + return err + } + + err = t.Validate() + if err != nil { + return err + } + + return nil +} + +/* +Validate is responsible for validating the transaction; returns an error if unsuccessful +*/ +func (t *Transaction) Validate() error { + if t.Type != "transaction" { + return errors.New("object not a transaction") + } + + if t.IsCoinbaseTx() { + if len(t.Inputs) > 0 { + return errors.New("coinbase transactions cannot have an input") + } + + if len(t.Outputs) != 1 { + return errors.New("coinbase transactions must have exactly one output") + } + } + + txOutpoints := map[string]int{} + + for _, input := range t.Inputs { + if input.Outpoint.Index < 0 { + return errors.New("transaction input index cannot be negative") + } + + outpointId := input.Outpoint.Txid + "-" + strconv.FormatInt(input.Outpoint.Index, 10) + if _, ok := txOutpoints[outpointId]; ok { + return errors.New("multiple transaction inputs with same outpoint found") + } + + txOutpoints[outpointId] = 1 + } + + for _, output := range t.Outputs { + if output.Value < 0 { + return errors.New("transaction output value cannot be negative") + } + + pubkeyBytes, err := hex.DecodeString(output.Pubkey) + if err != nil { + return err + } + + if ed25519.PublicKey(string(pubkeyBytes)) == nil { + return errors.New("invalid public key value, transaction invalid") + } + } + return nil +} + +/* +String returns the canonical JSON of the transaction as a string +*/ +func (t *Transaction) String() string { + res, _ := t.MarshalJson() + return string(res) +} + +/* +VerifySign verifies whether the signature of the transaction with the provided pubkey matches the signature provided +*/ +func (t *Transaction) VerifySign(sig string, pubkeyString string) bool { + var logger helpers.Logger + + newTransaction := transactionVerifier{ + Type: t.Type, + Inputs: []inputVerifier{}, + Outputs: t.Outputs, + } + + for _, i := range t.Inputs { + newInput := inputVerifier{ + Outpoint: i.Outpoint, + Sig: nil, + } + + newTransaction.Inputs = append(newTransaction.Inputs, newInput) + } + + txBytes := new(bytes.Buffer) + encoder := json.NewEncoder(txBytes) + encoder.Canonical() + encoder.Encode(newTransaction) + + pubkeyBytes, err := hex.DecodeString(pubkeyString) + if err != nil { + return false + } + + sigBytes, err := hex.DecodeString(sig) + if err != nil { + return false + } + + logger.Debug("Checking transaction signature with params: SIGNATURE(" + sig + "), PUBKEY(" + pubkeyString + "), TRANSACTION(" + txBytes.String() + ")") + + pubkey := ed25519.PublicKey(string(pubkeyBytes)) + if len(pubkey) != ed25519.PublicKeySize { + return false + } + + return ed25519.Verify(ed25519.PublicKey(pubkey), txBytes.Bytes(), sigBytes) +} + +/* +IsTxInput checks if the transaction ID supplied is an input of the current transaction +*/ +func (t *Transaction) IsTxInput(txid string) bool { + for _, input := range t.Inputs { + if txid == input.Outpoint.Txid { + return true + } + } + return false +} + +/* +IsCoinbaseTx checks if the transaction is a coinbase transaction +*/ +func (t *Transaction) IsCoinbaseTx() bool { + return t.Height != nil +} diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..2f8b2f3 --- /dev/null +++ b/run.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Please read the README for a full list of environment variables +echo "DOCKER_REGISTRY=localhost" >.env +echo "KERMA_STORE_BASE_DIR=/var/local/badkerma" >>.env +echo "==================================================" +echo "Starting with an empty peer list for readability. Please add KERMA_INITIAL_PEER_LIST to .env if you'd like to emulate a host run." +echo "==================================================" + +make docker-debug diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..f184d1a --- /dev/null +++ b/test.txt @@ -0,0 +1,13 @@ +{"type":"hello","version":"0.8.0"} +{"type":"peers","peers":[]} +{"type":"mempool", "txids": ["2a9458a2e75ed8bd0341b3cb2ab21015bbc13f21ea06229340a7b2b75720c4df"]} +{"type":"object", "object":{"T":"00000002af000000000000000000000000000000000000000000000000000000","created":1624221079,"miner":"Snekel testminer","nonce":"00000000000000000000000000000000000000000000000000000000182b95ea","note":"Second block after genesis with CBTX and TX","previd":"0000000108bdb42de5993bcf5f7d92557585dd6abfe9fb68e796518fe7f2ed2e","txids":["73231cc901774ddb4196ee7e9e6b857b208eea04aee26ced038ac465e1e706d2","7ef80f2da40b3f681a5aeb7962731beddccea25fa51e6e7ae6fbce8a58dbe799"],"type":"block"}} +{"type":"object","object":{"height":1,"outputs":[{"pubkey":"f66c7d51551d344b74e071d3b988d2bc09c3ffa82857302620d14f2469cfbf60","value":50000000000000}],"type":"transaction"}} +{"type":"object", "object":{"T":"00000002af000000000000000000000000000000000000000000000000000000","created":1624220079,"miner":"Snekel testminer","nonce":"000000000000000000000000000000000000000000000000000000009d8b60ea","note":"First block after genesis with CBTX","previd":"00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e","txids":["2a9458a2e75ed8bd0341b3cb2ab21015bbc13f21ea06229340a7b2b75720c4df"],"type":"block"}} +{"type":"object", "object":{"height":2,"outputs":[{"pubkey":"c7c2c13afd02be7986dee0f4630df01abdbc950ea379055f1a423a6090f1b2b3","value":50000000000000}],"type":"transaction"}} +{"type":"object", "object":{"inputs":[{"outpoint":{"index":0,"txid":"2a9458a2e75ed8bd0341b3cb2ab21015bbc13f21ea06229340a7b2b75720c4df"},"sig":"49cc4f9a1fb9d600a7debc99150e7909274c8c74edd7ca183626dfe49eb4aa21c6ff0e4c5f0dc2a328ad6b8ba10bf7169d5f42993a94bf67e13afa943b749c0b"}],"outputs":[{"pubkey":"c7c2c13afd02be7986dee0f4630df01abdbc950ea379055f1a423a6090f1b2b3","value":50}],"type":"transaction"}} +{"type": "getmempool"} +{"object":{"T":"00000002af000000000000000000000000000000000000000000000000000000","created":1652761335,"miner":"grader","nonce":"0000000000000000000000000000000000000000000000000000000015d586f3","previd":"000000001d70ea8d2a7997407b62551b37b88ed0075bba0615ab32cba37ef7d4","txids":[],"type":"block"},"type":"object"} +{"object":{"T":"00000002af000000000000000000000000000000000000000000000000000000","created":1652761333,"miner":"grader","nonce":"0000000000000000000000000000000000000000000000000000000000a78010","previd":"000000020ac07f9f5960e593a74843bb184ff5c867e3d20f8466fd74ecfc4b35","txids":[],"type":"block"},"type":"object"} +{"object":{"T":"00000002af000000000000000000000000000000000000000000000000000000","created":1652760150,"miner":"grader","nonce":"000000000000000000000000000000000000000000000000000000010508fbb0","previd":"00000000a420b7cefa2b7730243316921ed59ffe836e111ca3801f82a4f5360e","txids":[],"type":"block"},"type":"object"} +{"objectid":"000000006af19d99c7a2ad8ab05a6bd2496c5a3f4676019a376a72bfb287536e","type":"getobject"} diff --git a/utils/client.go b/utils/client.go new file mode 100644 index 0000000..5be115f --- /dev/null +++ b/utils/client.go @@ -0,0 +1,167 @@ +// Package utils provides utilities that are needed for the functioning of Kerma +package utils + +import ( + "bufio" + "errors" + "kerma/helpers" + "kerma/models" + "net" + "time" + + "github.com/docker/go/canonical/json" +) + +/* +A Client represents a Kerma client with the following properties: +- State - the current state of the application +- Handler - a TCPHandler that is used to handle requests to and from remote peers +- Logger +*/ +type Client struct { + State *models.State + Handler TCPHandler + Logger helpers.Logger +} + +/* +Construct creates a new client with the specified state and handler objects +*/ +func (c *Client) Construct(state *models.State, h TCPHandler) { + c.State = state + c.Handler = h +} + +/* +InitHandshake initiates a handshake on the specified connection +*/ +func (c *Client) InitHandshake(peerName string) { + var resp models.Hello + resp.Construct() + respJSON, _ := json.MarshalCanonical(resp) + + c.Handler.Output(respJSON) + c.Handler.Conn.SetDeadline(time.Now().Add(time.Duration(c.State.Config.ConnTimeout) * time.Second)) + in, err := bufio.NewReader(c.Handler.Conn).ReadBytes('\n') + if err != nil { + c.Logger.Error(err.Error()) + if errors.Is(err, net.ErrClosed) { + c.State.RemovePeerByName(peerName) + } else { + c.Handler.Fail("Failed reading message") + } + return + } + + msg, err := c.Handler.Input(in) + if err != nil { + c.Logger.Error(err.Error()) + c.Handler.Fail("Failed parsing message") + return + } + + msgJSON, err := json.MarshalCanonical(msg) + if err != nil { + c.Logger.Error("Failed parsing message " + err.Error()) + c.Handler.Fail("Failed parsing message") + return + } + + if msgType, ok := msg["type"]; ok { + if msgType == "hello" { + var helloReq models.Hello + err := helloReq.UnmarshalJSON(msgJSON) + + if err == nil { + c.Logger.Info("Constructing handshake with " + c.Handler.GetRemotePeer()) + c.State.CompleteHandshake(peerName) + } else { + c.Handler.Fail("Hello response invalid") + return + } + } else { + c.Handler.Fail("Response type not expected " + msgType.(string)) + return + } + } else { + c.Handler.Fail("Response type not specified") + return + } +} + +/* +DiscoverPeers requests remote peers from a known peer +*/ +func (c *Client) DiscoverPeers(peerName string) { + if c.State.CheckForHandshake(peerName) { + var req models.Generic + req.BuildPeerRequest() + reqJSON, _ := req.MarshalJson() + c.Handler.Output(reqJSON) + } +} + +/* +DiscoverChainTip requests chaintip from a known peer +*/ +func (c *Client) DiscoverChainTip(peerName string) { + if c.State.CheckForHandshake(peerName) { + var req models.Generic + req.BuildChainTipRequest() + reqJSON, _ := req.MarshalJson() + c.Handler.Output(reqJSON) + } +} + +/* +DiscoverMempool requests mempool from a known peer +*/ +func (c *Client) DiscoverMempool(peerName string) { + if c.State.CheckForHandshake(peerName) { + var req models.Generic + req.BuildMempoolRequest() + reqJSON, _ := req.MarshalJson() + c.Handler.Output(reqJSON) + } +} + +/* +DiscoverObject requests remote objects from a known peer +*/ +func (c *Client) DiscoverObject(peerName string, objectID string) { + if c.State.CheckForHandshake(peerName) { + var req models.ObjectWrapper + req.BuildObjectRequest(objectID) + reqJSON, _ := req.MarshalJson() + c.Handler.Output(reqJSON) + } +} + +/* +GossipBlocks sends information about local blocks to peer +*/ +func (c *Client) GossipBlocks(peerName string) { + if c.State.CheckForHandshake(peerName) { + for _, block := range c.State.Chain { + var req models.ObjectWrapper + bid := block.GetID() + req.BuildGossipObject(bid) + reqJSON, _ := req.MarshalJson() + c.Handler.Output(reqJSON) + } + } +} + +/* +GossipTransactions sends information about local transactions to peer +*/ +func (c *Client) GossipTransactions(peerName string) { + if c.State.CheckForHandshake(peerName) { + for txid := range c.State.Transactions { + var req models.ObjectWrapper + req.BuildGossipObject(txid) + reqJSON, _ := req.MarshalJson() + c.Handler.Output(reqJSON) + } + } +} diff --git a/utils/handler.go b/utils/handler.go new file mode 100644 index 0000000..1a54c15 --- /dev/null +++ b/utils/handler.go @@ -0,0 +1,89 @@ +// Package utils provides utilities that are needed for the functioning of Kerma +package utils + +import ( + "errors" + "kerma/helpers" + "kerma/models" + "net" + "strconv" + + "github.com/docker/go/canonical/json" +) + +/* +A TCPHandler is a struct used for communicating with other Kerma nodes/clients +It consists of: +- Conn - a connection for sending and receiving requests +- Logger +- Config - the configuration of the application +*/ +type TCPHandler struct { + Conn net.Conn + Logger helpers.Logger + Config helpers.Config +} + +/* +Construct creates a new handler on the specified connection +*/ +func (h *TCPHandler) Construct(conn net.Conn) { + h.Conn = conn + h.Config.Construct() +} + +/* +Output handles outgoing data in a canonical JSON format +*/ +func (h *TCPHandler) Output(respJSON []byte) { + h.Logger.Info("OUT: " + h.GetRemotePeer() + " " + string(respJSON)) + respJSON = append(respJSON, '\n') + h.Conn.Write(respJSON) +} + +/* +Error handles outgoing errors in a canonical JSON format +*/ +func (h *TCPHandler) Error(errorMsg string) { + var resp models.Error + resp.Construct(errorMsg) + respJSON, _ := resp.MarshalJson() + h.Output(respJSON) +} + +/* +Fail handles outgoing errors in a canonical JSON format and terminates the connection +*/ +func (h *TCPHandler) Fail(errorMsg string) { + h.Error(errorMsg) + h.Conn.Close() +} + +/* +Input handles input data in a canonical JSON format +*/ +func (h *TCPHandler) Input(in []byte) (map[string](interface{}), error) { + var msg map[string](interface{}) + + err := json.Unmarshal(in, &msg) + if err != nil { + h.Logger.Error("Failed decoding message " + err.Error()) + return nil, errors.New("failed decoding message") + } + + return msg, nil +} + +/* +GetRemotePeer returns tha address of the remote peer as a string +*/ +func (h *TCPHandler) GetRemotePeer() string { + return h.Conn.RemoteAddr().String() +} + +/* +GetLocalPeer returns the address + port combination of the local peer as a string +*/ +func (h *TCPHandler) GetLocalPeer() string { + return h.Config.IPAddress + ":" + strconv.Itoa(h.Config.Port) +} diff --git a/utils/server.go b/utils/server.go new file mode 100644 index 0000000..feff2c3 --- /dev/null +++ b/utils/server.go @@ -0,0 +1,352 @@ +// Package utils provides utilities that are needed for the functioning of Kerma +package utils + +import ( + "bufio" + "errors" + "kerma/helpers" + "kerma/models" + "time" + + "github.com/docker/go/canonical/json" +) + +/* +A Server represents a Kerma server with the following properties: +- State - the current state of the application +- Handler - a TCPHandler that is used to handle requests to and from remote peers +- Logger +- Client - a new client that uses the same state and handler as this server +*/ +type Server struct { + State *models.State + Handler TCPHandler + Logger helpers.Logger + Client Client +} + +/* +Construct creates a new server with the specified state and handler objects +*/ +func (s *Server) Construct(state *models.State, h TCPHandler) { + s.State = state + s.Handler = h + s.Client.Construct(s.State, s.Handler) +} + +/* +Handle is responsible for handling requests from other Kerma nodes/clients +*/ +func (s *Server) Handle() { + // Decode message received from reader, fail on error + + scanner := bufio.NewScanner(s.Handler.Conn) + + for scanner.Scan() { + msg, err := s.Handler.Input(scanner.Bytes()) + if err != nil { + s.Handler.Fail(err.Error()) + } + + // Parse received message, fail on error + msgJSON, err := json.MarshalCanonical(msg) + if err != nil { + s.Logger.Error("Failed parsing message " + err.Error()) + s.Handler.Fail("Failed parsing message") + } + + s.Logger.Info("IN: " + s.Handler.GetRemotePeer() + " " + string(msgJSON)) + + remotePeer := s.Handler.GetRemotePeer() + + // check message type, proceed accordingly + if msgType, ok := msg["type"]; ok { + switch msgType { + case "error": + break + case "hello": + // check if peer has already handshaked + if !s.State.CheckForHandshake(remotePeer) { + // check if hello request is valid + var helloReq models.Hello + err := helloReq.UnmarshalJSON(msgJSON) + + if err == nil { + // finish handshake + s.Logger.Info("Constructing handshake with " + remotePeer) + var resp models.Hello + resp.Construct() + respJSON, _ := resp.MarshalJson() + s.Handler.Output(respJSON) + s.State.CompleteHandshake(remotePeer) + s.Client.DiscoverPeers(remotePeer) + } else { + s.Handler.Fail("Hello request invalid") + } + } + break + case "getpeers": + err := s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + // create list of known peers, send to requester + resp := s.State.FetchPeerListResponse() + resp.Peers = append(resp.Peers, s.Handler.GetLocalPeer()) + respJSON, _ := resp.MarshalJson() + s.Handler.Output(respJSON) + break + case "peers": + err := s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + var peers models.PeerListResponse + json.Unmarshal(msgJSON, &peers) + s.State.ParsePeerListResponse(&peers) + break + case "ihaveobject": + err := s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + var obj models.ObjectWrapper + err = obj.UnmarshalJSON(msgJSON) + if err != nil { + s.Handler.Fail("Could not parse request") + break + } + + if s.State.Transactions[obj.ObjectID] == nil { + resp := models.ObjectWrapper{ + Type: "getobject", + ObjectID: obj.ObjectID, + } + + respJSON, _ := resp.MarshalJson() + s.Handler.Output(respJSON) + } + case "getobject": + err = s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + var obj models.ObjectWrapper + err := obj.UnmarshalJSON(msgJSON) + if err != nil { + s.Handler.Fail("Could not parse request") + break + } + + transaction := s.State.GetTransaction(obj.ObjectID) + if transaction != nil { + s.Logger.Debug("Found transaction: " + obj.ObjectID) + resp := json.RawMessage(`{"type":"object","object":` + transaction.String() + `}`) + + respJSON, _ := resp.MarshalJSON() + s.Handler.Output(respJSON) + } + + block := s.State.GetBlock(obj.ObjectID) + if block != nil { + s.Logger.Debug("Found block: " + obj.ObjectID) + resp := json.RawMessage(`{"type":"object","object":` + block.String() + `}`) + + respJSON, _ := resp.MarshalJSON() + s.Handler.Output(respJSON) + } else { + s.Handler.Error("Could not find object " + obj.ObjectID) + } + break + case "object": + err = s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + var object models.Object + + err := object.UnmarshalJSON(msgJSON) + if err != nil { + s.Logger.Debug("Could not unmarshal: " + err.Error()) + s.Handler.Fail("Object request invalid") + break + } + + val, err := object.GetObjectValue() + if err != nil { + s.Logger.Error("Could not parse object: " + err.Error()) + s.Handler.Fail("Could not parse object request") + break + } + + switch val.GetType() { + case "transaction": + err := s.State.AppendTransaction(val.GetEntity().(*models.Transaction)) + if err != nil { + s.Logger.Debug("Failed appending transaction: " + err.Error()) + s.Handler.Fail("Error appending transaction") + break + } + + break + case "block": + block := val.(*models.Block) + + go s.appendBlockToChain(block) + break + default: + s.Logger.Debug("Something went wrong with " + string(msgJSON)) + s.Handler.Fail("Could not parse object request") + } + break + case "getchaintip": + err := s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + res := s.State.GetChainTip() + + var chaintip models.Chaintip + chaintip.Construct(res.GetID()) + respJSON, _ := chaintip.MarshalJson() + s.Handler.Output(respJSON) + break + case "chaintip": + err := s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + var chaintip models.Chaintip + + err = chaintip.UnmarshalJSON(msgJSON) + if err != nil { + s.Logger.Debug("Could not unmarshal: " + err.Error()) + s.Handler.Fail("Object request invalid") + break + } + + res := s.State.GetChainTip() + if res.GetID() != chaintip.BlockID { + s.Client.DiscoverObject(remotePeer, chaintip.BlockID) + } + break + case "getmempool": + err := s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + res := s.State.GetMempoolTransactionIDs() + + var mempool models.Mempool + mempool.Construct(res) + respJSON, _ := mempool.MarshalJson() + s.Handler.Output(respJSON) + break + case "mempool": + err := s.terminateIfNoHandshake(remotePeer) + if err != nil { + break + } + + var mempool models.Mempool + + err = mempool.UnmarshalJSON(msgJSON) + if err != nil { + s.Logger.Debug("Could not unmarshal: " + err.Error()) + s.Handler.Fail("Object request invalid") + break + } + + for _, txid := range mempool.Txids { + res := s.State.GetTransaction(txid) + if res == nil { + s.Client.DiscoverObject(remotePeer, txid) + } + } + break + + default: + s.Handler.Fail("Request type not supported " + msgType.(string)) + } + } else { + s.Handler.Fail("Request type not specified") + } + } +} + +func (s *Server) terminateIfNoHandshake(remotePeer string) error { + if !s.State.CheckForHandshake(remotePeer) { + s.Handler.Fail("Handshake not completed") + return errors.New("handshake not completed with peer " + remotePeer) + } + return nil +} + +func (s *Server) appendBlockToChain(block *models.Block) { + dontAppend := false + missingTx, err := s.State.GetMissingTransactionsInBlock(block) + if err != nil { + s.Logger.Debug("Failed appending block: " + err.Error()) + s.Handler.Fail("Error appending block") + return + } + + if len(missingTx) != 0 { + for _, txid := range missingTx { + s.Client.DiscoverObject(s.Handler.GetRemotePeer(), txid) + waited := 0 + for s.State.GetTransaction(txid) == nil { + s.Logger.Debug("Waiting for transactions " + txid) + time.Sleep(500 * time.Millisecond) + waited += 500 + if waited*int(time.Millisecond) >= s.State.Config.ConnTimeout*int(time.Second) { + s.Handler.Fail("Did not receive requested transactions in time, failed appending block") + dontAppend = true + return + } + } + } + } + + s.getMissingBlocks(block) + + if !dontAppend { + err = s.State.AppendToChain(block) + if err != nil { + s.Logger.Debug("Failed appending block: " + err.Error()) + s.Handler.Fail("Error appending block") + } + + s.State.DumpBlockStore() + for _, peer := range s.State.PeerList { + if peer.Active { + s.Client.GossipBlocks(peer.Name) + } + } + } +} + +func (s *Server) getMissingBlocks(block *models.Block) { + blockId := *block.Previd + if s.State.GetBlock(blockId) != nil { + return + } + + s.Logger.Debug("Block missing from chain: " + blockId) + for _, peer := range s.State.PeerList { + s.Client.DiscoverObject(peer.Name, blockId) + } + + for s.State.GetBlock(blockId) == nil { + s.Logger.Debug("Waiting for block " + blockId) + time.Sleep(500 * time.Millisecond) + } +} diff --git a/utils/tests/client_test.go b/utils/tests/client_test.go new file mode 100644 index 0000000..334c869 --- /dev/null +++ b/utils/tests/client_test.go @@ -0,0 +1,38 @@ +package tests + +import ( + "kerma/helpers" + "kerma/models" + "kerma/utils" + "net" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestClient(t *testing.T) { + host := "127.0.0.1:1338" + net.Listen("tcp", host) + conn, _ := net.Dial("tcp", host) + + defer conn.Close() + + var config helpers.Config + config.Construct() + + var handler utils.TCPHandler + handler.Construct(conn) + + var state models.State + state.Construct() + + Convey("Given a new client", t, func() { + var client utils.Client + client.Construct(&state, handler) + + Convey("When Construct is called, a new object is returned", func() { + So(client.State, ShouldResemble, &state) + So(client.Handler, ShouldResemble, handler) + }) + }) +} diff --git a/utils/tests/handler_test.go b/utils/tests/handler_test.go new file mode 100644 index 0000000..298d711 --- /dev/null +++ b/utils/tests/handler_test.go @@ -0,0 +1,51 @@ +package tests + +import ( + "bufio" + "kerma/helpers" + "kerma/utils" + "net" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestHandler(t *testing.T) { + host := "127.0.0.1:1337" + net.Listen("tcp", host) + conn, _ := net.Dial("tcp", host) + scanner := bufio.NewScanner(conn) + + var config helpers.Config + config.Construct() + + Convey("Given a new handler", t, func() { + var handler utils.TCPHandler + handler.Construct(conn) + + Convey("When Construct is called, a new object is returned", func() { + So(handler.Conn, ShouldResemble, conn) + So(handler.Config, ShouldResemble, config) + }) + + Convey("When Output is called, a JSON string should be returned on the connection", func() { + jsonString := `{"foo": "bar"}` + handler.Output([]byte(jsonString)) + resp := string(scanner.Bytes()) + + So(resp, ShouldEqual, resp) + }) + + Convey("When Input is called, a JSON string and no error should be obtained from the connection", func() { + jsonString := `{"foo": "bar"}` + resp, err := handler.Input([]byte(jsonString)) + + So(resp, ShouldEqual, resp) + So(err, ShouldBeNil) + }) + + Convey("When GetRemotePeer is called, the local connection should be returned", func() { + So(handler.GetRemotePeer(), ShouldEqual, host) + }) + }) +}