diff --git a/dogebox.json b/dogebox.json index d051ef4..67778a2 100644 --- a/dogebox.json +++ b/dogebox.json @@ -17,6 +17,9 @@ }, { "location": "identity" + }, + { + "location": "spv" } ] } diff --git a/spv/README.md b/spv/README.md new file mode 100644 index 0000000..4a6c476 --- /dev/null +++ b/spv/README.md @@ -0,0 +1,11 @@ +
+ Dogebox Logo +

Libdogecoin SPV

+
+ +> [!CAUTION] +> This pup does not have a stable release yet. + +This pup will install [Libdogecoin SPV](https://github.com/dogecoinfoundation/libdogecoin) as a pup on your node. + +It will generate a new wallet and start block sync from the last checkpoint. diff --git a/spv/logger/logger.go b/spv/logger/logger.go new file mode 100644 index 0000000..290339a --- /dev/null +++ b/spv/logger/logger.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "io" + "log" + "os" + "time" + + "path/filepath" +) + +var storageDirectory string +var debugLogFilePath = filepath.Join(storageDirectory, "output.log") + +func main() { + + for { + if _, err := os.Stat(debugLogFilePath); os.IsNotExist(err) { + log.Printf("Waiting for output.log file to be created at %s", debugLogFilePath) + time.Sleep(5 * time.Second) + continue + } + break + } + + file, err := os.Open(debugLogFilePath) + if err != nil { + log.Fatal(err) + } + defer file.Close() + + _, err = file.Seek(0, io.SeekEnd) + if err != nil { + log.Fatal(err) + } + + for { + time.Sleep(2 * time.Second) + + buffer := make([]byte, 1024) + for { + n, err := file.Read(buffer) + if err != nil && err != io.EOF { + log.Fatal(err) + } + if n == 0 { + break + } + fmt.Print(string(buffer[:n])) + } + } +} diff --git a/spv/logo.png b/spv/logo.png new file mode 100644 index 0000000..b22c4b8 Binary files /dev/null and b/spv/logo.png differ diff --git a/spv/manifest.json b/spv/manifest.json new file mode 100644 index 0000000..e8499de --- /dev/null +++ b/spv/manifest.json @@ -0,0 +1,113 @@ +{ + "manifestVersion": 1, + "meta": { + "name": "Libdogecoin SPV", + "version": "0.0.3", + "logoPath": "logo.png", + "shortDescription": "Run a libdogecoin SPV node on your dogebox", + "longDescription": "Libdogecoin SPV runs a minimal node on your dogebox", + "upstreamVersions": { + "Libdogecoin": "v0.1.4-dogebox-pre" + } + }, + "config": { + "sections": null + }, + "container": { + "build": { + "nixFile": "pup.nix", + "nixFileSha256": "737d4364ead28d6300f203fccfee002681ee97b5a904474757e108aac46a7341" + }, + "services": [ + { + "name": "spvnode", + "command": { + "exec": "/bin/run.sh", + "cwd": "", + "env": null + } + }, + { + "name": "monitor", + "command": { + "exec": "/bin/monitor", + "cwd": "", + "env": null + } + }, + { + "name": "logger", + "command": { + "exec": "/bin/logger", + "cwd": "", + "env": null + } + } + ], + "exposes": [ + { + "name": "p2p-port", + "type": "tcp", + "port": 22556, + "interfaces": null, + "listenOnHost": true + }, + { + "name": "rest-port", + "type": "http", + "port": 8888, + "interfaces": ["lib-rest"], + "listenOnHost": false + } + ], + "requiresInternet": true + }, + "interfaces": [ + { + "name": "lib-rest", + "version": "0.0.1", + "permissionGroups": [ + { + "name": "REST", + "description": "Allows RESTful access to the Libdogecoin SPV node", + "severity": 2, + "routes": ["/*"], + "port": 0 + } + ] + } + ], + "dependencies": null, + "metrics": [ + { + "name": "chaintip", + "label": "Chain Tip", + "type": "string", + "history": 1 + }, + { + "name": "addresses", + "label": "Addresses", + "type": "string", + "history": 1 + }, + { + "name": "balance", + "label": "Wallet Balance", + "type": "string", + "history": 1 + }, + { + "name": "transaction_count", + "label": "Transaction Count", + "type": "string", + "history": 1 + }, + { + "name": "unspent_count", + "label": "UTXO Count", + "type": "string", + "history": 1 + } + ] +} diff --git a/spv/monitor/monitor.go b/spv/monitor/monitor.go new file mode 100644 index 0000000..cc7b513 --- /dev/null +++ b/spv/monitor/monitor.go @@ -0,0 +1,205 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +type Metrics struct { + Chaintip string `json:"chaintip"` + Balance string `json:"balance"` + Addresses string `json:"addresses"` + TransactionCount string `json:"transaction_count"` + UnspentCount string `json:"unspent_count"` +} + +func fetchEndpoint(endpoint string) (string, error) { + url := fmt.Sprintf("http://0.0.0.0:8888%s", endpoint) + + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + DisableKeepAlives: true, + }, + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Connection", "close") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("error sending request to %s: %w", endpoint, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("unexpected status code %d for %s: %s", resp.StatusCode, endpoint, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body for %s: %w", endpoint, err) + } + + return string(body), nil +} + +func collectMetrics() (Metrics, error) { + var metrics Metrics + + // Fetch chain tip + chaintipStr, err := fetchEndpoint("/getChaintip") + if err != nil { + return metrics, err + } + chaintipLine := strings.TrimSpace(chaintipStr) + if strings.HasPrefix(chaintipLine, "Chain tip: ") { + metrics.Chaintip = strings.TrimPrefix(chaintipLine, "Chain tip: ") + } else { + metrics.Chaintip = chaintipLine // In case the format is different + } + + // Fetch balance + balanceStr, err := fetchEndpoint("/getBalance") + if err != nil { + return metrics, err + } + balanceLine := strings.TrimSpace(balanceStr) + if strings.HasPrefix(balanceLine, "Wallet balance: ") { + metrics.Balance = strings.TrimPrefix(balanceLine, "Wallet balance: ") + } else { + metrics.Balance = balanceLine // In case the format is different + } + + // Fetch addresses + addressesStr, err := fetchEndpoint("/getAddresses") + if err != nil { + return metrics, err + } + var addresses []string + for _, line := range strings.Split(addressesStr, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "address: ") { + address := strings.TrimPrefix(line, "address: ") + addresses = append(addresses, address) + } + } + if len(addresses) > 0 { + metrics.Addresses = strings.Join(addresses, "\n") + } else { + metrics.Addresses = "No addresses found" + } + + // Fetch transaction count + transactionsStr, err := fetchEndpoint("/getTransactions") + if err != nil { + return metrics, err + } + transactionCount := 0 + for _, line := range strings.Split(transactionsStr, "\n") { + if strings.HasPrefix(line, "----------------------") { + transactionCount++ + } + } + metrics.TransactionCount = fmt.Sprintf("%d", transactionCount) + + // Fetch unspent UTXO count + utxosStr, err := fetchEndpoint("/getUTXOs") + if err != nil { + return metrics, err + } + unspentCount := 0 + for _, line := range strings.Split(utxosStr, "\n") { + if strings.HasPrefix(line, "----------------------") { + unspentCount++ + } + } + metrics.UnspentCount = fmt.Sprintf("%d", unspentCount) + + return metrics, nil +} + +func submitMetrics(metrics Metrics) { + client := &http.Client{ + Timeout: 10 * time.Second, + } + + // Create a nested structure for the metrics data + jsonData := map[string]interface{}{ + "chaintip": map[string]interface{}{"value": metrics.Chaintip}, + "balance": map[string]interface{}{"value": metrics.Balance}, + "addresses": map[string]interface{}{"value": metrics.Addresses}, + "transaction_count": map[string]interface{}{"value": metrics.TransactionCount}, + "unspent_count": map[string]interface{}{"value": metrics.UnspentCount}, + } + + // Marshal the data to JSON + marshalledData, err := json.Marshal(jsonData) + if err != nil { + log.Printf("Error marshalling metrics: %v", err) + return + } + + log.Printf("Submitting metrics: %+v", jsonData) + + url := fmt.Sprintf("http://%s:%s/dbx/metrics", os.Getenv("DBX_HOST"), os.Getenv("DBX_PORT")) + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(marshalledData)) + if err != nil { + log.Printf("Error creating request: %v", err) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Connection", "close") + + resp, err := client.Do(req) + if err != nil { + log.Printf("Error sending metrics: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + log.Printf("Unexpected status code when submitting metrics: %d", resp.StatusCode) + log.Printf("Response body: %s", string(body)) + return + } +} + +func main() { + log.Println("Sleeping to give spvnode time to start...") + time.Sleep(10 * time.Second) + + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + metrics, err := collectMetrics() + if err != nil { + log.Printf("Error collecting metrics: %v", err) + continue + } + + log.Printf("Metrics: %+v", metrics) + submitMetrics(metrics) + + log.Printf("----------------------------------------") + } + } +} diff --git a/spv/pup.nix b/spv/pup.nix new file mode 100644 index 0000000..0818b27 --- /dev/null +++ b/spv/pup.nix @@ -0,0 +1,78 @@ +{ pkgs ? import {} }: + +let + storageDirectory = "/storage"; + spvnode_bin = pkgs.callPackage (pkgs.fetchurl { + url = "https://raw.githubusercontent.com/dogeorg/dogebox-nur-packages/6ef13d84145c9e28868a586ddceebbba74bc6e4f/pkgs/libdogecoin/default.nix"; + sha256 = "sha256-cxKICNT/1o5drH/zLNu9v6LYEktKYMjjhU1NfynDp+g="; + }) { + }; + + spvnode = pkgs.writeScriptBin "run.sh" '' + #!${pkgs.stdenv.shell} + + # Generate a mnemonic if one doesn't exist + if [ ! -f "${storageDirectory}/1" ]; then + export MNEMONIC=$(${spvnode_bin}/bin/such -c generate_mnemonic | tee "${storageDirectory}/1") + fi + + # Update the DNS to resolve seed.multidoge.org + resolvectl dns eth0 1.1.1.1 + + # Scan in continuous (-c), block mode (-b) from the latest checkpoint (-p) + # Generate wallet with a mnemonic (-n) if one doesn't exist + # Store wallet in wallet.db (-w) and headers in header.db (-f) + # Connect to initial peer (-i) due to DNS + # Enable http server on port 8888 (-u) for endpoints + ${spvnode_bin}/bin/spvnode \ + -c -b -p -l \ + -n "$MNEMONIC" \ + -w "${storageDirectory}/wallet.db" \ + -f "${storageDirectory}/headers.db" \ + -i "192.7.117.243" \ + -u "0.0.0.0:8888" \ + scan 2>&1 | tee "${storageDirectory}/output.log" + ''; + + monitor = pkgs.buildGoModule { + pname = "monitor"; + version = "0.0.1"; + src = ./monitor; + vendorHash = null; + + systemPackages = [ spvnode_bin ]; + + buildPhase = '' + export GO111MODULE=off + export GOCACHE=$(pwd)/.gocache + go build -ldflags "-X main.pathToSpvnode=${spvnode_bin}" -o monitor monitor.go + ''; + + installPhase = '' + mkdir -p $out/bin + cp monitor $out/bin/ + ''; + }; + + logger = pkgs.buildGoModule { + pname = "logger"; + version = "0.0.1"; + src = ./logger; + vendorHash = null; + + buildPhase = '' + export GO111MODULE=off + export GOCACHE=$(pwd)/.gocache + go build -ldflags "-X main.storageDirectory=${storageDirectory}" -o logger logger.go + ''; + + installPhase = '' + mkdir -p $out/bin + cp logger $out/bin/ + ''; + }; + +in +{ + inherit spvnode monitor logger; +}