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 @@
+
+

+
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;
+}