PQ Doge Wallet
The first Post Quantum Secure Dogecoin wallet
PQ Wallet builds and sends Dogecoin transactions with standard ECDSA authorization and optional Phase-1 post-quantum commitments. Commitment outputs follow canonical tagged OP_RETURN format (
6a24 + FLC1/DIL2/RCG4 + 32-byte commitment). The wallet includes educational verification flows for TX_C/TX_R carrier model and uses sendtx for P2P broadcast (wallet-to-network peers, like Dogecoin Wallet’s bitcoinj path — not JSON-RPC to a local Core node), with SPV and embedded mempool tracking for visibility. spvnode watches every P2PKH address in the wallet; the dashboard can full-rescan or roll back the local header DB and wallet scan state when repair is needed.Latest version
v0.0.43
0.0.62
Review source
Use the tabs to switch between the manifest, Nix build file, and other text sources from the latest tag.
Latest release manifest (for manual review).
{
"manifestVersion": 1,
"meta": {
"name": "PQ Doge Wallet",
"version": "0.0.62",
"logoPath": "logo.png",
"shortDescription": "The first Post Quantum Secure Dogecoin wallet",
"longDescription": "PQ Wallet builds and sends Dogecoin transactions with standard ECDSA authorization and optional Phase-1 post-quantum commitments. Commitment outputs follow canonical tagged OP_RETURN format (`6a24 + FLC1/DIL2/RCG4 + 32-byte commitment`). The wallet includes educational verification flows for TX_C/TX_R carrier model and uses `sendtx` for P2P broadcast (wallet-to-network peers, like Dogecoin Wallet’s bitcoinj path — not JSON-RPC to a local Core node), with SPV and embedded mempool tracking for visibility. `spvnode` watches every P2PKH address in the wallet; the dashboard can full-rescan or roll back the local header DB and wallet scan state when repair is needed.",
"upstreamVersions": {
"Go": "1.24",
"libdogecoin": "4bd9b495f3088081d5c39c91dfd3eea6a53634ab (PQC carrier / FLC1 DIL2 RCG4) + liboqs where applicable"
}
},
"config": {
"sections": [
{
"name": "HTTP",
"description": "Web UI and JSON API bind address is 0.0.0.0 inside the container.",
"fields": [
{
"name": "PUBLIC_PORT",
"label": "HTTP port",
"type": "text",
"default": "33880"
}
]
},
{
"name": "Chain & SPV",
"description": "libdogecoin `spvnode` syncs headers and watches every address in the wallet. Log tail parsing drives chain tip and peer hints in the dashboard.",
"fields": [
{
"name": "SPVNODE_ENABLE",
"label": "Start spvnode after wallet is created (1=yes, 0=no)",
"type": "text",
"default": "1"
}
]
},
{
"name": "Mempool watcher (embedded)",
"description": "Optional tuning for the in-process P2P mempool relay watcher (same semantics as MemeTracker `MTR_*` env). Data lives under `PQ_STORAGE_DIR/mempooltracker/`. Default parallel workers is 1 to limit RAM on small hosts; set `MTR_P2P_PARALLEL` higher if you have headroom.",
"fields": [
{
"name": "MTR_P2P_PORT",
"label": "Dogecoin P2P port (mainnet default 22556)",
"type": "text",
"default": ""
},
{
"name": "MTR_P2P_PARALLEL",
"label": "Parallel peer workers (1–8, empty = default 1)",
"type": "text",
"default": ""
},
{
"name": "MTR_LIST_LIMIT",
"label": "Stored mempool tx rows per address (empty = default)",
"type": "text",
"default": ""
}
]
}
]
},
"container": {
"build": {
"nixFile": "pup.nix",
"nixFileSha256": "984d1f19d603ee20527bd18f6b8ea673d2f8db4244fb53fda7a72bb210e6b547"
},
"services": [
{
"name": "pq-wallet",
"command": {
"exec": "/bin/run.sh",
"cwd": "",
"env": null
}
}
],
"exposes": [
{
"name": "http",
"type": "http",
"port": 33880,
"interfaces": [
"pq-wallet-http"
],
"listenOnHost": true,
"webUI": true
}
],
"requiresInternet": true
},
"interfaces": [
{
"name": "pq-wallet-http",
"version": "0.0.62",
"permissionGroups": [
{
"name": "PQ Wallet HTTP",
"description": "Web UI and wallet API",
"severity": 2,
"routes": [
"/",
"/logo.png",
"/static/*",
"/api/*"
],
"port": 33880
}
]
}
],
"dependencies": [],
"metrics": [
{
"name": "uptime_seconds",
"label": "Uptime",
"type": "int",
"history": 30
}
]
}
Build definition from the latest tag (filename from manifest).
# PQ Doge Wallet — Go HTTP service + libdogecoin (such, sendtx, spvnode) on PATH.
# libdogecoin is built from ./vendors/libdogecoin (see nix/libdogecoin.nix).
# Service name must match manifest container.services[0].name.
{ pkgs ? import <nixpkgs> {} }:
let
libdogecoin = pkgs.callPackage ./nix/libdogecoin.nix {};
pq_bin = pkgs.buildGoModule {
pname = "pq-wallet";
version = "0.0.62";
src = ./service;
vendorHash = null;
go = pkgs.go_1_24;
buildPhase = ''
go build -trimpath -ldflags="-s -w" -o pq-wallet .
'';
installPhase = ''
mkdir -p $out/bin
cp pq-wallet $out/bin/
'';
};
pq-wallet = pkgs.writeShellScriptBin "run.sh" ''
set -e
PUBLIC_PORT="''${PUBLIC_PORT:-33880}"
STORAGE="''${PQ_STORAGE_DIR:-/storage/pq-wallet}"
mkdir -p "$STORAGE"
export PATH="${libdogecoin}/bin:${pkgs.jq}/bin:${pkgs.coreutils}/bin:$PATH"
export PUBLIC_PORT
export PQ_STORAGE_DIR="$STORAGE"
export LIBDOGECOIN_SUCH="''${LIBDOGECOIN_SUCH:-${libdogecoin}/bin/such}"
export LIBDOGECOIN_SENDTX="''${LIBDOGECOIN_SENDTX:-${libdogecoin}/bin/sendtx}"
export LIBDOGECOIN_SPVNODE="''${LIBDOGECOIN_SPVNODE:-${libdogecoin}/bin/spvnode}"
export SPVNODE_ENABLE="''${SPVNODE_ENABLE:-1}"
export SPV_HTTP_ADDR="''${SPV_HTTP_ADDR:-127.0.0.1:8080}"
export MTR_P2P_PORT="''${MTR_P2P_PORT:-}"
export MTR_P2P_PARALLEL="''${MTR_P2P_PARALLEL:-}"
export MTR_LIST_LIMIT="''${MTR_LIST_LIMIT:-}"
SPV_USER_START=1
if [ -f "$STORAGE/service_prefs.json" ]; then
sv=$(jq -r '.spv_enabled // true' "$STORAGE/service_prefs.json" 2>/dev/null || echo true)
if [ "$sv" = "false" ] || [ "$sv" = "0" ]; then SPV_USER_START=0; fi
fi
if [ "$SPVNODE_ENABLE" = "1" ] && [ "$SPV_USER_START" = "1" ] && [ -f "$STORAGE/wallet.json" ]; then
if [ ! -f "$STORAGE/spv.pid" ] || ! kill -0 "$(cat "$STORAGE/spv.pid" 2>/dev/null)" 2>/dev/null; then
rm -f "$STORAGE/spv.pid"
NET=$(jq -r '.network // "mainnet"' "$STORAGE/wallet.json")
mapfile -t SPV_ADDRS < <(jq -r '[ (.p2pkh_address // empty), (.addresses[]? | .p2pkh_address // empty) ] | map(select(type=="string" and length>0)) | unique | .[]' "$STORAGE/wallet.json")
if [ "''${#SPV_ADDRS[@]}" -gt 0 ]; then
TN_FLAG=""
if [ "$NET" = "testnet" ]; then TN_FLAG="-t"; fi
CP_FLAG="-p"
if [ -f "$STORAGE/spv_sync_prefs.json" ]; then
v=$(jq -r '.use_checkpoint // true' "$STORAGE/spv_sync_prefs.json" 2>/dev/null || echo true)
if [ "$v" = "false" ] || [ "$v" = "0" ]; then CP_FLAG=""; fi
fi
# One -a with space-separated addrs (spvnode getopt overwrites repeated -a). -g = BIP37 filtered
# merkleblocks + txs after headers so PQ scans see wallet-related tx bytes during rescan.
SPV_ADDR_JOINED="''${SPV_ADDRS[*]}"
printf '\n--- spvnode (pup entrypoint) starting pid pending ---\n' >>"$STORAGE/spv.log"
if command -v stdbuf >/dev/null 2>&1; then
nohup stdbuf -oL -eL spvnode $TN_FLAG $CP_FLAG -u "$SPV_HTTP_ADDR" -c -l -g -a "$SPV_ADDR_JOINED" -w "$STORAGE/spv_wallet.db" -h "$STORAGE/headers.db" -b scan >>"$STORAGE/spv.log" 2>&1 &
else
nohup spvnode $TN_FLAG $CP_FLAG -u "$SPV_HTTP_ADDR" -c -l -g -a "$SPV_ADDR_JOINED" -w "$STORAGE/spv_wallet.db" -h "$STORAGE/headers.db" -b scan >>"$STORAGE/spv.log" 2>&1 &
fi
echo $! >"$STORAGE/spv.pid"
fi
fi
fi
exec ${pq_bin}/bin/pq-wallet
'';
in
{
pq-wallet = pq-wallet;
}
#!/usr/bin/env bash
# Legacy: PQ keygen is built-in via `such -c falcon_keygen` (see pq-wallet API / wallet create with pq_keys).
# This script matches the old JSON contract if you still want a helper:
set -euo pipefail
SUCH="${LIBDOGECOIN_SUCH:-such}"
OUT=$("$SUCH" -c falcon_keygen 2>&1) || true
PUB=$(echo "$OUT" | grep -i 'public key:' | head -1 | sed 's/.*public key:[[:space:]]*//')
SEC=$(echo "$OUT" | grep -i 'secret key:' | head -1 | sed 's/.*secret key:[[:space:]]*//')
if [[ -n "$PUB" && -n "$SEC" ]]; then
printf '{"ok":true,"pq_public_key_hex":"%s","pq_private_key_hex":"%s"}\n' "$PUB" "$SEC"
else
echo '{"ok":false,"error":"parse falcon_keygen output"}'
exit 1
fi
# libdogecoin changes: spent-UTXO REST hints (Dogecoin Wallet–style)
This document describes **every change** made under PQ Wallet’s vendored libdogecoin (`pq-wallet/vendors/libdogecoin`) so you can **reproduce or port** the same edits onto the [official libdogecoin](https://github.com/dogecoinfoundation/libdogecoin) tree (or any fork).
## Goal
`/getTransactions` lists **spent** wallet UTXOs keyed by the **funding** txid (`txid` / `vout` / wallet `address`). That is not how **Dogecoin Wallet** shows **sends**: the UI wants the **spending transaction** id, the **payee address**, and the **payment line amount** (first external P2PKH output on that spend, same idea as bitcoinj’s “to address of sent”).
These changes add **optional** `key: value` lines to each spent-UTXO block so REST consumers (e.g. PQ Wallet) can build an OUT row for the **spend** without guessing from raw hex alone.
## Files touched (canonical list)
| File | Change |
|------|--------|
| `include/dogecoin/wallet.h` | Declare `dogecoin_wallet_sent_payment_hints_for_prevout(...)`. |
| `src/wallet.c` | Implement prevout → spending `wtx` lookup; spend txid hex; first non-mine P2PKH `pay_to` / `pay_amount`; optional height & confirmations. |
| `src/rest.c` | In `GET /getTransactions`, after each spent UTXO’s `solvable:` line, call the helper and print optional REST lines. |
| `doc/rest.md` | Document the new fields and extend the sample response. |
No CMake / new source files: `wallet.c` and `rest.c` are already in the build.
---
## 1. `include/dogecoin/wallet.h`
**Where:** Immediately after the declaration of `dogecoin_wallet_txout_is_mine` (and before `dogecoin_wallet_is_spent` or the next API block—match your tree’s ordering).
**Add:** the block comment + prototype:
```c
/**
* For a spent prevout (UTXO txid + vout), find the wallet tx that spends it and derive Dogecoin-Wallet-style
* payment metadata: spend txid (display hex), first non-wallet P2PKH recipient, and that output's amount.
* Any of pay_to / pay_amount buffers may be NULL if the caller does not need them.
* opt_spend_height / opt_spend_confirmations may be NULL.
*/
LIBDOGECOIN_API dogecoin_bool dogecoin_wallet_sent_payment_hints_for_prevout(dogecoin_wallet* wallet, const uint256_t prev_txid, uint32_t prev_vout, char spend_txid_hex65[65], char pay_to[P2PKHLEN], char pay_amount[KOINU_STRINGLEN], int* opt_spend_height, int* opt_spend_confirmations);
```
**Semantics:**
- `prev_txid` / `prev_vout`: same interpretation as `dogecoin_utxo` in the wallet (the **funding** outpoint that was later spent).
- `spend_txid_hex65`: 64 hex chars + NUL; same display style as other REST txids (`utils_uint8_to_hex` on `dogecoin_tx_hash` of the spending tx).
- `pay_to` / `pay_amount`: first **non–wallet-mine** output that decodes to P2PKH via `dogecoin_pubkey_hash_to_p2pkh_address`; amount via `koinu_to_coins_str`.
- Returns `true` if a spending transaction was found in `wallet->vec_wtxes` (even if no external P2PKH was found—then `pay_*` may stay empty).
- `opt_spend_height`: `wtx->height` of the spending tx.
- `opt_spend_confirmations`: `bestblockheight - height + 1` when both are set and positive.
---
## 2. `src/wallet.c`
**Where:** Implement the function **after** `dogecoin_wallet_txout_is_mine` and **before** `dogecoin_wallet_is_mine` (anchor names; line numbers differ per upstream revision).
**Algorithm (must stay consistent with wallet prevout handling):**
1. Require `wallet`, `spend_txid_hex65`, and `wallet->vec_wtxes`.
2. For each `dogecoin_wtx` in `vec_wtxes` (skip `ignore`, missing `tx` / `vin`).
3. For each input, resolve prevout bytes the **same way** as `dogecoin_wallet_scrape_utxos`: `utils_uint8_to_hex` on `tx_in->prevout.hash`, `utils_reverse_hex` on the 64-char buffer, then `utils_hex_to_uint8` into 32 bytes; compare to `prev_txid` and `tx_in->prevout.n` to `prev_vout`.
4. On match: `dogecoin_tx_hash(wtx->tx, spendh)` → copy hex into `spend_txid_hex65`.
5. Scan `vout` in order; skip `dogecoin_wallet_txout_is_mine`; for the first output where `dogecoin_pubkey_hash_to_p2pkh_address` succeeds and address non-empty, fill `pay_to` / `pay_amount` and break.
6. Fill optional height / confirmations; return `true`.
7. If no spend found, return `false`.
**Reference implementation:** copy the function `dogecoin_wallet_sent_payment_hints_for_prevout` verbatim from:
`silly-pups/pq-wallet/vendors/libdogecoin/src/wallet.c`
(search for that symbol). It is self-contained and uses only existing wallet / tx / utils APIs.
**Caveats for upstream review:**
- **P2PKH only** for `pay_to`: sends to P2SH / non-standard scripts will not get `pay_to` / `pay_amount` (spend_txid still appears).
- **“First external P2PKH”** matches a simple Dogecoin Wallet / bitcoinj-style heuristic, not full payment decomposition for complex txs.
- Depends on **`vec_wtxes`** containing the spending tx (same assumption as the rest of the wallet).
---
## 3. `src/rest.c`
**Where:** In `dogecoin_http_request_cb`, branch `strcmp(path, "/getTransactions") == 0`, inside `HASH_ITER` over `wallet->utxos` where `!utxo->spendable` (spent UTXO block).
**After** the line that prints `solvable:` (and **before** `wallet_total_u64 += coins_to_koinu_str(utxo->amount);`), insert a block that:
1. Allocates stack buffers: `char spend_hex[65]`, `char pay_to[P2PKHLEN]`, `char pay_amt[KOINU_STRINGLEN]`, `int sh = 0, sc = 0`.
2. Zeroes them with `dogecoin_mem_zero`.
3. Calls
`dogecoin_wallet_sent_payment_hints_for_prevout(wallet, utxo->txid, (uint32_t)utxo->vout, spend_hex, pay_to, pay_amt, &sh, &sc)`.
4. If it returns true, print only non-empty / positive fields:
| REST key | C condition | Notes |
|----------|-------------|--------|
| `spend_txid` | `spend_hex[0]` | Same hex style as `txid:` line |
| `pay_to` | `pay_to[0]` | Base58 P2PKH |
| `pay_amount` | `pay_amt[0]` | Coin string |
| `spend_height` | `sh > 0` | Spending tx block height |
| `spend_confirmations` | `sc > 0` | From `bestblockheight` |
**Exact labels in the reference tree** (PQ vendor; keep identical if you want PQ’s Go parser to match without changes):
- `spend_txid: %s\n`
- `pay_to: %s\n`
- `pay_amount: %s\n`
- `spend_height: %d\n`
- `spend_confirmations: %d\n`
`rest.c` already includes `dogecoin/wallet.h`; no new includes were required in the PQ vendor tree.
---
## 4. `doc/rest.md`
Under **GET /getTransactions**:
- Extend the response body template with the optional `spend_txid`, `pay_to`, `pay_amount`, `spend_height`, `spend_confirmations` lines.
- Add a short sentence that these mirror Dogecoin Wallet–style metadata for the **spending** transaction.
- Extend the **sample response** with a concrete example block showing those lines.
Copy from:
`silly-pups/pq-wallet/vendors/libdogecoin/doc/rest.md`
---
## How to apply on the official repo
1. **Clone** [dogecoinfoundation/libdogecoin](https://github.com/dogecoinfoundation/libdogecoin) and create a branch, e.g. `feature/rest-spent-utxo-payment-hints`.
2. **Apply edits** using either:
- **Manual:** follow sections 1–4 above and use the vendor files as a side-by-side reference, or
- **Diff:** from a machine that has both trees, diff only those four paths between upstream and
`silly-pups/pq-wallet/vendors/libdogecoin/…`
then `git apply` or cherry-pick hunks (resolve conflicts if upstream moved `rest.c` / `wallet.c`).
3. **Build** your usual targets (`spvnode`, tools) and run SPV with a wallet that has spent P2PKH sends.
4. **Smoke test:**
```bash
curl -sS "http://127.0.0.1:<REST_PORT>/getTransactions" | head -n 80
```
Each spent UTXO block should optionally show `spend_txid` / `pay_to` / `pay_amount` when the wallet has the spending wtx and a decodable external P2PKH output.
5. **Upstream PR:** describe motivation (REST consumers / wallet UX parity with Dogecoin Wallet), document backward compatibility (all new lines are optional), and note limitations (P2PKH-first heuristic, `vec_wtxes` dependency).
---
## PQ Wallet (silly-pups) consumer
PQ Wallet parses these keys in `pq-wallet/service/spv_rest_sync.go` and merges a separate **OUT** `TxRecord` keyed by `spend_txid`. If you change key names or formats in official libdogecoin, update that parser (and `doc/rest.md` here) to stay aligned.
---
## Single reference copy in this repo
Canonical patched sources live at:
- `pq-wallet/vendors/libdogecoin/include/dogecoin/wallet.h`
- `pq-wallet/vendors/libdogecoin/src/wallet.c`
- `pq-wallet/vendors/libdogecoin/src/rest.c`
- `pq-wallet/vendors/libdogecoin/doc/rest.md`
Use those files as the **source of truth** when porting to another clone.
# libdogecoin with liboqs (Falcon-512 / Dilithium2 / Raccoon-G carrier) + such, sendtx, spvnode CLI tools.
# Source: pq-wallet/vendors/libdogecoin at dogecoinfoundation/libdogecoin PR #294 head (4bd9b49).
# Nix applies ./libdogecoin-vendor-patches.patch (liboqs pkg-config, libevent hints, link fixes, PQ wallet log hooks).
{ lib, stdenv, cmake, pkg-config, gmp, liboqs, openssl, ninja }:
stdenv.mkDerivation rec {
pname = "libdogecoin-with-oqs";
version = "vendor";
src = ../vendors/libdogecoin;
patches = [
./libdogecoin-vendor-patches.patch
];
nativeBuildInputs = [ cmake pkg-config ninja ];
buildInputs = [ gmp liboqs openssl ];
strictDeps = true;
cmakeFlags = [
"-GNinja"
"-DCMAKE_BUILD_TYPE=Release"
"-DBUILD_TESTING=OFF"
"-DUSE_LIBOQS=ON"
"-DUSE_TPM2=OFF"
"-DWITH_BENCH=OFF"
];
buildPhase = ''
runHook preBuild
cmake --build .
runHook postBuild
'';
installPhase = ''
runHook preInstall
cmake --install . --prefix "$out"
runHook postInstall
'';
meta = with lib; {
description = "libdogecoin with liboqs-enabled such/sendtx/spvnode";
license = licenses.mit;
};
}
package main
import (
"encoding/hex"
"fmt"
"strings"
)
const (
dogeMainnetP2PKHVersion byte = 0x1e // 30
dogeTestnetP2PKHVersion byte = 0x71 // 113
)
// dogeP2PKHScriptFromAddress decodes a Dogecoin P2PKH address to scriptPubKey bytes (legacy P2PKH).
func dogeP2PKHScriptFromAddress(addr string, testnet bool) ([]byte, error) {
addr = strings.TrimSpace(addr)
if addr == "" {
return nil, fmt.Errorf("empty address")
}
payload, version, err := base58CheckDecode(addr)
if err != nil {
return nil, fmt.Errorf("decode address: %w", err)
}
if len(payload) != 20 {
return nil, fmt.Errorf("expected 20-byte pubkey hash")
}
wantVer := dogeMainnetP2PKHVersion
if testnet {
wantVer = dogeTestnetP2PKHVersion
}
if version != wantVer {
return nil, fmt.Errorf("unexpected address version %d (want %d)", version, wantVer)
}
out := make([]byte, 0, 25)
// Standard P2PKH script: OP_DUP OP_HASH160 PUSH20 <pubKeyHash> OP_EQUALVERIFY OP_CHECKSIG
out = append(out, 0x76, 0xa9, 0x14)
out = append(out, payload...)
out = append(out, 0x88, 0xac)
return out, nil
}
func scriptBytesToHex(b []byte) string {
return hex.EncodeToString(b)
}
package main
import (
"crypto/sha256"
"errors"
"math/big"
)
// Dogecoin P2PKH address decoding uses Bitcoin-style base58check (no btcsuite dependency).
var (
errBase58Checksum = errors.New("base58check: checksum mismatch")
errBase58Format = errors.New("base58check: invalid format")
errBase58Char = errors.New("base58check: invalid character")
)
const alphabetIdx0 = '1'
var b58 = [256]byte{
255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255,
255, 255, 255, 255, 255, 255, 255, 255,
255, 0, 1, 2, 3, 4, 5, 6,
7, 8, 255, 255, 255, 255, 255, 255,
255, 9, 10, 11, 12, 13, 14, 15,
16, 255, 17, 18, 19, 20, 21, 255,
22, 23, 24, 25, 26, 27, 28, 29,
30, 31, 32, 255, 255, 255, 255, 255,
255, 33, 34, 35, 36, 37, 38, 39,
40, 41, 42, 43, 255, 44, 45, 46,
47, 48, 49, 50, 51, 52, 53, 54,
55, 56, 57, 255, 255, 255, 255, 255,
}
var bigRadix = [...]*big.Int{
big.NewInt(0),
big.NewInt(58),
big.NewInt(58 * 58),
big.NewInt(58 * 58 * 58),
big.NewInt(58 * 58 * 58 * 58),
big.NewInt(58 * 58 * 58 * 58 * 58),
big.NewInt(58 * 58 * 58 * 58 * 58 * 58),
big.NewInt(58 * 58 * 58 * 58 * 58 * 58 * 58),
big.NewInt(58 * 58 * 58 * 58 * 58 * 58 * 58 * 58),
big.NewInt(58 * 58 * 58 * 58 * 58 * 58 * 58 * 58 * 58),
big.NewInt(58 * 58 * 58 * 58 * 58 * 58 * 58 * 58 * 58 * 58),
}
func base58Decode(b string) ([]byte, error) {
answer := big.NewInt(0)
scratch := new(big.Int)
for t := b; len(t) > 0; {
n := len(t)
if n > 10 {
n = 10
}
var total uint64
for _, v := range t[:n] {
if v > 255 {
return nil, errBase58Char
}
tmp := b58[v]
if tmp == 255 {
return nil, errBase58Char
}
total = total*58 + uint64(tmp)
}
answer.Mul(answer, bigRadix[n])
scratch.SetUint64(total)
answer.Add(answer, scratch)
t = t[n:]
}
tmpval := answer.Bytes()
var numZeros int
for numZeros = 0; numZeros < len(b); numZeros++ {
if b[numZeros] != alphabetIdx0 {
break
}
}
flen := numZeros + len(tmpval)
val := make([]byte, flen)
copy(val[numZeros:], tmpval)
return val, nil
}
func base58Checksum(versionAndPayload []byte) [4]byte {
h := sha256.Sum256(versionAndPayload)
h2 := sha256.Sum256(h[:])
var c [4]byte
copy(c[:], h2[:4])
return c
}
// base58CheckDecode verifies base58check and returns the 20-byte pubkey hash and address version byte.
func base58CheckDecode(input string) (payload []byte, version byte, err error) {
decoded, err := base58Decode(input)
if err != nil {
return nil, 0, err
}
if len(decoded) < 5 {
return nil, 0, errBase58Format
}
version = decoded[0]
var cksum [4]byte
copy(cksum[:], decoded[len(decoded)-4:])
if base58Checksum(decoded[:len(decoded)-4]) != cksum {
return nil, 0, errBase58Checksum
}
payload = append(payload, decoded[1:len(decoded)-4]...)
return payload, version, nil
}
package main
import (
"encoding/hex"
"regexp"
"sort"
"strconv"
"strings"
)
var (
// broadcast.log lines are prefixed with RFC3339 time + space (see appendBroadcastLogLine).
reBroadcastLogTimePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T\S+\s+`)
// logBroadcastDetails uses arbitrary tags: tx_broadcast, send_pq_safe, send_pq_safe_txr, …
reBroadcastTxLine = regexp.MustCompile(`(?i)^\S+\s+txid=([0-9a-f]{64})\s+signed_hex_len=(\d+)`)
reBroadcastHexChunk = regexp.MustCompile(`(?i)^\S+\s+SIGNED_RAW_HEX\s+off=(\d+)\s+len=(\d+)\s+([0-9a-f]+)\s*$`)
reBroadcastLineTxid = regexp.MustCompile(`(?i)^\S+\s+txid=([0-9a-f]{64})\b`)
reBroadcastPaymentHintLine = regexp.MustCompile(`(?i)^\S+\s+PAYMENT_HINT\s+txid=([0-9a-f]{64})\s+to=([A-Za-z0-9]{26,64})\s+amount_doge=([0-9]+(?:\.[0-9]+)?)\s*$`)
reBroadcastSpentPrevoutHintLine = regexp.MustCompile(`(?i)^\S+\s+SPENT_PREVOUT_HINT\s+prev_txid=([0-9a-f]{64})\s+prev_vout=(\d+)\s+spend_txid=([0-9a-f]{64})\s+to=([A-Za-z0-9]{26,64})\s+amount_doge=([0-9]+(?:\.[0-9]+)?)\s*$`)
// sendtx sometimes logs: "start broadcasting transaction: <64hex>"
reSendtxBroadcastStartLine = regexp.MustCompile(`(?i)start\s+broadcasting\s+transaction:\s*([0-9a-f]{64})\b`)
)
func stripBroadcastLogTimePrefix(line string) string {
line = strings.TrimSpace(line)
return strings.TrimSpace(reBroadcastLogTimePrefix.ReplaceAllString(line, ""))
}
// extractBroadcastOutTxidsFromTail returns txids from broadcast.log (any source tag + sendtx hints), first-seen order.
func extractBroadcastOutTxidsFromTail(tail string) []string {
var out []string
seen := map[string]struct{}{}
add := func(id string) {
id = normalizeTxid(id)
if id == "" {
return
}
if _, ok := seen[id]; ok {
return
}
seen[id] = struct{}{}
out = append(out, id)
}
for _, line := range strings.Split(strings.ReplaceAll(tail, "\r\n", "\n"), "\n") {
pl := stripBroadcastLogTimePrefix(strings.TrimSpace(line))
if pl == "" {
continue
}
if strings.Contains(strings.ToUpper(pl), "SIGNED_RAW_HEX") {
continue
}
if m := reSendtxBroadcastStartLine.FindStringSubmatch(pl); len(m) >= 2 {
add(m[1])
}
if m := reBroadcastLineTxid.FindStringSubmatch(pl); len(m) >= 2 {
add(m[1])
}
}
return out
}
type broadcastHexChunk struct {
off int
hex string
}
type broadcastPaymentHint struct {
ToAddress string
AmountDOGE float64
}
// broadcastSpentPrevoutHint links /getTransactions "spent" rows (keyed by funding txid) to the real spend txid.
type broadcastSpentPrevoutHint struct {
PrevTxid string
SpendTxid string
ToAddress string
AmountDOGE float64
}
func extractBroadcastPaymentHintsFromTail(tail string) map[string]broadcastPaymentHint {
out := map[string]broadcastPaymentHint{}
for _, line := range strings.Split(strings.ReplaceAll(tail, "\r\n", "\n"), "\n") {
pl := stripBroadcastLogTimePrefix(strings.TrimSpace(line))
if pl == "" {
continue
}
m := reBroadcastPaymentHintLine.FindStringSubmatch(pl)
if len(m) < 4 {
continue
}
txid := normalizeTxid(m[1])
to := strings.TrimSpace(m[2])
amt, _ := strconv.ParseFloat(strings.TrimSpace(m[3]), 64)
if txid == "" || to == "" || amt <= 0 {
continue
}
out[txid] = broadcastPaymentHint{ToAddress: to, AmountDOGE: amt}
}
return out
}
func extractBroadcastSpentPrevoutHintsFromTail(tail string) []broadcastSpentPrevoutHint {
var out []broadcastSpentPrevoutHint
for _, line := range strings.Split(strings.ReplaceAll(tail, "\r\n", "\n"), "\n") {
pl := stripBroadcastLogTimePrefix(strings.TrimSpace(line))
if pl == "" {
continue
}
m := reBroadcastSpentPrevoutHintLine.FindStringSubmatch(pl)
if len(m) < 6 {
continue
}
prev := normalizeTxid(m[1])
spend := normalizeTxid(m[3])
to := strings.TrimSpace(m[4])
amt, _ := strconv.ParseFloat(strings.TrimSpace(m[5]), 64)
if prev == "" || spend == "" || to == "" || amt <= 0 {
continue
}
out = append(out, broadcastSpentPrevoutHint{
PrevTxid: prev,
SpendTxid: spend,
ToAddress: to,
AmountDOGE: amt,
})
}
return out
}
// applyBroadcastSpentPrevoutRewrites rewrites SPV /getTransactions rows that use the funding txid as the row key
// into the actual broadcast spend txid with pay-to metadata (matches PAYMENT_HINT / local send row).
func applyBroadcastSpentPrevoutRewrites(st *WalletState, tail string) bool {
if st == nil {
return false
}
hints := extractBroadcastSpentPrevoutHintsFromTail(tail)
if len(hints) == 0 {
return false
}
prevMeta := make(map[string]broadcastSpentPrevoutHint, len(hints))
for _, h := range hints {
prevMeta[h.PrevTxid] = h
}
changed := false
for i := range st.Transactions {
id := normalizeTxid(st.Transactions[i].Txid)
h, ok := prevMeta[id]
if !ok {
continue
}
st.Transactions[i].Txid = h.SpendTxid
st.Transactions[i].Direction = "out"
st.Transactions[i].Address = h.ToAddress
st.Transactions[i].AmountDOGE = h.AmountDOGE
st.Transactions[i].Source = "manual"
changed = true
}
if !changed {
return false
}
st.Transactions = mergeTxRecords(nil, st.Transactions)
return true
}
// mergeRawHexFromBroadcastLog reassembles SIGNED_RAW_HEX chunks from broadcast.log into TxRecord.RawHex
// so enrichSPVTxFromRawHex can classify OUT spends and counterparty amounts.
func (s *Server) mergeRawHexFromBroadcastLog(st *WalletState) bool {
if st == nil {
return false
}
tail, err := readFileTail(s.broadcastLogPath(), 4<<20)
if err != nil || strings.TrimSpace(tail) == "" {
return false
}
type acc struct {
wantLen int
chunks []broadcastHexChunk
}
curTx := ""
blocks := map[string]*acc{}
flushBlock := func() {
curTx = ""
}
lines := strings.Split(strings.ReplaceAll(tail, "\r\n", "\n"), "\n")
for _, line := range lines {
pl := stripBroadcastLogTimePrefix(line)
if pl == "" {
continue
}
if m := reBroadcastTxLine.FindStringSubmatch(pl); len(m) >= 4 {
want, _ := strconv.Atoi(strings.TrimSpace(m[3]))
id := normalizeTxid(m[2])
if id == "" || want <= 0 {
flushBlock()
continue
}
curTx = id
blocks[id] = &acc{wantLen: want, chunks: nil}
continue
}
if curTx == "" {
continue
}
if m := reBroadcastHexChunk.FindStringSubmatch(pl); len(m) >= 5 {
off, _ := strconv.Atoi(m[2])
hexPart := strings.TrimSpace(strings.ToLower(m[4]))
if off < 0 || hexPart == "" {
continue
}
b := blocks[curTx]
if b == nil {
continue
}
b.chunks = append(b.chunks, broadcastHexChunk{off: off, hex: hexPart})
}
}
changed := false
for txid, b := range blocks {
if b == nil || b.wantLen <= 0 || len(b.chunks) == 0 {
continue
}
// wantLen is len(hex) ASCII chars (see logBroadcastDetails).
full, ok := assembleBroadcastHexChunks(b.chunks, b.wantLen)
if !ok || full == "" {
continue
}
if !looksLikeDogecoinRawTxHex(full) {
continue
}
for i := range st.Transactions {
if normalizeTxid(st.Transactions[i].Txid) != txid {
continue
}
if strings.TrimSpace(st.Transactions[i].RawHex) == "" {
st.Transactions[i].RawHex = full
changed = true
}
break
}
}
return changed
}
// assembleBroadcastHexChunks joins SIGNED_RAW_HEX chunks. off/len are byte offsets into the ASCII hex string
// (same as logBroadcastDetails); wantHexChars is signed_hex_len (= len(hex) in the broadcaster).
func assembleBroadcastHexChunks(chunks []broadcastHexChunk, wantHexChars int) (string, bool) {
if wantHexChars <= 0 || wantHexChars%2 != 0 {
return "", false
}
sort.Slice(chunks, func(i, j int) bool { return chunks[i].off < chunks[j].off })
var b strings.Builder
nextOff := 0
totalChars := 0
for _, c := range chunks {
if c.off != nextOff {
return "", false
}
if len(c.hex)%2 != 0 {
return "", false
}
if _, err := hex.DecodeString(c.hex); err != nil {
return "", false
}
b.WriteString(strings.ToLower(c.hex))
n := len(c.hex)
nextOff += n
totalChars += n
if totalChars > wantHexChars {
return "", false
}
}
if totalChars != wantHexChars {
return "", false
}
return b.String(), true
}
func looksLikeDogecoinRawTxHex(s string) bool {
s = strings.TrimSpace(strings.ToLower(s))
if len(s) < 20 || len(s)%2 != 0 {
return false
}
for _, c := range s {
if c >= '0' && c <= '9' || c >= 'a' && c <= 'f' {
continue
}
return false
}
return true
}
package main
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// txListRow is one row for /api/transactions (SPV + MemeTracker).
type txListRow struct {
TxRecord
Pending bool `json:"pending"`
}
// shouldRunSuchMerge throttles expensive such list_unspent merges.
// Frequent dashboard/transactions polls should not repeatedly spawn such.
func (s *Server) shouldRunSuchMerge(minInterval time.Duration) bool {
if minInterval <= 0 {
minInterval = 20 * time.Second
}
s.suchMergeMu.Lock()
defer s.suchMergeMu.Unlock()
if time.Since(s.lastSuchMerge) < minInterval {
return false
}
s.lastSuchMerge = time.Now()
return true
}
func (s *Server) latestSuchSpendable(maxAge time.Duration) (float64, bool) {
if maxAge <= 0 {
maxAge = 3 * time.Minute
}
s.suchMergeMu.Lock()
defer s.suchMergeMu.Unlock()
if s.lastSuchSpendableAt.IsZero() {
return 0, false
}
if time.Since(s.lastSuchSpendableAt) > maxAge {
return 0, false
}
return s.lastSuchSpendableDOGE, true
}
// computeSuchSpendableDOGE sums spendable UTXOs per address via fetchUTXOsFromExplorer
// (SPV REST /getUTXOs, then such list_unspent).
func (s *Server) computeSuchSpendableDOGE(wf *WalletFile) (float64, bool) {
if wf == nil {
return 0, false
}
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
sumKoinu := int64(0)
seen := map[string]struct{}{}
successfulReads := 0
for _, addr := range wf.AllDistinctP2PKHAddresses() {
addr = strings.TrimSpace(addr)
if addr == "" {
continue
}
utxos, err := s.fetchUTXOsFromExplorer(ctx, addr)
if err != nil {
continue
}
successfulReads++
for _, u := range utxos {
txid := normalizeTxid(u.TxID)
if txid == "" || u.Value <= 0 {
continue
}
k := txid + ":" + strconv.FormatUint(uint64(u.Vout), 10)
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
sumKoinu += u.Value
}
}
if successfulReads == 0 {
return 0, false
}
sum := round2(float64(sumKoinu) / 1e8)
s.suchMergeMu.Lock()
s.lastSuchSpendableDOGE = sum
s.lastSuchSpendableAt = time.Now()
s.suchMergeMu.Unlock()
return sum, true
}
// handleDashboard returns balances, SPV header parse, metrics tail, and merged tx summary.
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
s.mu.Lock()
wf, err := s.loadWallet()
if err != nil {
if errors.Is(err, ErrWalletLocked) {
s.startSPVNodeFromWatchState()
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"wallet": nil, "dashboard": nil, "locked": true, "sealed": true})
return
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"wallet": nil, "dashboard": nil})
return
}
if wf == nil {
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"wallet": nil, "dashboard": nil})
return
}
svcPrefs := s.readServicePrefs()
s.startSPVNode(wf)
s.mu.Unlock()
// SPV REST + merges can take many seconds; never hold s.mu across that work or other tabs
// (and PQ send) will block on the same mutex and appear frozen.
spv := s.readSPVStatus()
logTail, _ := spv["log_tail"].(string)
hdr := parseSPVLogHeaderInfo(logTail)
applySPVStatusHeaderInfo(&hdr, spv)
tipHeight := spvHeaderHeightFromMap(spv)
tipUnix := spvHeaderUnixFromMap(spv)
var st *WalletState
var pendingMeme float64
var memeErr error
var mtrCount, mtrConn int
var mtrLive, mtrWorkers []map[string]any
s.stateMergeMu.Lock()
st, err = s.loadState()
if err != nil {
s.stateMergeMu.Unlock()
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
seenChanged := false
rawChanged := false
confirmChanged := false
proofMetaChanged := false
if logTail != "" {
seenChanged = s.applySPVSeenTxids(st, logTail)
rawChanged = s.applySPVRawHex(st, logTail)
confirmChanged = s.applySPVConfirmations(st, logTail)
proofMetaChanged = s.applySPVProofMeta(st, logTail)
}
restChanged := false
if s.shouldIngestSPVRESTTxHints() {
restChanged = s.mergeTransactionsFromSPVREST(st, tipHeight, tipUnix)
}
bcChanged := s.mergeTransactionsFromBroadcastLog(st)
bcRawChanged := s.mergeRawHexFromBroadcastLog(st)
suchChanged := false
if s.shouldRunSuchMerge(20 * time.Second) {
suchChanged = s.mergeTransactionsFromSuchListUnspent(wf, st)
}
enrichedChanged := s.enrichSPVTxFromRawHex(st, wf)
if seenChanged || rawChanged || confirmChanged || proofMetaChanged || enrichedChanged || restChanged || bcChanged || bcRawChanged || suchChanged {
_ = s.saveState(st)
}
var inSum, outSum float64
for _, t := range st.Transactions {
if strings.EqualFold(t.Direction, "in") {
inSum += t.AmountDOGE
}
if strings.EqualFold(t.Direction, "out") {
outSum += t.AmountDOGE
}
}
spendable := math.Max(0, inSum-outSum)
// Spendable from summed UTXOs (such / REST via fetchUTXOsFromExplorer), not /getBalance alone:
// during header sync REST totals can lag or disagree with the wallet UI ledger.
if utxoSpendable, ok := s.computeSuchSpendableDOGE(wf); ok {
spendable = math.Max(0, utxoSpendable)
} else if utxoSpendable, ok := s.latestSuchSpendable(10 * time.Minute); ok {
spendable = math.Max(0, utxoSpendable)
} else if restSpend, ok := s.computeSPVRESTSpendableDOGE(); ok {
spendable = math.Max(0, restSpend)
}
eng, engErr := s.ensureMempoolEngine(wf)
if eng != nil && engErr == nil {
mtrCount, mtrLive, mtrWorkers, mtrConn = eng.DashboardSnapshot()
}
if eng != nil && engErr == nil {
if s.persistMemeTrackerTxs(st, mtrLive, wf) || s.enrichSPVTxFromRawHex(st, wf) {
_ = s.saveState(st)
}
}
s.stateMergeMu.Unlock()
// Run mempool pending estimate outside stateMerge lock so dashboard and tx list calls
// do not block each other for long periods during refresh/poll bursts.
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
pendingMeme, memeErr = s.syncMemeTracker(ctx, wf, st)
cancel()
s.mu.Lock()
if changed, err := s.maybeRotateHDReceiveAddress(wf, st); err == nil && changed {
_ = s.saveWallet(wf)
s.startSPVNode(wf)
}
wf, _ = s.loadWallet()
s.mu.Unlock()
if hdr.HeaderHeight == 0 && len(st.Metrics) > 0 {
for i := len(st.Metrics) - 1; i >= 0; i-- {
if st.Metrics[i].HeaderHeight > 0 {
hdr.HeaderHeight = st.Metrics[i].HeaderHeight
if hdr.BestBlockHash == "" && st.Metrics[i].BestBlockHash != "" {
hdr.BestBlockHash = st.Metrics[i].BestBlockHash
}
break
}
}
}
running, _ := spv["running"].(bool)
memeErrStr := ""
if memeErr != nil {
memeErrStr = memeErr.Error()
}
mempoolDisplay := hdr.MempoolTxCount
if eng != nil && engErr == nil {
mempoolDisplay = mtrCount
}
mtrEngErr := ""
if engErr != nil {
mtrEngErr = engErr.Error()
}
mtrP2PActive := eng != nil && engErr == nil && (mtrConn > 0 || mtrCount > 0)
writeJSON(w, http.StatusOK, map[string]any{
"wallet": wf,
"dashboard": map[string]any{
"services": map[string]any{
"spv_enabled": svcPrefs.SpvEnabled,
"spv_running": running,
"memetracker_enabled": svcPrefs.MemetrackerEnabled,
"memetracker_engine_alive": eng != nil && engErr == nil,
"memetracker_p2p_active": mtrP2PActive,
"memetracker_workers_connected": mtrConn,
"memetracker_mempool_tx_count": mtrCount,
"such_pqc": s.cachedSuchPQCProbe(r.Context()),
},
"spv": map[string]any{
"running": running,
"header_height": hdr.HeaderHeight,
"best_block_hash": hdr.BestBlockHash,
"header_unix_time": hdr.HeaderUnixTime,
"sync_lag_seconds": syncLagSeconds(hdr.HeaderUnixTime),
"sync_lag_label": syncLagLabel(hdr.HeaderUnixTime),
"mempool_tx_count": mempoolDisplay,
},
"memetracker": map[string]any{
"mempool_tx_count": mtrCount,
"mempool_transactions": mtrLive,
"worker_peers": mtrWorkers,
"workers_connected": mtrConn,
"engine_ok": eng != nil && engErr == nil,
"engine_error": mtrEngErr,
},
"totals": map[string]any{
"spendable_hint_doge": round2(spendable),
"pending_mempool_doge": round2(pendingMeme),
"memetracker_error": memeErrStr,
"tx_count": len(st.Transactions),
},
"metrics_sample": metricsLast24Hours(st.Metrics),
},
})
}
func tailMetrics(m []MetricPoint, n int) []MetricPoint {
if n <= 0 || len(m) <= n {
return m
}
return m[len(m)-n:]
}
// metricsLast24Hours returns samples from the last 24 hours for dashboard charts (up to ~4000 points cap).
func metricsLast24Hours(m []MetricPoint) []MetricPoint {
if len(m) == 0 {
return m
}
cutoff := time.Now().UTC().Add(-24 * time.Hour)
out := make([]MetricPoint, 0, len(m))
for _, p := range m {
if !p.T.Before(cutoff) {
out = append(out, p)
}
}
if len(out) > 0 {
return out
}
return tailMetrics(m, 120)
}
func round4(f float64) float64 {
return math.Round(f*1e4) / 1e4
}
func round2(f float64) float64 {
return math.Round(f*100) / 100
}
func spvHeaderHeightFromMap(spv map[string]any) int64 {
if spv == nil {
return 0
}
switch v := spv["header_height"].(type) {
case int64:
return v
case int:
return int64(v)
case float64:
return int64(v)
default:
return 0
}
}
func spvHeaderUnixFromMap(spv map[string]any) int64 {
if spv == nil {
return 0
}
switch v := spv["header_unix_time"].(type) {
case int64:
return v
case int:
return int64(v)
case float64:
return int64(v)
default:
return 0
}
}
// applySPVStatusHeaderInfo overlays REST-derived chain tip onto log-parsed header info.
// Log pipe lines can mis-parse timestamps (e.g. year prefix as "unix"); REST values win when plausible.
func applySPVStatusHeaderInfo(hdr *SPVHeaderInfo, spv map[string]any) {
if hdr == nil || spv == nil {
return
}
if sh := spvHeaderHeightFromMap(spv); sh > 0 {
hdr.HeaderHeight = sh
}
if v, ok := spv["best_block_hash"].(string); ok {
v = strings.TrimSpace(v)
if id := normalizeTxid(v); len(id) == 64 {
hdr.BestBlockHash = id
}
}
if su := spvHeaderUnixFromMap(spv); su > 1231006505 {
hdr.HeaderUnixTime = su
}
}
// mergeTransactionsFromBroadcastLog tags txids we attempted to broadcast as outgoing spends.
func (s *Server) mergeTransactionsFromBroadcastLog(st *WalletState) bool {
if st == nil {
return false
}
tail, err := readFileTail(s.broadcastLogPath(), 2<<20)
if err != nil || strings.TrimSpace(tail) == "" {
return false
}
ids := extractBroadcastOutTxidsFromTail(tail)
hints := extractBroadcastPaymentHintsFromTail(tail)
spentPrev := extractBroadcastSpentPrevoutHintsFromTail(tail)
if len(ids) == 0 && len(hints) == 0 && len(spentPrev) == 0 {
return false
}
incoming := make([]TxRecord, 0, len(ids)+len(hints))
for _, id := range ids {
incoming = append(incoming, TxRecord{Txid: id, Direction: "out", Source: "spv"})
}
for txid, h := range hints {
incoming = append(incoming, TxRecord{
Txid: txid,
Direction: "out",
Address: strings.TrimSpace(h.ToAddress),
AmountDOGE: h.AmountDOGE,
Source: "manual",
})
}
merged := mergeTxRecords(st.Transactions, incoming)
before := len(st.Transactions)
changed := len(merged) != before
if !changed {
oldByID := map[string]TxRecord{}
for _, t := range st.Transactions {
id := normalizeTxid(t.Txid)
if id != "" {
oldByID[id] = t
}
}
for _, t := range merged {
id := normalizeTxid(t.Txid)
o, ok := oldByID[id]
if !ok || t.Direction != o.Direction || t.AmountDOGE != o.AmountDOGE || t.Address != o.Address ||
t.Confirmations != o.Confirmations || !t.SeenAt.Equal(o.SeenAt) {
changed = true
break
}
}
}
if changed {
st.Transactions = merged
}
if applyBroadcastSpentPrevoutRewrites(st, tail) {
changed = true
}
return changed
}
// handleMetrics returns metric history for charts.
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
s.mu.Lock()
defer s.mu.Unlock()
st, err := s.loadState()
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"metrics": st.Metrics})
}
// handleTransactions lists cached transactions from local SPV/P2P state.
func (s *Server) handleTransactions(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
s.mu.Lock()
wf, err := s.loadWallet()
if err != nil {
if errors.Is(err, ErrWalletLocked) {
s.mu.Unlock()
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "locked", "need_unlock": true, "transactions": []TxRecord{}})
return
}
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"transactions": []TxRecord{}})
return
}
if wf == nil {
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"transactions": []TxRecord{}})
return
}
s.mu.Unlock()
engTx, engTxErr := s.ensureMempoolEngine(wf)
spv := s.readSPVStatus()
tipHeight := spvHeaderHeightFromMap(spv)
tipUnix := spvHeaderUnixFromMap(spv)
s.stateMergeMu.Lock()
st, err := s.loadState()
if err != nil {
s.stateMergeMu.Unlock()
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
var txMtrLive []map[string]any
if engTx != nil && engTxErr == nil {
_, txMtrLive, _, _ = engTx.DashboardSnapshot()
}
logTail, _ := spv["log_tail"].(string)
changed := false
rawChanged := false
confirmChanged := false
proofMetaChanged := false
if logTail != "" {
changed = s.applySPVSeenTxids(st, logTail)
rawChanged = s.applySPVRawHex(st, logTail)
confirmChanged = s.applySPVConfirmations(st, logTail)
proofMetaChanged = s.applySPVProofMeta(st, logTail)
}
restChanged := false
if s.shouldIngestSPVRESTTxHints() {
restChanged = s.mergeTransactionsFromSPVREST(st, tipHeight, tipUnix)
}
bcChanged := s.mergeTransactionsFromBroadcastLog(st)
bcRawChanged := s.mergeRawHexFromBroadcastLog(st)
suchChanged := false
if s.shouldRunSuchMerge(20 * time.Second) {
suchChanged = s.mergeTransactionsFromSuchListUnspent(wf, st)
}
enrichedChanged := s.enrichSPVTxFromRawHex(st, wf)
// Keep MemeTracker authoritative for unconfirmed mempool tx rows after SPV/REST/raw enrich merges.
mtrChanged := false
if len(txMtrLive) > 0 {
mtrChanged = s.persistMemeTrackerTxs(st, txMtrLive, wf)
}
if changed || rawChanged || confirmChanged || proofMetaChanged || enrichedChanged || restChanged || bcChanged || bcRawChanged || suchChanged || mtrChanged {
_ = s.saveState(st)
}
s.stateMergeMu.Unlock()
s.mu.Lock()
if changed, err := s.maybeRotateHDReceiveAddress(wf, st); err == nil && changed {
_ = s.saveWallet(wf)
s.startSPVNode(wf)
}
wf, _ = s.loadWallet()
s.mu.Unlock()
out := s.mergeTxListWithMemeTracker(wf, st)
total := len(out)
q := r.URL.Query()
limStr := strings.TrimSpace(q.Get("limit"))
if limStr == "" {
writeJSON(w, http.StatusOK, map[string]any{"transactions": out, "total": total})
return
}
limit, err := strconv.Atoi(limStr)
if err != nil || limit <= 0 {
limit = 40
}
if limit > 500 {
limit = 500
}
offset, _ := strconv.Atoi(strings.TrimSpace(q.Get("offset")))
if offset < 0 {
offset = 0
}
if offset > total {
offset = total
}
end := offset + limit
if end > total {
end = total
}
page := out[offset:end]
writeJSON(w, http.StatusOK, map[string]any{
"transactions": page,
"total": total,
"limit": limit,
"offset": offset,
"has_more": end < total,
})
}
func syncLagSeconds(headerUnix int64) int64 {
// Dogecoin chain tips are 2013+ wall times. Tiny values are almost always mis-parsed log tokens
// (same numeric floor as spv_* “plausible on-chain unix” checks elsewhere in this service).
if headerUnix <= 1231006505 {
return -1
}
lag := time.Now().UTC().Unix() - headerUnix
if lag < 0 {
return 0
}
return lag
}
func syncLagLabel(headerUnix int64) string {
lag := syncLagSeconds(headerUnix)
if lag < 0 {
return "Unknown"
}
if lag == 0 {
return "Synced"
}
const (
hour = int64(3600)
day = int64(24 * 3600)
week = int64(7 * 24 * 3600)
month = int64(30 * 24 * 3600)
)
switch {
case lag < 2*day:
return fmt.Sprintf("%d hours behind", lag/hour)
case lag < 2*week:
return fmt.Sprintf("%d days behind", lag/day)
case lag < 3*month:
return fmt.Sprintf("%d weeks behind", lag/week)
default:
return fmt.Sprintf("%d months behind", lag/month)
}
}
func truthyAny(v any) bool {
b, ok := v.(bool)
return ok && b
}
func jsonStringAny(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return strings.TrimSpace(t)
default:
return strings.TrimSpace(fmt.Sprint(t))
}
}
func floatFromAny(v any) float64 {
switch t := v.(type) {
case float64:
return t
case float32:
return float64(t)
case json.Number:
f, _ := t.Float64()
return f
case int:
return float64(t)
case int64:
return float64(t)
default:
return 0
}
}
// classifyMemeTrackerP2PKHFlow decodes mempool raw hex against wallet P2PKH outputs to classify
// whether the tx is a spend (out) or receive (in) in Dogecoin Wallet-style list semantics.
func classifyMemeTrackerP2PKHFlow(rawHex string, wf *WalletFile, prevIdx map[string]prevoutWalletMeta) (direction string, amountDOGE float64, address string, ok bool) {
if wf == nil || strings.TrimSpace(rawHex) == "" {
return "", 0, "", false
}
walletH160 := walletP2PKHHash160Map(wf)
if len(walletH160) == 0 {
return "", 0, "", false
}
testnet := strings.EqualFold(wf.Network, "testnet")
fl, err := decodeSPVRawTxFlow(rawHex, walletH160, testnet)
if err != nil {
return "", 0, "", false
}
// Prefer indexed wallet net when possible (bitcoinj-style: wallet credits minus spent wallet prevouts).
if len(prevIdx) > 0 {
if net, _, _, netOK := walletNetFromPrevoutIndex(rawHex, walletH160, testnet, prevIdx); netOK && net != 0 {
if net > 0 {
return "in", round2(float64(net)/1e8), strings.TrimSpace(fl.WalletAddr), true
}
pay := fl.CounterpartySats
if pay <= 0 {
pay = -net
}
if pay <= 0 {
pay = fl.ExternalSats
}
return "out", round2(float64(pay)/1e8), strings.TrimSpace(fl.ExternalAddr), true
}
// If any known input spends our wallet UTXO, classify as outgoing even when net graph is incomplete.
if rawB, err := hex.DecodeString(strings.ToLower(strings.TrimSpace(rawHex))); err == nil {
if ins, err := parseLegacyTxPrevouts(rawB); err == nil {
walletDebit := false
for _, in := range ins {
if in.Txid == "" {
continue
}
if m, ok := prevIdx[prevoutIndexKey(in.Txid, in.Vout)]; ok && m.WalletRecv {
walletDebit = true
break
}
}
if walletDebit {
pay := fl.CounterpartySats
if pay <= 0 {
pay = fl.ExternalSats
}
return "out", round2(float64(pay)/1e8), strings.TrimSpace(fl.ExternalAddr), true
}
}
}
}
// Incoming txs commonly include sender change outputs (external sats > 0). If wallet credits exist and we
// cannot prove a wallet debit, classify as receive by wallet credit value (matches Dogecoin Wallet list view).
if fl.WalletSats > 0 {
return "in", round2(float64(fl.WalletSats) / 1e8), strings.TrimSpace(fl.WalletAddr), true
}
if fl.ExternalSats > 0 {
pay := fl.CounterpartySats
if pay <= 0 {
pay = fl.ExternalSats
}
return "out", round2(float64(pay) / 1e8), strings.TrimSpace(fl.ExternalAddr), true
}
return "", 0, "", false
}
func isPQCarrierRevealRaw(rawHex string) bool {
s := strings.ToLower(strings.TrimSpace(rawHex))
if len(s) < 40 {
return false
}
if strings.Contains(s, "464c433146554c4c") || strings.Contains(s, "44494c3246554c4c") || strings.Contains(s, "5243473446554c4c") {
// Canonical carrier redeemScript tail (OP_DROP x5 OP_TRUE).
return strings.Contains(s, "757575757551")
}
return false
}
// persistMemeTrackerTxs appends mempool-tracked txs to state so they remain listed after they leave the live mempool.
func (s *Server) persistMemeTrackerTxs(st *WalletState, mtrLive []map[string]any, wf *WalletFile) bool {
byTxid := make(map[string]int, len(st.Transactions))
for i := range st.Transactions {
id := normalizeTxid(st.Transactions[i].Txid)
if id == "" {
continue
}
st.Transactions[i].Txid = id
if _, ok := byTxid[id]; !ok {
byTxid[id] = i
}
}
var incoming []TxRecord
changed := false
var prevIdx map[string]prevoutWalletMeta
if wf != nil {
walletH160 := walletP2PKHHash160Map(wf)
if len(walletH160) > 0 {
logBlob := ""
if lb, err := readFileTail(s.spvLogPath(), spvLogTailForEnrich); err == nil {
logBlob = lb
}
prevIdx = buildPrevoutWalletIndex(collectUniqueRawHexes(st, logBlob), walletH160)
}
}
for _, m := range mtrLive {
if !truthyAny(m["tracked_match"]) {
continue
}
txid := normalizeTxid(jsonStringAny(m["txid"]))
if txid == "" {
continue
}
amt := floatFromAny(m["amount_doge"])
rawHex := strings.TrimSpace(jsonStringAny(m["raw_hex"]))
if idx, ok := byTxid[txid]; ok {
row := &st.Transactions[idx]
addrLive := strings.TrimSpace(jsonStringAny(m["address"]))
if addrLive == "" {
addrLive = strings.TrimSpace(jsonStringAny(m["tracked_address"]))
}
// Wallet-recorded sends (broadcast / PQ) must not be overwritten by MemeTracker: it only sums
// credits to watched addresses (often change), and would flip direction to "in" — then change-echo
// heuristics can hide the row from the activity list entirely.
if strings.EqualFold(strings.TrimSpace(row.Source), "manual") && strings.EqualFold(strings.TrimSpace(row.Direction), "out") {
if row.RawHex == "" && rawHex != "" {
row.RawHex = rawHex
changed = true
}
continue
}
if row.Confirmations != 0 {
row.Confirmations = 0
changed = true
}
if row.RawHex == "" && rawHex != "" {
row.RawHex = rawHex
changed = true
}
flowRaw := rawHex
if strings.TrimSpace(flowRaw) == "" {
flowRaw = strings.TrimSpace(row.RawHex)
}
if row.Confirmations == 0 {
if dir, aDOGE, aAddr, ok := classifyMemeTrackerP2PKHFlow(flowRaw, wf, prevIdx); ok {
if !strings.EqualFold(strings.TrimSpace(row.Direction), dir) {
row.Direction = dir
changed = true
}
if aDOGE > 0 && round2(row.AmountDOGE) != round2(aDOGE) {
row.AmountDOGE = aDOGE
changed = true
}
if strings.TrimSpace(aAddr) != "" && !strings.EqualFold(strings.TrimSpace(row.Address), aAddr) {
row.Address = aAddr
changed = true
}
} else {
// Fallback when raw decode is unavailable: MTR amount/address represent watched credits.
if amt > 0 && round2(row.AmountDOGE) != round2(amt) {
row.AmountDOGE = amt
changed = true
}
d := strings.ToLower(strings.TrimSpace(row.Direction))
if d == "" || d == "unknown" {
row.Direction = "in"
changed = true
}
if addrLive != "" && !strings.EqualFold(strings.TrimSpace(row.Address), addrLive) {
row.Address = addrLive
changed = true
}
}
}
if row.Confirmations == 0 && !strings.EqualFold(strings.TrimSpace(row.Source), "manual") &&
!strings.EqualFold(strings.TrimSpace(row.Source), "memetracker") {
row.Source = "memetracker"
changed = true
} else if row.Source == "" || strings.EqualFold(row.Source, "spv") {
row.Source = "memetracker"
changed = true
}
continue
}
addrNew := strings.TrimSpace(jsonStringAny(m["address"]))
if addrNew == "" {
addrNew = strings.TrimSpace(jsonStringAny(m["tracked_address"]))
}
dirNew := "in"
amtNew := amt
addrOut := addrNew
if d, a, ad, ok := classifyMemeTrackerP2PKHFlow(rawHex, wf, prevIdx); ok {
dirNew = d
if a > 0 {
amtNew = a
}
if strings.TrimSpace(ad) != "" {
addrOut = ad
}
}
incoming = append(incoming, TxRecord{
Txid: txid,
Direction: dirNew,
AmountDOGE: amtNew,
RawHex: rawHex,
Address: addrOut,
Source: "memetracker",
Confirmations: 0,
SeenAt: time.Now().UTC(),
})
}
if len(incoming) > 0 {
st.Transactions = mergeTxRecords(st.Transactions, incoming)
changed = true
}
return changed
}
// coinbaseLikePrevout matches legacy coinbase / null outpoints (prev hash all zero, n = 0xffffffff).
func coinbaseLikePrevout(txid string, vout uint32) bool {
if vout == 0xffffffff {
return true
}
id := normalizeTxid(txid)
if len(id) != 64 {
return false
}
for i := 0; i < 64; i++ {
if id[i] != '0' {
return false
}
}
return true
}
// txIsLikelyWalletChangeEcho suppresses activity-list rows that are pure internal wallet credits:
// every non-coinbase input spends a UTXO we have indexed as paying to our wallet, and each of
// those funding transactions is one we classify as OUT (a prior send). Legitimate receives spend
// third-party prevouts (not in the index as wallet-owned, or funded by a tx not in outTxids).
func txIsLikelyWalletChangeEcho(t TxRecord, wf *WalletFile, testnet bool, walletH160 map[string]string, outTxids map[string]struct{}, prevIdx map[string]prevoutWalletMeta) bool {
if wf == nil || len(walletH160) == 0 || prevIdx == nil || len(prevIdx) == 0 {
return false
}
if strings.EqualFold(strings.TrimSpace(t.Source), "manual") && strings.EqualFold(strings.TrimSpace(t.Direction), "out") {
return false
}
if strings.EqualFold(strings.TrimSpace(t.Source), "memetracker") {
return false
}
if !strings.EqualFold(strings.TrimSpace(t.Direction), "in") {
return false
}
raw := strings.TrimSpace(t.RawHex)
if raw == "" {
return false
}
rawB, err := hex.DecodeString(strings.ToLower(raw))
if err != nil {
return false
}
ins, err := parseLegacyTxPrevouts(rawB)
if err != nil || len(ins) == 0 {
return false
}
nonCoinbase := 0
for _, in := range ins {
if coinbaseLikePrevout(in.Txid, in.Vout) {
continue
}
nonCoinbase++
key := prevoutIndexKey(in.Txid, in.Vout)
meta, ok := prevIdx[key]
if !ok || !meta.WalletRecv {
return false
}
if _, ok := outTxids[in.Txid]; !ok {
return false
}
}
if nonCoinbase == 0 {
return false
}
fl, err := decodeSPVRawTxFlow(raw, walletH160, testnet)
if err != nil || fl.WalletSats <= 0 {
return false
}
// Explicit external-facing P2PKH payout in this tx — not a pure change echo.
if fl.ExternalSats > 0 {
return false
}
return true
}
func (s *Server) mergeTxListWithMemeTracker(wf *WalletFile, st *WalletState) []txListRow {
mtrOverlay := map[string]bool{}
walletAddrSet := map[string]struct{}{}
if wf != nil {
for _, a := range wf.AllDistinctP2PKHAddresses() {
a = strings.ToLower(strings.TrimSpace(a))
if a == "" {
continue
}
walletAddrSet[a] = struct{}{}
}
}
testnet := wf != nil && strings.EqualFold(wf.Network, "testnet")
walletH160 := walletP2PKHHash160Map(wf)
outTxids := map[string]struct{}{}
for _, t := range st.Transactions {
id := normalizeTxid(t.Txid)
if id == "" {
continue
}
if strings.EqualFold(strings.TrimSpace(t.Direction), "out") {
outTxids[id] = struct{}{}
continue
}
// Include decoded sends as OUT anchors so change-echo rows can be suppressed
// even when REST temporarily labels the spend as IN/unknown.
raw := strings.TrimSpace(t.RawHex)
if raw == "" || len(walletH160) == 0 {
continue
}
if fl, err := decodeSPVRawTxFlow(raw, walletH160, testnet); err == nil && fl.ExternalSats > 0 {
outTxids[id] = struct{}{}
}
}
var prevIdx map[string]prevoutWalletMeta
if len(walletH160) > 0 {
logBlob := ""
if lb, err := readFileTail(s.spvLogPath(), spvLogTailForEnrich); err == nil {
logBlob = lb
}
prevIdx = buildPrevoutWalletIndex(collectUniqueRawHexes(st, logBlob), walletH160)
}
eng, engErr := s.ensureMempoolEngine(wf)
if eng != nil && engErr == nil {
_, mtrLive, _, _ := eng.DashboardSnapshot()
for _, m := range mtrLive {
if !truthyAny(m["tracked_match"]) {
continue
}
txid := normalizeTxid(jsonStringAny(m["txid"]))
if txid == "" {
continue
}
mtrOverlay[txid] = true
}
}
out := make([]txListRow, 0, len(st.Transactions))
for _, t := range st.Transactions {
if txIsLikelyWalletChangeEcho(t, wf, testnet, walletH160, outTxids, prevIdx) {
continue
}
tr := txListRow{TxRecord: t, Pending: t.Confirmations == 0}
txKey := normalizeTxid(t.Txid)
// MemeTracker rows and local broadcast sends carry authoritative amount/address; raw-hex prevout
// decoding can mis-estimate mempool receives (e.g. treat wallet debits as "inputs") or fight MTR totals.
// Live mempool overlay rows must match the pending balance path (UpsertTracking), not a re-decoded guess.
skipRawHexEnrich := strings.EqualFold(strings.TrimSpace(t.Source), "memetracker") ||
(strings.EqualFold(strings.TrimSpace(t.Source), "manual") && strings.EqualFold(strings.TrimSpace(t.Direction), "out")) ||
(txKey != "" && mtrOverlay[txKey])
if !skipRawHexEnrich && len(walletH160) > 0 && strings.TrimSpace(tr.RawHex) != "" && prevIdx != nil {
fl, err := decodeSPVRawTxFlow(tr.RawHex, walletH160, testnet)
if err == nil {
net, _, _, netOk := walletNetFromPrevoutIndex(tr.RawHex, walletH160, testnet, prevIdx)
if netOk && net != 0 {
amtSats := net
if amtSats < 0 {
amtSats = -amtSats
}
if net < 0 {
if fl.CounterpartySats > 0 {
amtSats = fl.CounterpartySats
}
amt := round2(float64(amtSats) / 1e8)
tr.Direction = "out"
if amt > 0 {
tr.AmountDOGE = amt
}
if fl.ExternalAddr != "" {
tr.Address = fl.ExternalAddr
}
} else {
amt := round2(float64(amtSats) / 1e8)
tr.Direction = "in"
if amt > 0 {
tr.AmountDOGE = amt
}
if strings.TrimSpace(tr.Address) == "" && fl.WalletAddr != "" {
tr.Address = fl.WalletAddr
}
}
} else if fl.ExternalSats > 0 && fl.WalletSats == 0 {
// Only treat as a pure outbound payment when this tx has no P2PKH credits to us.
// Incoming payments often include the sender's change output; with mempool txs the
// prevout graph is usually incomplete so netOk is false — do not rewrite as OUT using
// CounterpartySats (that can be the sender's change, not our receive).
tr.Direction = "out"
paySats := fl.CounterpartySats
if paySats <= 0 {
paySats = fl.ExternalSats
}
amt := round2(float64(paySats) / 1e8)
if amt > 0 {
tr.AmountDOGE = amt
}
if fl.ExternalAddr != "" {
tr.Address = fl.ExternalAddr
}
}
}
}
// Last-mile guardrail for API output:
// only force OUT when address is explicitly non-wallet.
// Do not force IN for wallet-address rows (can be spend tx change rows).
if strings.EqualFold(strings.TrimSpace(tr.Direction), "unknown") || strings.TrimSpace(tr.Direction) == "" {
addr := strings.ToLower(strings.TrimSpace(tr.Address))
if addr != "" {
if _, ok := walletAddrSet[addr]; !ok {
tr.Direction = "out"
} else if tr.AmountDOGE > 0 {
// SPV REST often omits direction on /getTransactions funding lines (credits to our P2PKH).
tr.Direction = "in"
}
} else if tr.AmountDOGE > 0 && strings.EqualFold(strings.TrimSpace(tr.Source), "memetracker") {
tr.Direction = "in"
}
}
if mtrOverlay[normalizeTxid(t.Txid)] && t.Confirmations == 0 {
tr.Pending = true
// Keep local send rows labeled manual so list enrichment stays authoritative for payee + amount.
if !(strings.EqualFold(strings.TrimSpace(t.Source), "manual") && strings.EqualFold(strings.TrimSpace(t.Direction), "out")) {
tr.Source = "memetracker"
}
}
// TX_R reveal spends carrier outputs and returns value back to our wallet.
// Showing the carrier face value as a normal outgoing payment is misleading.
if isPQCarrierRevealRaw(tr.RawHex) && strings.EqualFold(strings.TrimSpace(tr.Direction), "out") && tr.AmountDOGE >= 0.9 {
tr.AmountDOGE = 0
}
out = append(out, tr)
}
sort.SliceStable(out, func(i, j int) bool {
ti := out[i]
tj := out[j]
ui := ti.SeenAt.Unix()
uj := tj.SeenAt.Unix()
if ui != uj {
return ui > uj
}
if ti.BlockHeight != tj.BlockHeight {
return ti.BlockHeight > tj.BlockHeight
}
if ti.Confirmations != tj.Confirmations {
return ti.Confirmations > tj.Confirmations
}
return strings.ToLower(strings.TrimSpace(ti.Txid)) > strings.ToLower(strings.TrimSpace(tj.Txid))
})
return out
}
func (s *Server) applySPVConfirmations(st *WalletState, logTail string) bool {
confirmed := parseSPVConfirmedTxids(logTail)
if len(confirmed) == 0 {
return false
}
changed := false
for i := range st.Transactions {
id := normalizeTxid(st.Transactions[i].Txid)
if id == "" {
continue
}
if _, ok := confirmed[id]; !ok {
continue
}
st.Transactions[i].Txid = id
if st.Transactions[i].Confirmations <= 0 {
st.Transactions[i].Confirmations = 1
if st.Transactions[i].Source == "memetracker" || st.Transactions[i].Source == "" {
st.Transactions[i].Source = "spv"
}
changed = true
}
}
return changed
}
func (s *Server) applySPVProofMeta(st *WalletState, logTail string) bool {
if st == nil || strings.TrimSpace(logTail) == "" {
return false
}
metaByTxid := parseSPVProofMetaByTxid(logTail)
if len(metaByTxid) == 0 {
return false
}
changed := false
for i := range st.Transactions {
id := normalizeTxid(st.Transactions[i].Txid)
if id == "" {
continue
}
meta, ok := metaByTxid[id]
if !ok {
continue
}
if st.Transactions[i].SPVBlockHash == "" && meta.BlockHash != "" {
st.Transactions[i].SPVBlockHash = meta.BlockHash
changed = true
}
if meta.BlockHeight > st.Transactions[i].SPVBlockHeight {
st.Transactions[i].SPVBlockHeight = meta.BlockHeight
changed = true
}
if st.Transactions[i].SPVMerkleRaw == "" && meta.MerkleRaw != "" {
st.Transactions[i].SPVMerkleRaw = meta.MerkleRaw
changed = true
}
if st.Transactions[i].SPVHeaderRaw == "" && meta.HeaderRaw != "" {
st.Transactions[i].SPVHeaderRaw = meta.HeaderRaw
changed = true
}
if st.Transactions[i].SPVProofNote == "" && meta.ProofNote != "" {
st.Transactions[i].SPVProofNote = meta.ProofNote
changed = true
}
}
return changed
}
func (s *Server) applySPVRawHex(st *WalletState, logTail string) bool {
byTxid := parseSPVRawTxHexByTxid(logTail)
if len(byTxid) == 0 {
return false
}
changed := false
existing := make(map[string]struct{}, len(st.Transactions))
for i := range st.Transactions {
id := normalizeTxid(st.Transactions[i].Txid)
if id == "" {
continue
}
existing[id] = struct{}{}
raw := byTxid[id]
if raw == "" {
continue
}
if st.Transactions[i].RawHex == "" {
st.Transactions[i].RawHex = raw
changed = true
}
}
for id, raw := range byTxid {
if id == "" || raw == "" {
continue
}
if _, ok := existing[id]; ok {
continue
}
st.Transactions = append(st.Transactions, TxRecord{
Txid: id,
Direction: "unknown",
AmountDOGE: 0,
RawHex: raw,
Confirmations: 0,
Source: "spv",
SeenAt: time.Time{},
})
existing[id] = struct{}{}
changed = true
}
return changed
}
// applySPVSeenTxids ensures txids visible in SPV logs appear in state even when
// explorer/mempool sources are unavailable.
func (s *Server) applySPVSeenTxids(st *WalletState, logTail string) bool {
seen := parseSPVSeenTxids(logTail)
if len(seen) == 0 {
return false
}
existing := make(map[string]struct{}, len(st.Transactions))
for i := range st.Transactions {
id := normalizeTxid(st.Transactions[i].Txid)
if id == "" {
continue
}
existing[id] = struct{}{}
}
added := false
for id := range seen {
if _, ok := existing[id]; ok {
continue
}
st.Transactions = append(st.Transactions, TxRecord{
Txid: id,
Direction: "unknown",
AmountDOGE: 0,
Confirmations: 0,
Source: "spv",
SeenAt: time.Time{},
})
existing[id] = struct{}{}
added = true
}
return added
}
// Blockchair uses smallest units for Dogecoin (×1e8) when the number is large.
func normalizeDogecoinUnits(v float64) float64 {
if v >= 1e6 {
return v / 1e8
}
return v
}
func parseFloatAny(v any) float64 {
switch t := v.(type) {
case float64:
return t
case string:
f, _ := strconv.ParseFloat(strings.TrimSpace(t), 64)
return f
case json.Number:
f, _ := t.Float64()
return f
default:
return 0
}
}
// backgroundMetricsLoop samples SPV + header info and appends metric points.
func (s *Server) backgroundMetricsLoop() {
tick := time.NewTicker(20 * time.Second)
defer tick.Stop()
for range tick.C {
s.mu.Lock()
wf, err := s.loadWallet()
if errors.Is(err, ErrWalletLocked) {
s.startSPVNodeFromWatchState()
s.mu.Unlock()
continue
}
if err != nil || wf == nil {
s.mu.Unlock()
continue
}
s.startSPVNode(wf)
s.mu.Unlock()
spv := s.readSPVStatus()
logTail, _ := spv["log_tail"].(string)
hdr := parseSPVLogHeaderInfo(logTail)
applySPVStatusHeaderInfo(&hdr, spv)
running, _ := spv["running"].(bool)
tipHeight := spvHeaderHeightFromMap(spv)
tipUnix := spvHeaderUnixFromMap(spv)
s.stateMergeMu.Lock()
st, err := s.loadState()
if err != nil {
s.stateMergeMu.Unlock()
continue
}
spvTxSeen := parseSPVTxSeenCount(logTail)
_ = s.applySPVSeenTxids(st, logTail)
_ = s.applySPVConfirmations(st, logTail)
_ = s.applySPVProofMeta(st, logTail)
_ = s.applySPVRawHex(st, logTail)
if s.shouldIngestSPVRESTTxHints() {
_ = s.mergeTransactionsFromSPVREST(st, tipHeight, tipUnix)
}
_ = s.mergeTransactionsFromBroadcastLog(st)
_ = s.mergeRawHexFromBroadcastLog(st)
_ = s.mergeTransactionsFromSuchListUnspent(wf, st)
_ = s.enrichSPVTxFromRawHex(st, wf)
mempoolRelay := 0
if eng, eerr := s.ensureMempoolEngine(wf); eerr == nil && eng != nil {
mempoolRelay, _, _, _ = eng.DashboardSnapshot()
}
s.appendMetricPoint(st, MetricPoint{
T: time.Now().UTC(),
HeaderHeight: hdr.HeaderHeight,
BestBlockHash: hdr.BestBlockHash,
SPVRunning: running,
PeerCount: hdr.PeerCount,
MempoolTxCount: hdr.MempoolTxCount,
SPVTxSeenCount: spvTxSeen,
MempoolRelayCount: mempoolRelay,
})
_ = s.saveState(st)
s.stateMergeMu.Unlock()
}
}
package main
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const debugLedgerRESTMax = 256 * 1024
func truncateDebugString(s string, max int) string {
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + fmt.Sprintf("…(%d bytes total)", len(s))
}
func spvStatusMapForDebug(spv map[string]any, logTailMax int) map[string]any {
if spv == nil {
return nil
}
out := make(map[string]any, len(spv)+1)
for k, v := range spv {
if k == "log_tail" {
if s, ok := v.(string); ok && logTailMax > 0 && len(s) > logTailMax {
out[k] = truncateDebugString(s, logTailMax)
out["log_tail_truncated"] = true
out["log_tail_original_bytes"] = len(s)
continue
}
}
out[k] = v
}
return out
}
func spvRESTTxRowsSummary(rows []spvRESTTxRow, limit int) []map[string]any {
if limit <= 0 {
limit = 200
}
var out []map[string]any
for i, r := range rows {
if i >= limit {
break
}
out = append(out, map[string]any{
"txid": normalizeTxid(r.Txid),
"direction": strings.ToLower(strings.TrimSpace(r.Direction)),
"amount_doge": r.AmountDOGE,
"address": r.Address,
"confirmations": r.Confirmations,
"vout": r.Vout,
"spend_txid": normalizeTxid(r.SpendTxid),
"pay_to": strings.TrimSpace(r.PayTo),
"pay_amount_doge": r.PayAmountDOGE,
"spend_height": r.SpendBlockHeight,
"spend_confirmations": r.SpendConfirmations,
"seen_at_unix": func() int64 {
if r.SeenAt.IsZero() {
return 0
}
return r.SeenAt.UTC().Unix()
}(),
})
}
return out
}
// handleDebugSPVLedger returns a JSON snapshot of SPV REST bodies, truncated log tail, headers.db height probe,
// persisted state tx summary, and broadcast.log-derived outgoing txids (for comparing with the UI list).
func (s *Server) handleDebugSPVLedger(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
if s.strictSettingsPeekBlocked() {
writeStrictSettingsAuthRequired(w)
return
}
s.mu.Lock()
_, werr := s.loadWallet()
s.mu.Unlock()
if werr != nil {
if errors.Is(werr, ErrWalletLocked) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "wallet locked"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": werr.Error()})
return
}
spv := s.readSPVStatus()
out := map[string]any{
"spv_status": spvStatusMapForDebug(spv, 24*1024),
}
rawTip, errTip := s.fetchSPVREST("/getChaintip")
rawTS, errTS := s.fetchSPVREST("/getTimestamp")
rawTx, errTx := s.fetchSPVREST("/getTransactions")
rawUtxo, errUtxo := s.fetchSPVREST("/getUTXOs")
out["rest"] = map[string]any{
"getChaintip": map[string]any{
"error": errString(errTip),
"raw": truncateDebugString(rawTip, debugLedgerRESTMax),
},
"getTimestamp": map[string]any{
"error": errString(errTS),
"raw": truncateDebugString(rawTS, debugLedgerRESTMax),
},
"getTransactions": map[string]any{
"error": errString(errTx),
"raw": truncateDebugString(rawTx, debugLedgerRESTMax),
},
"getUTXOs": map[string]any{
"error": errString(errUtxo),
"raw": truncateDebugString(rawUtxo, debugLedgerRESTMax),
},
}
th, bh := parseSPVRESTChaintip(rawTip)
txRows := parseSPVRESTRows(rawTx, "unknown")
utxoRows := parseSPVRESTRows(rawUtxo, "in")
out["parsed"] = map[string]any{
"chaintip_height": th,
"chaintip_hash": bh,
"timestamp_unix": parseSPVRESTTimestamp(rawTS),
"tx_rows": spvRESTTxRowsSummary(txRows, 300),
"utxo_rows": spvRESTTxRowsSummary(utxoRows, 300),
"tx_row_count": len(txRows),
"utxo_row_count": len(utxoRows),
"tx_parse_note": "REST rows are per-UTXO hints, not SoChain-style net; spendable:0 does not imply OUT. Wallet UI merges raw-hex enrich into state.json.",
}
hdb := filepath.Join(s.storageDir, "headers.db")
hmeta := map[string]any{"path": hdb}
if st, err := os.Stat(hdb); err == nil {
hmeta["size"] = st.Size()
hmeta["is_dir"] = st.IsDir()
hmeta["libdogecoin_headers_file"] = isLibdogecoinHeadersFileFormat(hdb)
if isLibdogecoinHeadersFileFormat(hdb) {
if tip, err := libdogecoinHeadersFileTipHeight(hdb); err == nil {
hmeta["headers_file_tip_height"] = tip
}
}
} else {
hmeta["stat_error"] = err.Error()
}
out["headers_db"] = hmeta
s.stateMergeMu.Lock()
st, _ := s.loadState()
s.stateMergeMu.Unlock()
txSample := []map[string]any{}
n := 0
if st != nil {
n = len(st.Transactions)
for i, t := range st.Transactions {
if i >= 120 {
break
}
seenAt := ""
if !t.SeenAt.IsZero() {
seenAt = t.SeenAt.UTC().Format(time.RFC3339Nano)
}
txSample = append(txSample, map[string]any{
"txid": t.Txid,
"direction": t.Direction,
"amount_doge": t.AmountDOGE,
"confirmations": t.Confirmations,
"source": t.Source,
"seen_at": seenAt,
})
}
}
out["state"] = map[string]any{
"transactions_count": n,
"transactions_sample": txSample,
}
bcTail, bcErr := readFileTail(s.broadcastLogPath(), 2<<20)
bcIDs := []string{}
if bcErr == nil {
bcIDs = extractBroadcastOutTxidsFromTail(bcTail)
}
out["broadcast_log"] = map[string]any{
"path": s.broadcastLogPath(),
"tail_read_error": errString(bcErr),
"tail_bytes": len(bcTail),
"txids_from_log": bcIDs,
"virtual_db_query": "GET /api/debug/spv-wallet-db?op=query&q=SELECT%20*%20FROM%20transactions — virtual tables over libdogecoin spv_wallet.db",
}
writeJSON(w, http.StatusOK, out)
}
func errString(err error) string {
if err == nil {
return ""
}
return err.Error()
}
package main
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
)
const debugWalletDBMaxRows = 200
func debugVirtualQueryAllowed(q string) (string, error) {
q = strings.TrimSpace(q)
if q == "" {
return "", errors.New("empty query")
}
if strings.HasSuffix(q, ";") {
q = strings.TrimSpace(strings.TrimSuffix(q, ";"))
}
low := strings.ToLower(q)
if strings.Contains(q, ";") {
return "", errors.New("multiple statements are not allowed")
}
if strings.HasPrefix(low, "pragma ") {
return q, nil
}
if strings.HasPrefix(low, "select ") || strings.HasPrefix(low, "with ") {
if strings.Contains(low, " into ") {
return "", errors.New("SELECT INTO is not allowed")
}
return q, nil
}
return "", errors.New("only SELECT, WITH … SELECT, or PRAGMA queries are allowed")
}
func nonSQLiteDebugPayload(path, op string) map[string]any {
return map[string]any{
"path": path,
"format": "libdogecoin_wallet_file",
"requested_operation": op,
"hint": "libdogecoin spv_wallet.db is a binary wallet file in this pup. Use virtual tables (addresses, utxos, transactions, chaintip, timestamp) backed by such + SPV REST.",
}
}
func extractFromClauseTable(query string) string {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return ""
}
parts := strings.Fields(q)
for i := 0; i < len(parts)-1; i++ {
if parts[i] == "from" {
t := strings.Trim(parts[i+1], "`\"'[]();")
return t
}
}
return ""
}
func nonSQLiteVirtualTableNames() []string {
return []string{"addresses", "utxos", "transactions", "chaintip", "timestamp"}
}
func (s *Server) nonSQLiteVirtualRows(wf *WalletFile, table string) ([]map[string]any, error) {
table = strings.ToLower(strings.TrimSpace(table))
testnet := false
if wf != nil {
testnet = strings.EqualFold(wf.Network, "testnet")
}
switch table {
case "addresses":
addrs := []map[string]any{}
if wf != nil {
for _, a := range wf.AllDistinctP2PKHAddresses() {
a = strings.TrimSpace(a)
if a == "" {
continue
}
addrs = append(addrs, map[string]any{"address": a})
}
}
return addrs, nil
case "utxos":
rows := []map[string]any{}
if wf == nil {
return rows, nil
}
for _, addr := range wf.AllDistinctP2PKHAddresses() {
addr = strings.TrimSpace(addr)
if addr == "" {
continue
}
utxos, err := s.runSuchListUnspent(addr, testnet)
if err != nil {
continue
}
for _, u := range utxos {
rows = append(rows, map[string]any{
"txid": normalizeTxid(u.TxID),
"vout": u.Vout,
"address": addr,
"value_koinu": u.Value,
"amount_doge": float64(u.Value) / 1e8,
"script": u.ScriptPubHex,
})
}
}
return rows, nil
case "transactions":
all := []map[string]any{}
if raw, err := s.fetchSPVREST("/getUTXOs"); err == nil {
for _, r := range parseSPVRESTRows(raw, "unknown") {
dir := strings.TrimSpace(strings.ToLower(r.Direction))
if dir == "" {
dir = "unknown"
}
all = append(all, map[string]any{
"txid": normalizeTxid(r.Txid),
"address": r.Address,
"direction": dir,
"amount_doge": r.AmountDOGE,
"confirmations": r.Confirmations,
})
}
}
if raw, err := s.fetchSPVREST("/getTransactions"); err == nil {
for _, r := range parseSPVRESTRows(raw, "unknown") {
dir := strings.TrimSpace(strings.ToLower(r.Direction))
if dir == "" {
dir = "unknown"
}
all = append(all, map[string]any{
"txid": normalizeTxid(r.Txid),
"address": r.Address,
"direction": dir,
"amount_doge": r.AmountDOGE,
"confirmations": r.Confirmations,
})
}
}
return all, nil
case "chaintip":
raw, err := s.fetchSPVREST("/getChaintip")
if err != nil {
return nil, err
}
h, bh := parseSPVRESTChaintip(raw)
return []map[string]any{{"height": h, "hash": bh}}, nil
case "timestamp":
raw, err := s.fetchSPVREST("/getTimestamp")
if err != nil {
return nil, err
}
return []map[string]any{{"timestamp_unix": parseSPVRESTTimestamp(raw)}}, nil
default:
return nil, fmt.Errorf("unknown virtual table: %s", table)
}
}
func (s *Server) handleDebugSPVWalletDB(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
if s.strictSettingsPeekBlocked() {
writeStrictSettingsAuthRequired(w)
return
}
s.mu.Lock()
defer s.mu.Unlock()
wf, err := s.loadWallet()
if err != nil {
if errors.Is(err, ErrWalletLocked) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "wallet locked"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
dbPath := filepath.Join(s.storageDir, "spv_wallet.db")
st, err := os.Stat(dbPath)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "spv_wallet.db not found"})
return
}
if st.IsDir() {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "path is not a file"})
return
}
op := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("op")))
if op == "" || op == "meta" {
p := nonSQLiteDebugPayload(dbPath, "meta")
p["virtual_tables"] = nonSQLiteVirtualTableNames()
writeJSON(w, http.StatusOK, p)
return
}
switch op {
case "tables":
t := nonSQLiteVirtualTableNames()
sort.Strings(t)
writeJSON(w, http.StatusOK, map[string]any{
"path": dbPath,
"format": "libdogecoin_wallet_file",
"virtual_tables": t,
"tables": t,
"note": "Virtual tables are derived from libdogecoin such/spv REST data sources.",
})
return
case "table_info":
tbl := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("table")))
if tbl == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing table"})
return
}
rows, err := s.nonSQLiteVirtualRows(wf, tbl)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
cols := []string{}
if len(rows) > 0 {
for k := range rows[0] {
cols = append(cols, k)
}
sort.Strings(cols)
}
writeJSON(w, http.StatusOK, map[string]any{"table": tbl, "columns": cols, "row_count": len(rows), "virtual": true})
return
case "query":
qin := strings.TrimSpace(r.URL.Query().Get("q"))
if _, err := debugVirtualQueryAllowed(qin); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
tbl := extractFromClauseTable(qin)
if tbl == "" {
tbl = strings.ToLower(strings.TrimSpace(qin))
}
rows, err := s.nonSQLiteVirtualRows(wf, tbl)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "use virtual table names or SELECT ... FROM <table>; available: addresses, utxos, transactions, chaintip, timestamp"})
return
}
truncated := false
if len(rows) > debugWalletDBMaxRows {
truncated = true
rows = rows[:debugWalletDBMaxRows]
}
writeJSON(w, http.StatusOK, map[string]any{"table": tbl, "rows": rows, "row_count": len(rows), "truncated": truncated, "virtual": true})
return
default:
p := nonSQLiteDebugPayload(dbPath, op)
p["virtual_tables"] = nonSQLiteVirtualTableNames()
writeJSON(w, http.StatusOK, p)
return
}
}
package main
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"io"
"math"
)
// Legacy Bitcoin/Dogecoin transaction serialization (BIP144 witness not used here).
// Unsigned txs use empty scriptSig; signing is done by libdogecoin `such` on the hex.
const (
legacyTxVersion int32 = 1
legacyTxInSequence uint32 = 0xffffffff
legacyHashHexMaxLen = 64
)
// txidWireBytes parses a display-order txid hex into the 32-byte order used in serialized outpoints.
func txidWireBytes(displayHex string) ([32]byte, error) {
var dst [32]byte
src := displayHex
if len(src) > legacyHashHexMaxLen {
return dst, fmt.Errorf("txid string too long")
}
var srcBytes []byte
if len(src)%2 == 0 {
srcBytes = []byte(src)
} else {
srcBytes = make([]byte, 1+len(src))
srcBytes[0] = '0'
copy(srcBytes[1:], src)
}
var reversedHash [32]byte
decLen := hex.DecodedLen(len(srcBytes))
if decLen > 32 {
return dst, fmt.Errorf("invalid txid hex length")
}
_, err := hex.Decode(reversedHash[32-decLen:], srcBytes)
if err != nil {
return dst, err
}
const hashSize = 32
for i, b := range reversedHash[:hashSize/2] {
dst[i], dst[hashSize-1-i] = reversedHash[hashSize-1-i], b
}
return dst, nil
}
func writeVarInt(w io.Writer, val uint64) error {
var buf [9]byte
switch {
case val < 0xfd:
buf[0] = uint8(val)
_, err := w.Write(buf[:1])
return err
case val <= math.MaxUint16:
buf[0] = 0xfd
binary.LittleEndian.PutUint16(buf[1:3], uint16(val))
_, err := w.Write(buf[:3])
return err
case val <= math.MaxUint32:
buf[0] = 0xfe
binary.LittleEndian.PutUint32(buf[1:5], uint32(val))
_, err := w.Write(buf[:5])
return err
default:
buf[0] = 0xff
if _, err := w.Write(buf[:1]); err != nil {
return err
}
binary.LittleEndian.PutUint64(buf[:8], val)
_, err := w.Write(buf[:8])
return err
}
}
type txOutWire struct {
Value int64
PkScript []byte
}
// serializeUnsignedLegacyP2PKHTx builds unsigned raw tx bytes (empty scriptSig per input).
func serializeUnsignedLegacyP2PKHTx(
selected []ExplorerUTXO,
outputs []txOutWire,
lockTime uint32,
) ([]byte, error) {
var buf bytes.Buffer
if err := binary.Write(&buf, binary.LittleEndian, legacyTxVersion); err != nil {
return nil, err
}
if err := writeVarInt(&buf, uint64(len(selected))); err != nil {
return nil, err
}
for _, u := range selected {
h, err := txidWireBytes(u.TxID)
if err != nil {
return nil, fmt.Errorf("txid %s: %w", u.TxID, err)
}
if _, err := buf.Write(h[:]); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, u.Vout); err != nil {
return nil, err
}
if err := writeVarInt(&buf, 0); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, legacyTxInSequence); err != nil {
return nil, err
}
}
if err := writeVarInt(&buf, uint64(len(outputs))); err != nil {
return nil, err
}
for _, out := range outputs {
if err := binary.Write(&buf, binary.LittleEndian, uint64(out.Value)); err != nil {
return nil, err
}
if err := writeVarInt(&buf, uint64(len(out.PkScript))); err != nil {
return nil, err
}
if _, err := buf.Write(out.PkScript); err != nil {
return nil, err
}
}
if err := binary.Write(&buf, binary.LittleEndian, lockTime); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// dogeLegacyTxidHex returns display-order txid (double-SHA256, byte-reversed) for a serialized transaction.
func dogeLegacyTxidHex(tx []byte) string {
if len(tx) == 0 {
return ""
}
h1 := sha256.Sum256(tx)
h2 := sha256.Sum256(h1[:])
out := make([]byte, 32)
for i := range h2 {
out[i] = h2[31-i]
}
return hex.EncodeToString(out)
}
// parseLegacyTxOutputs walks legacy tx inputs then returns output vector (value + pkScript).
func parseLegacyTxOutputs(raw []byte) ([]txOutWire, error) {
off := 0
if len(raw) < 6 {
return nil, fmt.Errorf("tx too short")
}
off += 4 // version
nin, err := readCompactSize(raw, &off)
if err != nil {
return nil, err
}
for i := 0; i < int(nin); i++ {
if off+36 > len(raw) {
return nil, fmt.Errorf("truncated txin %d", i)
}
off += 36
slen, err := readCompactSize(raw, &off)
if err != nil {
return nil, err
}
if int64(slen) > int64(len(raw)-off) {
return nil, fmt.Errorf("truncated scriptsig %d", i)
}
off += int(slen)
if off+4 > len(raw) {
return nil, fmt.Errorf("truncated sequence %d", i)
}
off += 4
}
nout, err := readCompactSize(raw, &off)
if err != nil {
return nil, err
}
var outs []txOutWire
for i := 0; i < int(nout); i++ {
if off+8 > len(raw) {
return nil, fmt.Errorf("truncated output value %d", i)
}
val := int64(binary.LittleEndian.Uint64(raw[off : off+8]))
off += 8
pklen, err := readCompactSize(raw, &off)
if err != nil {
return nil, err
}
if int64(pklen) > int64(len(raw)-off) {
return nil, fmt.Errorf("truncated pkscript %d", i)
}
pk := make([]byte, pklen)
copy(pk, raw[off:off+int(pklen)])
off += int(pklen)
outs = append(outs, txOutWire{Value: val, PkScript: pk})
}
return outs, nil
}
// legacyTxWireLen returns the serialized byte length of a transaction starting at raw[0],
// matching libdogecoin dogecoin_tx_deserialize (including optional witness extension).
func legacyTxWireLen(raw []byte) (int, error) {
off := 0
if len(raw) < 4 {
return 0, fmt.Errorf("tx too short")
}
off += 4 // version (int32 LE)
vinCount, err := readCompactSize(raw, &off)
if err != nil {
return 0, err
}
flags := byte(0)
if vinCount == 0 {
if off >= len(raw) {
return 0, fmt.Errorf("witness marker eof")
}
flags = raw[off]
off++
if flags != 0 {
vinCount, err = readCompactSize(raw, &off)
if err != nil {
return 0, err
}
}
}
for i := 0; i < int(vinCount); i++ {
if off+36 > len(raw) {
return 0, fmt.Errorf("truncated txin %d", i)
}
off += 36
slen, err := readCompactSize(raw, &off)
if err != nil {
return 0, err
}
if int64(slen) > int64(len(raw)-off) {
return 0, fmt.Errorf("truncated scriptsig %d", i)
}
off += int(slen)
if off+4 > len(raw) {
return 0, fmt.Errorf("truncated sequence %d", i)
}
off += 4
}
nout, err := readCompactSize(raw, &off)
if err != nil {
return 0, err
}
for i := 0; i < int(nout); i++ {
if off+8 > len(raw) {
return 0, fmt.Errorf("truncated output value %d", i)
}
off += 8
pklen, err := readCompactSize(raw, &off)
if err != nil {
return 0, err
}
if int64(pklen) > int64(len(raw)-off) {
return 0, fmt.Errorf("truncated pkscript %d", i)
}
off += int(pklen)
}
if flags&1 != 0 {
flags ^= 1
for i := 0; i < int(vinCount); i++ {
wc, err := readCompactSize(raw, &off)
if err != nil {
return 0, err
}
for j := 0; j < int(wc); j++ {
itemLen, err := readCompactSize(raw, &off)
if err != nil {
return 0, err
}
if int64(itemLen) > int64(len(raw)-off) {
return 0, fmt.Errorf("truncated witness item %d:%d", i, j)
}
off += int(itemLen)
}
}
}
if flags != 0 {
return 0, fmt.Errorf("unsupported tx witness flags")
}
if off+4 > len(raw) {
return 0, fmt.Errorf("truncated locktime")
}
off += 4
return off, nil
}
func findOutputIndexByPkScript(outs []txOutWire, want []byte) int {
for i := range outs {
if len(outs[i].PkScript) == len(want) && bytes.Equal(outs[i].PkScript, want) {
return i
}
}
return -1
}
// findAllPkScriptMatches returns every vout index whose pkScript equals want (in ascending order).
func findAllPkScriptMatches(outs []txOutWire, want []byte) []int {
var idx []int
for i := range outs {
if len(outs[i].PkScript) == len(want) && bytes.Equal(outs[i].PkScript, want) {
idx = append(idx, i)
}
}
return idx
}
// buildUnsignedCarrierRevealTx spends one prevout with empty scriptSig and pays outValue to outPkScript (single output).
func buildUnsignedCarrierRevealTx(prevTxID string, prevVout uint32, outValue int64, outPkScript []byte) ([]byte, error) {
if outValue <= 0 {
return nil, fmt.Errorf("reveal output value must be positive")
}
if len(outPkScript) == 0 {
return nil, fmt.Errorf("empty output script")
}
var buf bytes.Buffer
if err := binary.Write(&buf, binary.LittleEndian, legacyTxVersion); err != nil {
return nil, err
}
if err := writeVarInt(&buf, 1); err != nil {
return nil, err
}
h, err := txidWireBytes(prevTxID)
if err != nil {
return nil, err
}
if _, err := buf.Write(h[:]); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, prevVout); err != nil {
return nil, err
}
if err := writeVarInt(&buf, 0); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, legacyTxInSequence); err != nil {
return nil, err
}
if err := writeVarInt(&buf, 1); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, uint64(outValue)); err != nil {
return nil, err
}
if err := writeVarInt(&buf, uint64(len(outPkScript))); err != nil {
return nil, err
}
if _, err := buf.Write(outPkScript); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, uint32(0)); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// buildUnsignedCarrierRevealTxMulti spends prevTxID at each listed vout (same tx) with empty scriptSig
// and pays a single P2PKH output of outValue (fee is implicit: sum(prev values) − outValue).
func buildUnsignedCarrierRevealTxMulti(prevTxID string, vouts []uint32, outValue int64, outPkScript []byte) ([]byte, error) {
if len(vouts) == 0 {
return nil, fmt.Errorf("no carrier prevouts")
}
if outValue <= 0 {
return nil, fmt.Errorf("reveal output value must be positive")
}
if len(outPkScript) == 0 {
return nil, fmt.Errorf("empty output script")
}
var buf bytes.Buffer
if err := binary.Write(&buf, binary.LittleEndian, legacyTxVersion); err != nil {
return nil, err
}
if err := writeVarInt(&buf, uint64(len(vouts))); err != nil {
return nil, err
}
h, err := txidWireBytes(prevTxID)
if err != nil {
return nil, err
}
for _, vo := range vouts {
if _, err := buf.Write(h[:]); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, vo); err != nil {
return nil, err
}
if err := writeVarInt(&buf, 0); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, legacyTxInSequence); err != nil {
return nil, err
}
}
if err := writeVarInt(&buf, 1); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, uint64(outValue)); err != nil {
return nil, err
}
if err := writeVarInt(&buf, uint64(len(outPkScript))); err != nil {
return nil, err
}
if _, err := buf.Write(outPkScript); err != nil {
return nil, err
}
if err := binary.Write(&buf, binary.LittleEndian, uint32(0)); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
package main
// educationPayload returns long-form copy for the web UI (How it works).
func educationPayload() map[string]any {
return map[string]any{
"app_version": pqWalletAppVersion,
"build_hash": pqWalletBuildHash,
"title": "How Dogecoin + post-quantum proofs fit together",
"summary": "Normal Dogecoin spends still use ECDSA (secp256k1) P2PKH. Experimental post-quantum material (e.g. Falcon/Dilithium via liboqs) " +
"can be used to add commitments or attestations around a transaction, it does not replace the chain’s ECDSA signatures today.",
"sections": []map[string]any{
{
"id": "layers",
"title": "Two layers: chain signatures vs PQ attestations",
"body": []string{
"**On-chain spend authorization** is still the usual Dogecoin-style ECDSA signature over the sighash, using your P2PKH key. The `such -c sign` command in this wallet performs that ECDSA signing step.",
"**Post-quantum (PQ) keys** (e.g. Falcon-512 generated with `such -c falcon_keygen`) are separate material. Phase-1 commitment format is canonical tagged OP_RETURN: `6a24 + TAG4 + 32-byte commitment` where TAG4 is `FLC1`, `DIL2`, or `RCG4`.",
"So: **spending DOGE** = ECDSA. **PQ** = additional proof or attestation layer described in libdogecoin experiments, not a drop-in replacement for ECDSA on the base layer.",
},
},
{
"id": "send_flow",
"title": "How a payment is created and sent (conceptually)",
"body": []string{
"1. You choose inputs (UTXOs) and outputs (recipient + change). That yields an **unsigned** raw transaction hex.",
"2. For each input you must sign with the private key that locks that input, here that is **`such -c sign`** with your WIF and the correct scriptPubKey for your address.",
"3. The result is a **signed** raw hex, valid under Dogecoin’s consensus rules.",
"4. **Broadcast** propagates the signed tx to the peer-to-peer network so miners can include it. This pup uses **`sendtx`** (libdogecoin), the same *shape* as **Dogecoin Wallet** on Android: **bitcoinj** relays over **P2P** (`TransactionBroadcast`), not **`sendrawtransaction`** to a local Core RPC.",
},
},
{
"id": "verify",
"title": "What “verification” means here",
"body": []string{
"**Consensus verification** happens on every node: ECDSA signatures must validate, scripts must succeed, balances must add up.",
"**PQ verification in this UI** checks canonical Phase-1 OP_RETURN commitment layout and tags (`FLC1`/`DIL2`/`RCG4`). Full cryptographic proof still requires verifier material (`pubkey || signature`) and signature validation flow.",
"A serious audit would decode the transaction, parse outputs, and verify commitments against published PQ keys and schemes, beyond what this lightweight wallet does automatically.",
},
},
{
"id": "tx_c_tx_r",
"title": "TX_C and TX_R (libdogecoin carrier flow)",
"body": []string{
"See **libdogecoin** `such` PQC tools (Falcon-512, carrier helpers): https://github.com/edtubbs/libdogecoin/blob/0.1.5-dev-pqc-carrier/doc/tools.md#falcon-512-post-quantum-cryptography-pqc-commands — keygen, **falcon_sign**, **falcon_commit**, **falcon_add_commit_and_carrier_tx**, and **pqc_carrier_** helpers.",
"**TX_C (commitment tx):** standard spends + **`OP_RETURN`** with `6a24` + **TAG4** (`FLC1` for Falcon-512) + **32-byte commitment**, where **commitment = SHA256(pqc_public_key ‖ pqc_signature)** over the message you signed (commonly **tx sighash32**). Optionally adds the **canonical P2SH carrier** output (fixed redeemScript `OP_DROP×5 OP_TRUE`) so DOGE sits in a known script until reveal.",
"**TX_R (reveal tx):** spends the carrier **P2SH**; the **full public key and signature** are embedded in **`scriptSig`** using the tagged carrier layout (**`FLC1FULL`** header + chunked pushes, per `pqc_carrier_mkpart` / `pqc_carrier_parsepart` in that doc).",
"**Dogecoin Core (PQC build)** can show the same linkage: TX_C commitment, carrier indices, TX_R txid, `SHA256(pk‖sig)` vs commitment, and `OQS_SIG_verify` — this wallet UI focuses on building/broadcasting compatible txs and educational checks; deep on-chain PQ panels mirror **Quantum Explorer** (carrier match uses a configurable block lookback for TX_C vs TX_R, not only same-block).",
},
},
{
"id": "this_pup",
"title": "What this pup actually runs",
"body": []string{
"Binaries on PATH: **`such`**, **`sendtx`**, **`spvnode`**, built with **USE_LIBOQS** for Falcon/Dilithium support in libdogecoin.",
"**Broadcast** uses **`sendtx`** (P2P) only — aligned with Dogecoin Wallet’s P2P broadcast model, not Core JSON-RPC.",
"**SPV** (`spvnode`) follows headers and BIP37-watches **all addresses** in your wallet. **Pending** amounts use the embedded **Memepool Tracker** (data under `mempooltracker/`). The dashboard charts mempool relay activity over the last 24 hours.",
},
},
},
"references": []string{
"https://github.com/dogecoinfoundation/libdogecoin/pull/294",
"https://github.com/dogecoinfoundation/libdogecoin",
},
"flow_lead": "You start with a normal Dogecoin payment (ECDSA), then optionally attach PQ-related data. Each step builds on the last.",
"flow": []map[string]string{
{
"step": "1",
"name": "Build and sign like any Dogecoin tx",
"detail": "Pick UTXOs, set outputs, then sign inputs with **ECDSA** (`such -c sign`). Until this step succeeds, nothing is valid on-chain.",
},
{
"step": "2",
"name": "Canonical commitment output (TX_C)",
"detail": "Add canonical Phase-1 OP_RETURN commitment: **`6a24 + TAG4 + commitment32`** (`FLC1`, `DIL2`, `RCG4`).",
},
{
"step": "3",
"name": "Carrier output and reveal (TX_R), if needed",
"detail": "Some flows lock a little value in a **carrier** output, then spend it in **TX_R** to reveal more PQ data and recover funds minus fees.",
},
{
"step": "4",
"name": "Broadcast with P2P sendtx",
"detail": "Submit the **signed** hex with **`sendtx`** so Dogecoin peers relay it over P2P (same idea as Dogecoin Wallet’s **TransactionBroadcast**).",
},
},
"libdogecoin_build": "This pup ships such/sendtx/spvnode built with -DUSE_LIBOQS=ON (Falcon-512, Dilithium2).",
}
}
package main
import (
"crypto/rand"
"crypto/sha256"
"fmt"
"strings"
"time"
"github.com/btcsuite/btcd/btcutil/base58"
bip32 "github.com/tyler-smith/go-bip32"
)
const (
hdModeBIP44Doge = "bip44-dogecoin-v1"
hardOffset = 0x80000000
)
func base58CheckEncode(version byte, payload []byte) string {
data := append([]byte{version}, payload...)
h1 := sha256.Sum256(data)
h2 := sha256.Sum256(h1[:])
full := append(data, h2[:4]...)
return base58.Encode(full)
}
func dogeWIFFromPriv(priv32 []byte, testnet bool) string {
version := byte(0x9e)
if testnet {
version = 0xf1
}
b := append([]byte{}, priv32...)
// Use compressed key form so libdogecoin emits compressed pubkey/address.
b = append(b, 0x01)
return base58CheckEncode(version, b)
}
func deriveBIP44ChildKey(master *bip32.Key, index uint32) (*bip32.Key, string, error) {
purpose, err := master.NewChildKey(uint32(44) + hardOffset)
if err != nil {
return nil, "", err
}
coin, err := purpose.NewChildKey(uint32(3) + hardOffset) // Dogecoin SLIP-44 coin type
if err != nil {
return nil, "", err
}
acct, err := coin.NewChildKey(0 + hardOffset)
if err != nil {
return nil, "", err
}
chg, err := acct.NewChildKey(0)
if err != nil {
return nil, "", err
}
child, err := chg.NewChildKey(index)
if err != nil {
return nil, "", err
}
path := fmt.Sprintf("m/44'/3'/0'/0/%d", index)
return child, path, nil
}
func (s *Server) deriveHDWalletAddress(wf *WalletFile, index int) (WalletAddress, error) {
if wf == nil || !wf.isHDWallet() {
return WalletAddress{}, fmt.Errorf("wallet is not HD-enabled")
}
if index < 0 {
return WalletAddress{}, fmt.Errorf("invalid derive index")
}
master, err := bip32.B58Deserialize(strings.TrimSpace(wf.HDMasterXPrv))
if err != nil {
return WalletAddress{}, fmt.Errorf("decode hd master: %w", err)
}
child, path, err := deriveBIP44ChildKey(master, uint32(index))
if err != nil {
return WalletAddress{}, fmt.Errorf("derive child key: %w", err)
}
testnet := strings.EqualFold(wf.Network, "testnet")
wif := dogeWIFFromPriv(child.Key, testnet)
pubHex, addr, err := s.runSuchPubKeyFromWIF(wif, testnet)
if err != nil {
return WalletAddress{}, err
}
return WalletAddress{
ID: newAddressID(),
Label: fmt.Sprintf("Receive #%d", index+1),
P2PKH: strings.TrimSpace(addr),
WIF: strings.TrimSpace(wif),
PubHex: strings.TrimSpace(pubHex),
CreatedAt: time.Now().UTC(),
Primary: false,
HDPath: path,
DeriveIndex: index,
}, nil
}
func (s *Server) initHDWallet(testnet bool) (*WalletFile, error) {
seed := make([]byte, 32)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
master, err := bip32.NewMasterKey(seed)
if err != nil {
return nil, err
}
network := "mainnet"
if testnet {
network = "testnet"
}
now := time.Now().UTC()
w := &WalletFile{
Version: 2,
CreatedAt: now,
Network: network,
PQScheme: "Falcon-512 / Dilithium2 / Raccoon-G (liboqs via libdogecoin, experimental)",
PQSource: "none",
PQNotes: "Post-quantum proofs attach to ordinary Dogecoin transactions: TX_C adds an OP_RETURN " +
"commitment; an optional 1-DOGE carrier output can be spent in TX_R to reveal the full PQ " +
"public key and signature on-chain. Standard P2PKH keys above fund and control DOGE; PQ " +
"material is additional attestation per Dogecoin Foundation experiments.",
LibdogecoinSPV: "Bundled spvnode: headers + BIP37 watch for every address in the wallet. Starts after wallet creation when SPVNODE_ENABLE=1. " +
"Check GET /api/spv/status and /api/transactions.",
ExperimentalDiscl: "Experimental research software. You may lose funds. Back up your WIF. " +
"PQ proofs on mainnet are early-phase; verify any third-party tooling.",
HDMode: hdModeBIP44Doge,
HDMasterXPrv: master.B58Serialize(),
HDMasterXPub: master.PublicKey().B58Serialize(),
HDNextIndex: 0,
}
a0, err := s.deriveHDWalletAddress(w, 0)
if err != nil {
return nil, err
}
a0.Primary = true
a0.Label = "Primary"
w.Addresses = []WalletAddress{a0}
w.HDNextIndex = 1
w.syncLegacyFromPrimary()
return w, nil
}
func (s *Server) appendNextHDDerivedAddress(wf *WalletFile, makePrimary bool) (WalletAddress, error) {
if wf == nil || !wf.isHDWallet() {
return WalletAddress{}, fmt.Errorf("wallet is not HD-enabled")
}
if wf.HDNextIndex < 0 {
wf.HDNextIndex = len(wf.Addresses)
}
a, err := s.deriveHDWalletAddress(wf, wf.HDNextIndex)
if err != nil {
return WalletAddress{}, err
}
wf.HDNextIndex++
wf.Addresses = append(wf.Addresses, a)
if makePrimary {
for i := range wf.Addresses {
wf.Addresses[i].Primary = wf.Addresses[i].ID == a.ID
}
wf.syncLegacyFromPrimary()
}
return a, nil
}
func (s *Server) maybeRotateHDReceiveAddress(wf *WalletFile, st *WalletState) (bool, error) {
if wf == nil || st == nil || !wf.isHDWallet() {
return false, nil
}
p := wf.PrimaryAddress()
if p == nil {
return false, nil
}
currentAddr := strings.TrimSpace(p.P2PKH)
if currentAddr == "" {
return false, nil
}
latestIncomingTxID := ""
for _, tx := range st.Transactions {
if !strings.EqualFold(strings.TrimSpace(tx.Direction), "in") {
continue
}
if strings.TrimSpace(tx.Address) != currentAddr {
continue
}
if normalizeTxid(tx.Txid) == "" {
continue
}
if latestIncomingTxID == "" || tx.SeenAt.After(stampOfTx(st.Transactions, latestIncomingTxID)) {
latestIncomingTxID = normalizeTxid(tx.Txid)
}
}
if latestIncomingTxID == "" || latestIncomingTxID == strings.TrimSpace(wf.HDLastRotateTxID) {
return false, nil
}
if _, err := s.appendNextHDDerivedAddress(wf, true); err != nil {
return false, err
}
wf.HDLastRotateTxID = latestIncomingTxID
return true, nil
}
func stampOfTx(rows []TxRecord, txid string) time.Time {
id := normalizeTxid(txid)
for _, tx := range rows {
if normalizeTxid(tx.Txid) == id {
return tx.SeenAt
}
}
return time.Time{}
}
package main
import (
"bytes"
"encoding/binary"
"encoding/hex"
"fmt"
"os"
"strings"
)
// libdogecoin file-based headers.db (headersdb_file.c), not SQLite.
// See vendors/libdogecoin/include/dogecoin/headersdb_file.h — magic + uint32 LE version, then fixed-length records.
var libdogeHeadersFileMagic = []byte{0xA8, 0xF0, 0x11, 0xC5}
const (
libdogeHeadersFileHdrLen = 8 // magic(4) + version uint32 LE(4)
libdogeHeadersFileRecLen = 148 // hash(32) + height u32(4) + chainwork(32) + header(80)
libdogeHeadersFileVerMax = 3
)
func isLibdogecoinHeadersFileFormat(path string) bool {
b := make([]byte, libdogeHeadersFileHdrLen)
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
if _, err := f.Read(b); err != nil {
return false
}
if len(b) < libdogeHeadersFileHdrLen {
return false
}
if !bytes.Equal(b[:4], libdogeHeadersFileMagic) {
return false
}
ver := binary.LittleEndian.Uint32(b[4:8])
return ver >= 1 && ver <= libdogeHeadersFileVerMax
}
// truncateLibdogecoinHeadersFile truncates headers.db to the last full record whose height <= keepHeight.
// libdogecoin stores records in chain order with non-decreasing height; height is uint32 LE at offset 32 in each record.
func truncateLibdogecoinHeadersFile(path string, keepHeight int64) (maxKeptHeight uint32, newSize int64, err error) {
if keepHeight < 0 || keepHeight > 200_000_000 {
return 0, 0, fmt.Errorf("keep height out of range")
}
fi, err := os.Stat(path)
if err != nil {
return 0, 0, err
}
size := fi.Size()
if size < libdogeHeadersFileHdrLen {
return 0, 0, fmt.Errorf("headers file too small")
}
f, err := os.OpenFile(path, os.O_RDWR, 0600)
if err != nil {
return 0, 0, err
}
defer f.Close()
hdr := make([]byte, libdogeHeadersFileHdrLen)
if _, err := f.ReadAt(hdr, 0); err != nil {
return 0, 0, err
}
if !bytes.Equal(hdr[:4], libdogeHeadersFileMagic) {
return 0, 0, fmt.Errorf("not libdogecoin headers file magic")
}
ver := binary.LittleEndian.Uint32(hdr[4:8])
if ver < 1 || ver > libdogeHeadersFileVerMax {
return 0, 0, fmt.Errorf("unsupported headers file version %d", ver)
}
cut := int64(libdogeHeadersFileHdrLen)
off := int64(libdogeHeadersFileHdrLen)
rec := make([]byte, libdogeHeadersFileRecLen)
var maxKept uint32
var firstHeight *uint32
for off+int64(libdogeHeadersFileRecLen) <= size {
n, rerr := f.ReadAt(rec, off)
if rerr != nil || n < libdogeHeadersFileRecLen {
break
}
h := binary.LittleEndian.Uint32(rec[32:36])
if firstHeight == nil {
tmp := h
firstHeight = &tmp
}
if int64(h) > keepHeight {
break
}
cut = off + int64(libdogeHeadersFileRecLen)
maxKept = h
off += int64(libdogeHeadersFileRecLen)
}
if firstHeight == nil {
if size <= int64(libdogeHeadersFileHdrLen) {
return 0, 0, fmt.Errorf("headers.db has no header records (only file header)")
}
return 0, 0, fmt.Errorf("headers.db is incomplete or corrupt (expected %d-byte records after %d-byte header)", libdogeHeadersFileRecLen, libdogeHeadersFileHdrLen)
}
if int64(*firstHeight) > keepHeight {
return 0, 0, fmt.Errorf("on-disk header chain starts at height %d; cannot roll back to %d (remove headers.db with Full SPV rescan, or choose rollback_height >= %d)", *firstHeight, keepHeight, *firstHeight)
}
if cut < int64(libdogeHeadersFileHdrLen) {
return 0, 0, fmt.Errorf("invalid truncate offset")
}
if err := f.Truncate(cut); err != nil {
return 0, 0, err
}
return maxKept, cut, nil
}
// libdogecoinHeadersFileFirstHeight returns the height field of the first stored header record (after the file header).
func libdogecoinHeadersFileFirstHeight(path string) (uint32, error) {
fi, err := os.Stat(path)
if err != nil {
return 0, err
}
size := fi.Size()
if size < int64(libdogeHeadersFileHdrLen)+int64(libdogeHeadersFileRecLen) {
return 0, fmt.Errorf("headers.db has no full header records")
}
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
rec := make([]byte, libdogeHeadersFileRecLen)
n, err := f.ReadAt(rec, int64(libdogeHeadersFileHdrLen))
if err != nil || n < libdogeHeadersFileRecLen {
return 0, fmt.Errorf("headers.db read first record: %w", err)
}
return binary.LittleEndian.Uint32(rec[32:36]), nil
}
// libdogecoinHeadersFileTipHeight returns the largest header height stored in a libdogecoin file-based headers.db.
func libdogecoinHeadersFileTipHeight(path string) (int64, error) {
fi, err := os.Stat(path)
if err != nil {
return 0, err
}
size := fi.Size()
if size < int64(libdogeHeadersFileHdrLen)+int64(libdogeHeadersFileRecLen) {
return 0, nil
}
f, err := os.Open(path)
if err != nil {
return 0, err
}
defer f.Close()
off := int64(libdogeHeadersFileHdrLen)
rec := make([]byte, libdogeHeadersFileRecLen)
var maxH uint32
for off+int64(libdogeHeadersFileRecLen) <= size {
n, rerr := f.ReadAt(rec, off)
if rerr != nil || n < libdogeHeadersFileRecLen {
break
}
h := binary.LittleEndian.Uint32(rec[32:36])
if h > maxH {
maxH = h
}
off += int64(libdogeHeadersFileRecLen)
}
return int64(maxH), nil
}
// libdogecoinHeaderHashHexAtHeight returns lowercase hex of the 32-byte block hash field for the record at height, or "".
func libdogecoinHeaderHashHexAtHeight(path string, height int64) string {
if height < 0 || height > 200_000_000 {
return ""
}
want := uint32(height)
fi, err := os.Stat(path)
if err != nil {
return ""
}
size := fi.Size()
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
off := int64(libdogeHeadersFileHdrLen)
rec := make([]byte, libdogeHeadersFileRecLen)
for off+int64(libdogeHeadersFileRecLen) <= size {
n, rerr := f.ReadAt(rec, off)
if rerr != nil || n < libdogeHeadersFileRecLen {
break
}
h := binary.LittleEndian.Uint32(rec[32:36])
if h == want {
return strings.ToLower(hex.EncodeToString(rec[0:32]))
}
off += int64(libdogeHeadersFileRecLen)
}
return ""
}
// libdogecoinHeader80HexAtHeight returns lowercase hex of the 80-byte block header stored in the record at height, or "".
func libdogecoinHeader80HexAtHeight(path string, height int64) string {
if height < 0 || height > 200_000_000 {
return ""
}
want := uint32(height)
fi, err := os.Stat(path)
if err != nil {
return ""
}
size := fi.Size()
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
off := int64(libdogeHeadersFileHdrLen)
rec := make([]byte, libdogeHeadersFileRecLen)
const headerOff = 32 + 4 + 32 // after hash, height, chainwork
for off+int64(libdogeHeadersFileRecLen) <= size {
n, rerr := f.ReadAt(rec, off)
if rerr != nil || n < libdogeHeadersFileRecLen {
break
}
h := binary.LittleEndian.Uint32(rec[32:36])
if h == want {
return strings.ToLower(hex.EncodeToString(rec[headerOff : headerOff+80]))
}
off += int64(libdogeHeadersFileRecLen)
}
return ""
}
package main
import (
"encoding/binary"
"os"
"path/filepath"
"testing"
)
func TestTruncateLibdogecoinHeadersFile(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "headers.db")
f, err := os.Create(p)
if err != nil {
t.Fatal(err)
}
_, _ = f.Write(libdogeHeadersFileMagic)
_ = binary.Write(f, binary.LittleEndian, uint32(3))
rec := make([]byte, libdogeHeadersFileRecLen)
binary.LittleEndian.PutUint32(rec[32:36], 0)
if _, err := f.Write(rec); err != nil {
t.Fatal(err)
}
rec2 := make([]byte, libdogeHeadersFileRecLen)
binary.LittleEndian.PutUint32(rec2[32:36], 100)
if _, err := f.Write(rec2); err != nil {
t.Fatal(err)
}
_ = f.Close()
maxK, sz, err := truncateLibdogecoinHeadersFile(p, 50)
if err != nil {
t.Fatal(err)
}
if maxK != 0 {
t.Fatalf("maxKept=%d want 0", maxK)
}
if sz != libdogeHeadersFileHdrLen+libdogeHeadersFileRecLen {
t.Fatalf("size=%d", sz)
}
st, _ := os.Stat(p)
if st.Size() != sz {
t.Fatalf("file size %d != %d", st.Size(), sz)
}
}
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
var (
reSuchFalconPub = regexp.MustCompile(`(?i)public key:\s*([0-9a-f]+)`)
reSuchFalconSec = regexp.MustCompile(`(?i)secret key:\s*([0-9a-f]+)`)
reSuchSignedTx = regexp.MustCompile(`(?i)signed TX:\s*([0-9a-f]+)`)
reSuchPrivWIF = regexp.MustCompile(`(?i)private key wif:\s*(\S+)`)
reSuchPubKeyHex = regexp.MustCompile(`(?i)public key hex:\s*([0-9a-f]+)`)
reSuchP2PKHAddr = regexp.MustCompile(`(?i)p2pkh address:\s*(\S+)`)
reSuchAnyHexValue = regexp.MustCompile(`(?i)\b([0-9a-f]{64,})\b`)
reSuchUTXOLine = regexp.MustCompile(`(?i)\btxid[=: ]+([a-f0-9]{64})\b.*?\bvout[=: ]+(\d+)\b.*?\b(?:value|amount|koinu|satoshis)[=: ]+(-?\d+(?:\.\d+)?)`)
reSendtxStartTxid = regexp.MustCompile(`(?i)start broadcasting transaction:\s*([a-f0-9]{64})`)
reSuchFlexibleTxHex = regexp.MustCompile(`(?i)(?:signed|unsigned|modified)\s+TX\s*:\s*([0-9a-f]+)`)
// falcon_add_commit_and_carrier_tx prints these before multi-kilobyte carrier_part_scriptsig lines — never use longest-hex
// fallback across the full buffer or we mis-parse scriptsig as a raw tx and such sign returns "Invalid tx hex".
reSuchTxCommitCarrier = regexp.MustCompile(`(?i)tx with commitment and carrier outputs:\s*([0-9a-f]+)`)
reSuchTxCommitOnly = regexp.MustCompile(`(?i)tx with commitment:\s*([0-9a-f]+)`)
// set_scriptsig prints: "tx with scriptsig set: <hex>\n" (such.c)
reSuchTxScriptSigSet = regexp.MustCompile(`(?i)tx with scriptsig set:\s*([0-9a-f]+)`)
reSuchCarrierSPK = regexp.MustCompile(`(?i)carrier_p2sh_scriptpubkey:\s*([0-9a-f]+)`)
// Accept both historical forms emitted by such:
// carrier_part_scriptsig[0]: <hex>
// carrier_part_scriptsig: <hex>
reSuchCarrierMkSig = regexp.MustCompile(`(?i)carrier_part_scriptsig(?:\[(\d+)\])?\s*:\s*([0-9a-f]+)`)
reSuchCarrierPartIndex = regexp.MustCompile(`(?i)carrier_part_index\s*:\s*(\d+)`)
reSuchLongHex = regexp.MustCompile(`\b([0-9a-f]{200,})\b`)
)
// runSuchP2PKHWallet runs `such -c generate_private_key` then `such -c generate_public_key -p <WIF>` (libdogecoin ECC + base58).
func (s *Server) runSuchP2PKHWallet(testnet bool) (wif, pubHex, p2pkh string, err error) {
args := []string{"-c", "generate_private_key"}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", "", "", fmt.Errorf("such generate_private_key: %w — %s", err, truncateStr(out.String(), 800))
}
text := out.String()
m := reSuchPrivWIF.FindStringSubmatch(text)
if len(m) < 2 {
return "", "", "", fmt.Errorf("parse private key wif from such: %s", truncateStr(text, 1000))
}
wif = strings.TrimSpace(m[1])
args2 := []string{"-c", "generate_public_key", "-p", wif}
if testnet {
args2 = append([]string{"-t"}, args2...)
}
ctx2, cancel2 := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel2()
cmd2 := exec.CommandContext(ctx2, s.suchPath(), args2...)
var out2 bytes.Buffer
cmd2.Stdout = &out2
cmd2.Stderr = &out2
if err := cmd2.Run(); err != nil {
return "", "", "", fmt.Errorf("such generate_public_key: %w — %s", err, truncateStr(out2.String(), 800))
}
t2 := out2.String()
mp := reSuchPubKeyHex.FindStringSubmatch(t2)
ma := reSuchP2PKHAddr.FindStringSubmatch(t2)
if len(mp) < 2 || len(ma) < 2 {
return "", "", "", fmt.Errorf("parse pubkey/address from such: %s", truncateStr(t2, 1000))
}
return wif, strings.TrimSpace(mp[1]), strings.TrimSpace(ma[1]), nil
}
// runSuchPubKeyFromWIF runs `such -c generate_public_key -p <WIF>` and returns pubkey hex + P2PKH address.
func (s *Server) runSuchPubKeyFromWIF(wif string, testnet bool) (pubHex, p2pkh string, err error) {
wif = strings.TrimSpace(wif)
if wif == "" {
return "", "", fmt.Errorf("empty WIF")
}
args := []string{"-c", "generate_public_key", "-p", wif}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", "", fmt.Errorf("such generate_public_key: %w — %s", err, truncateStr(out.String(), 800))
}
t := out.String()
mp := reSuchPubKeyHex.FindStringSubmatch(t)
ma := reSuchP2PKHAddr.FindStringSubmatch(t)
if len(mp) < 2 || len(ma) < 2 {
return "", "", fmt.Errorf("parse pubkey/address from such: %s", truncateStr(t, 1000))
}
return strings.TrimSpace(mp[1]), strings.TrimSpace(ma[1]), nil
}
// p2pkhScriptPubKeyHexForSign returns scriptPubKey hex for `such -c sign -s` using libdogecoin-derived pubkey.
func (s *Server) p2pkhScriptPubKeyHexForSign(wf *WalletFile) (string, error) {
p := wf.PrimaryAddress()
if p == nil {
return "", fmt.Errorf("no primary address")
}
testnet := strings.EqualFold(wf.Network, "testnet")
pub := strings.TrimSpace(p.PubHex)
if pub == "" {
var err error
pub, _, err = s.runSuchPubKeyFromWIF(p.WIF, testnet)
if err != nil {
return "", err
}
}
return p2pkhScriptPubKeyHexFromCompressedPubKeyHex(pub)
}
// libdogecoinVendoredToolCandidates returns possible paths for such/sendtx/spvnode shipped next to the
// service or under pq-wallet/vendors (PQ_LIBDOGECOIN_BIN, vendors/bin, or a local cmake build tree).
func libdogecoinVendoredToolCandidates(tool string) []string {
ext := ""
if runtime.GOOS == "windows" {
ext = ".exe"
}
fn := tool + ext
var out []string
if d := strings.TrimSpace(os.Getenv("PQ_LIBDOGECOIN_BIN")); d != "" {
out = append(out, filepath.Join(d, fn))
}
addRoots := func(root string) {
if root == "" {
return
}
out = append(out,
filepath.Join(root, "vendors", "bin", fn),
filepath.Join(root, "vendors", "libdogecoin", "build", fn),
filepath.Join(root, "vendors", "libdogecoin", "build", "Release", fn),
)
}
if ex, err := os.Executable(); err == nil && ex != "" {
dir := filepath.Dir(ex)
addRoots(dir)
addRoots(filepath.Clean(filepath.Join(dir, "..")))
addRoots(filepath.Clean(filepath.Join(dir, "..", "..")))
}
if wd, err := os.Getwd(); err == nil {
addRoots(wd)
addRoots(filepath.Clean(filepath.Join(wd, "..")))
addRoots(filepath.Clean(filepath.Join(wd, "..", "..")))
}
return out
}
func firstExistingFile(paths []string) string {
for _, p := range paths {
p = filepath.Clean(p)
if st, err := os.Stat(p); err == nil && !st.IsDir() && st.Size() > 0 {
return p
}
}
return ""
}
func (s *Server) suchPath() string {
if p := strings.TrimSpace(os.Getenv("LIBDOGECOIN_SUCH")); p != "" {
return p
}
if p := firstExistingFile(libdogecoinVendoredToolCandidates("such")); p != "" {
return p
}
if p, err := exec.LookPath("such"); err == nil {
return p
}
return "such"
}
func (s *Server) sendtxPath() string {
if p := strings.TrimSpace(os.Getenv("LIBDOGECOIN_SENDTX")); p != "" {
return p
}
if p := firstExistingFile(libdogecoinVendoredToolCandidates("sendtx")); p != "" {
return p
}
if p, err := exec.LookPath("sendtx"); err == nil {
return p
}
return "sendtx"
}
func (s *Server) spvnodePath() string {
if p := strings.TrimSpace(os.Getenv("LIBDOGECOIN_SPVNODE")); p != "" {
return p
}
if p := firstExistingFile(libdogecoinVendoredToolCandidates("spvnode")); p != "" {
return p
}
if p, err := exec.LookPath("spvnode"); err == nil {
return p
}
return "spvnode"
}
// runSuchLibOqsKeygen runs `such -c <cmd>` (falcon_keygen, dilithium2_keygen, raccoong_keygen, …) and parses public/secret hex from stdout.
func (s *Server) runSuchLibOqsKeygen(cmd string, testnet bool) (pubHex, privHex string, err error) {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return "", "", fmt.Errorf("empty liboqs keygen command")
}
args := []string{"-c", cmd}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
execCmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
execCmd.Stdout = &out
execCmd.Stderr = &out
if err := execCmd.Run(); err != nil {
return "", "", fmt.Errorf("such %s: %w — output: %s", cmd, err, truncateStr(out.String(), 800))
}
text := out.String()
m1 := reSuchFalconPub.FindStringSubmatch(text)
m2 := reSuchFalconSec.FindStringSubmatch(text)
if len(m1) < 2 || len(m2) < 2 {
return "", "", fmt.Errorf("could not parse %s output: %s", cmd, truncateStr(text, 1200))
}
return m1[1], m2[1], nil
}
// runSuchFalconKeygen runs `such -c falcon_keygen` and parses Falcon-512 hex keys from stdout.
func (s *Server) runSuchFalconKeygen(testnet bool) (pubHex, privHex string, err error) {
return s.runSuchLibOqsKeygen("falcon_keygen", testnet)
}
func (s *Server) runSuchDilithium2Keygen(testnet bool) (pubHex, privHex string, err error) {
return s.runSuchLibOqsKeygen("dilithium2_keygen", testnet)
}
func (s *Server) runSuchRaccoonKeygen(testnet bool) (pubHex, privHex string, err error) {
return s.runSuchLibOqsKeygen("raccoong_keygen", testnet)
}
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "…"
}
// runSuchSign runs `such -c sign` and returns signed raw transaction hex.
func (s *Server) runSuchSign(rawHex, scriptPubHex, wif string, inputIndex, sighashType int, testnet bool) (string, error) {
args := []string{
"-c", "sign",
"-x", rawHex,
"-s", scriptPubHex,
"-i", strconv.Itoa(inputIndex),
"-h", strconv.Itoa(sighashType),
"-p", wif,
}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("such sign: %w — %s", err, truncateStr(out.String(), 800))
}
m := reSuchSignedTx.FindStringSubmatch(out.String())
if len(m) < 2 {
return "", fmt.Errorf("signed TX not found in such output: %s", truncateStr(out.String(), 1200))
}
return m[1], nil
}
func (s *Server) runSuchTxSighash32(rawHex, scriptPubHex string, inputIndex, hashType int, testnet bool) (string, error) {
args := []string{
"-c", "tx_sighash32",
"-x", strings.TrimSpace(rawHex),
"-s", strings.TrimSpace(scriptPubHex),
"-i", strconv.Itoa(inputIndex),
"-h", strconv.Itoa(hashType),
}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("such tx_sighash32: %w — %s", err, truncateStr(out.String(), 800))
}
m := reSuchAnyHexValue.FindStringSubmatch(out.String())
if len(m) < 2 {
return "", fmt.Errorf("sighash32 not found in such output: %s", truncateStr(out.String(), 1200))
}
h := strings.ToLower(strings.TrimSpace(m[1]))
if len(h) < 64 {
return "", fmt.Errorf("invalid sighash length from such")
}
return h[:64], nil
}
func (s *Server) runSuchPQSign(kind pqAlgoKind, msgHex, privHex string, testnet bool) (string, error) {
sc := suchSignCmd(kind)
args := []string{
"-c", sc,
"-x", strings.TrimSpace(msgHex),
"-p", strings.TrimSpace(privHex),
}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("such %s: %w — %s", sc, err, truncateStr(out.String(), 800))
}
matches := reSuchAnyHexValue.FindAllStringSubmatch(out.String(), -1)
if len(matches) == 0 {
return "", fmt.Errorf("%s: signature hex not found in such output: %s", sc, truncateStr(out.String(), 1200))
}
best := ""
for _, m := range matches {
if len(m) < 2 {
continue
}
if len(m[1]) > len(best) {
best = m[1]
}
}
if best == "" {
return "", fmt.Errorf("%s: signature parse failed", sc)
}
return strings.ToLower(strings.TrimSpace(best)), nil
}
func (s *Server) runSuchFalconSign(msgHex, privHex string, testnet bool) (string, error) {
return s.runSuchPQSign(pqAlgoFalcon, msgHex, privHex, testnet)
}
// parseSuchTransactionHex extracts raw transaction hex from such stdout (sign / set_scriptsig / falcon_add_*).
func parseSuchTransactionHex(text string) string {
text = strings.TrimSpace(text)
if m := reSuchTxCommitCarrier.FindStringSubmatch(text); len(m) >= 2 && len(m[1]) >= 120 {
return strings.ToLower(m[1])
}
if m := reSuchTxCommitOnly.FindStringSubmatch(text); len(m) >= 2 && len(m[1]) >= 120 {
return strings.ToLower(m[1])
}
if m := reSuchTxScriptSigSet.FindStringSubmatch(text); len(m) >= 2 && len(m[1]) >= 120 {
return strings.ToLower(m[1])
}
if m := reSuchFlexibleTxHex.FindStringSubmatch(text); len(m) >= 2 && len(m[1]) >= 120 {
return strings.ToLower(m[1])
}
if m := reSuchSignedTx.FindStringSubmatch(text); len(m) >= 2 && len(m[1]) >= 120 {
return strings.ToLower(m[1])
}
best := ""
for _, ln := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
low := strings.ToLower(strings.TrimSpace(ln))
// Skip carrier_part_scriptsig / scriptsig dumps — but not the summary line from set_scriptsig.
if strings.Contains(low, "scriptsig") && !strings.Contains(low, "tx with scriptsig set:") {
continue
}
for _, m := range reSuchLongHex.FindAllStringSubmatch(ln, -1) {
if len(m) >= 2 && len(m[1]) > len(best) {
best = strings.ToLower(m[1])
}
}
}
return best
}
func (s *Server) runSuchAddCommitAndCarrierTx(kind pqAlgoKind, unsignedHex, commit32Hex, pubHex, sigHex string, carrierKoinu int64, testnet bool) (string, error) {
unsignedHex = strings.TrimSpace(unsignedHex)
commit32Hex = strings.TrimSpace(commit32Hex)
pubHex = strings.TrimSpace(pubHex)
sigHex = strings.TrimSpace(sigHex)
cc := suchAddCommitCarrierCmd(kind)
if unsignedHex == "" || commit32Hex == "" || pubHex == "" || sigHex == "" {
return "", fmt.Errorf("missing %s argument", cc)
}
if carrierKoinu <= 0 {
carrierKoinu = 100_000_000
}
args := []string{
"-c", cc,
"-x", unsignedHex,
"-m", commit32Hex,
"-k", pubHex,
"-s", sigHex,
"-h", strconv.FormatInt(carrierKoinu, 10),
}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("such %s: %w — %s", cc, err, truncateStr(out.String(), 800))
}
h := parseSuchTransactionHex(out.String())
if h == "" {
return "", fmt.Errorf("%s: no transaction hex in output: %s", cc, truncateStr(out.String(), 1200))
}
return h, nil
}
func (s *Server) runSuchFalconAddCommitAndCarrierTx(unsignedHex, commit32Hex, pubHex, sigHex string, carrierKoinu int64, testnet bool) (string, error) {
return s.runSuchAddCommitAndCarrierTx(pqAlgoFalcon, unsignedHex, commit32Hex, pubHex, sigHex, carrierKoinu, testnet)
}
func (s *Server) runSuchPqcCarrierScriptPubkey(testnet bool) (string, error) {
args := []string{"-c", "pqc_carrier_scriptpubkey"}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("such pqc_carrier_scriptpubkey: %w — %s", err, truncateStr(out.String(), 600))
}
if m := reSuchCarrierSPK.FindStringSubmatch(out.String()); len(m) >= 2 && len(m[1]) >= 4 {
return strings.ToLower(strings.TrimSpace(m[1])), nil
}
return "", fmt.Errorf("pqc_carrier_scriptpubkey: parse failed: %s", truncateStr(out.String(), 800))
}
func (s *Server) runSuchPqcCarrierMkpart(tag4Hex, pubHex, sigHex string, partIndex int, testnet bool) (string, error) {
tag4Hex = strings.TrimSpace(tag4Hex)
pubHex = strings.TrimSpace(pubHex)
sigHex = strings.TrimSpace(sigHex)
if tag4Hex == "" || pubHex == "" || sigHex == "" {
return "", fmt.Errorf("missing pqc_carrier_mkpart argument")
}
args := []string{
"-c", "pqc_carrier_mkpart",
"-k", tag4Hex,
"-p", pubHex,
"-s", sigHex,
"-i", strconv.Itoa(partIndex),
}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("such pqc_carrier_mkpart: %w — %s", err, truncateStr(out.String(), 800))
}
txt := out.String()
if m := reSuchCarrierMkSig.FindStringSubmatch(txt); len(m) >= 3 {
// Indexed format: carrier_part_scriptsig[<n>]:
if strings.TrimSpace(m[1]) != "" {
if pi, err := strconv.Atoi(strings.TrimSpace(m[1])); err == nil && pi == partIndex {
return strings.ToLower(strings.TrimSpace(m[2])), nil
}
} else {
// Unindexed format: carrier_part_scriptsig:
// validate against carrier_part_index when available; otherwise accept as requested part.
if mi := reSuchCarrierPartIndex.FindStringSubmatch(txt); len(mi) >= 2 {
if pi, err := strconv.Atoi(strings.TrimSpace(mi[1])); err == nil && pi == partIndex {
return strings.ToLower(strings.TrimSpace(m[2])), nil
}
} else {
return strings.ToLower(strings.TrimSpace(m[2])), nil
}
}
}
return "", fmt.Errorf("pqc_carrier_mkpart: parse failed: %s", truncateStr(txt, 1200))
}
func (s *Server) runSuchSetScriptSig(rawHex string, vin int, scriptSigHex string, testnet bool) (string, error) {
rawHex = strings.TrimSpace(rawHex)
scriptSigHex = strings.TrimSpace(scriptSigHex)
if rawHex == "" || scriptSigHex == "" {
return "", fmt.Errorf("empty set_scriptsig argument")
}
args := []string{
"-c", "set_scriptsig",
"-x", rawHex,
"-i", strconv.Itoa(vin),
"-s", scriptSigHex,
}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("such set_scriptsig: %w — %s", err, truncateStr(out.String(), 800))
}
h := parseSuchTransactionHex(out.String())
if h == "" {
return "", fmt.Errorf("set_scriptsig: no transaction hex in output: %s", truncateStr(out.String(), 1200))
}
return h, nil
}
// runSuchSetScriptSigMulti applies set_scriptsig for vin 0..len(sigs)-1 in order.
func (s *Server) runSuchSetScriptSigMulti(rawHex string, sigs []string, testnet bool) (string, error) {
h := strings.TrimSpace(rawHex)
for i, sg := range sigs {
var err error
h, err = s.runSuchSetScriptSig(h, i, sg, testnet)
if err != nil {
return "", fmt.Errorf("set_scriptsig vin %d: %w", i, err)
}
}
return h, nil
}
// collectCarrierScriptSigs calls pqc_carrier_mkpart for part indices 0,1,... until the tool errors (single-part payloads stop at i=1).
func (s *Server) collectCarrierScriptSigs(tag4Hex, pubHex, sigHex string, testnet bool) ([]string, error) {
var out []string
for i := 0; i < 48; i++ {
sg, err := s.runSuchPqcCarrierMkpart(tag4Hex, pubHex, sigHex, i, testnet)
if err != nil {
if i == 0 {
return nil, err
}
break
}
out = append(out, sg)
}
if len(out) == 0 {
return nil, fmt.Errorf("no carrier scriptSig parts from pqc_carrier_mkpart")
}
return out, nil
}
// probeSuchPQC runs lightweight such probes (short timeout) to report whether the liboqs/PQC CLI surface is present.
func (s *Server) probeSuchPQC(ctx context.Context) map[string]any {
out := map[string]any{"such_path": s.suchPath()}
if ctx == nil {
ctx = context.Background()
}
args := []string{"-c", "pqc_carrier_scriptpubkey"}
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
txt := buf.String()
if err != nil {
out["pqc_carrier_scriptpubkey_ok"] = false
out["pqc_carrier_scriptpubkey_error"] = truncateStr(strings.TrimSpace(txt+" | "+err.Error()), 500)
} else if m := reSuchCarrierSPK.FindStringSubmatch(txt); len(m) >= 2 {
out["pqc_carrier_scriptpubkey_ok"] = true
out["carrier_p2sh_scriptpubkey_hex"] = strings.ToLower(strings.TrimSpace(m[1]))
} else {
out["pqc_carrier_scriptpubkey_ok"] = false
out["pqc_carrier_scriptpubkey_error"] = truncateStr(txt, 500)
}
// Missing-args invocation distinguishes "unknown command" (non-OQS build) from a usage / missing-parameter error.
args2 := []string{"-c", "falcon_add_commit_and_carrier_tx"}
cmd2 := exec.CommandContext(ctx, s.suchPath(), args2...)
var b2 bytes.Buffer
cmd2.Stdout = &b2
cmd2.Stderr = &b2
_ = cmd2.Run()
t2 := strings.ToLower(b2.String())
if strings.Contains(t2, "unknown command") {
out["falcon_add_commit_and_carrier_tx"] = false
} else {
out["falcon_add_commit_and_carrier_tx"] = true
}
return out
}
func (s *Server) cachedSuchPQCProbe(ctx context.Context) map[string]any {
s.suchProbeMu.Lock()
defer s.suchProbeMu.Unlock()
if s.suchProbeData != nil && time.Since(s.suchProbeAt) < 2*time.Minute {
return s.suchProbeData
}
c := ctx
if c == nil {
c = context.Background()
}
c, cancel := context.WithTimeout(c, 6*time.Second)
defer cancel()
s.suchProbeData = s.probeSuchPQC(c)
s.suchProbeAt = time.Now()
return s.suchProbeData
}
// runSuchListUnspent tries to use a native libdogecoin/such unspent query command.
// If the current such build does not support it, caller should fallback to SPV REST /getUTXOs.
func (s *Server) runSuchListUnspent(address string, testnet bool) ([]ExplorerUTXO, error) {
address = strings.TrimSpace(address)
if address == "" {
return nil, fmt.Errorf("empty address")
}
spvWalletDB := filepath.Join(s.storageDir, "spv_wallet.db")
args := []string{
"-c", "list_unspent",
"-a", address,
"-w", spvWalletDB,
}
if testnet {
args = append([]string{"-t"}, args...)
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, s.suchPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("such list_unspent unavailable: %w — %s", err, truncateStr(out.String(), 600))
}
return parseSuchListUnspentOutput(out.String())
}
func parseSuchListUnspentOutput(raw string) ([]ExplorerUTXO, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty such list_unspent output")
}
if strings.HasPrefix(raw, "{") || strings.HasPrefix(raw, "[") {
if utxos, err := parseSuchListUnspentJSON(raw); err == nil && len(utxos) > 0 {
return utxos, nil
}
}
var out []ExplorerUTXO
for _, ln := range strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
m := reSuchUTXOLine.FindStringSubmatch(ln)
if len(m) < 4 {
continue
}
txid := normalizeTxid(m[1])
if txid == "" {
continue
}
vout, err := strconv.ParseUint(strings.TrimSpace(m[2]), 10, 32)
if err != nil {
continue
}
val, err := parseValueKoinu(m[3])
if err != nil || val <= 0 {
continue
}
out = append(out, ExplorerUTXO{
TxID: txid,
Vout: uint32(vout),
Value: val,
})
}
if len(out) == 0 {
return nil, fmt.Errorf("could not parse such list_unspent output")
}
return out, nil
}
func parseSuchListUnspentJSON(raw string) ([]ExplorerUTXO, error) {
var anyRoot any
if err := json.Unmarshal([]byte(raw), &anyRoot); err != nil {
return nil, err
}
candidates := []any{anyRoot}
if m, ok := anyRoot.(map[string]any); ok {
for _, k := range []string{"utxos", "unspent", "rows", "data"} {
if v, ok := m[k]; ok {
candidates = append(candidates, v)
}
}
}
var out []ExplorerUTXO
for _, c := range candidates {
arr, ok := c.([]any)
if !ok {
continue
}
for _, item := range arr {
m, ok := item.(map[string]any)
if !ok {
continue
}
txid := normalizeTxid(jsonStringAny(m["txid"]))
if txid == "" {
txid = normalizeTxid(jsonStringAny(m["tx_hash"]))
}
if txid == "" {
continue
}
vout := uint32(parseIntDefault(jsonStringAny(m["vout"]), 0))
val, err := parseValueKoinu(jsonStringAny(m["value"]))
if err != nil || val <= 0 {
val, err = parseValueKoinu(jsonStringAny(m["amount"]))
if err != nil || val <= 0 {
continue
}
}
out = append(out, ExplorerUTXO{
TxID: txid,
Vout: vout,
Value: val,
ScriptPubHex: strings.TrimSpace(jsonStringAny(m["script_pubkey"])),
})
}
}
if len(out) == 0 {
return nil, fmt.Errorf("no json utxos parsed")
}
return out, nil
}
func sendtxExecTimeout() time.Duration {
if v := strings.TrimSpace(os.Getenv("PUP_SENDTX_TIMEOUT_SEC")); v != "" {
if n, err := strconv.Atoi(v); err == nil && n >= 15 && n <= 300 {
return time.Duration(n) * time.Second
}
}
// Shorter default avoids the UI waiting minutes; libdogecoin still connects to many peers in parallel.
return 90 * time.Second
}
// runSendtx broadcasts a signed raw hex transaction via libdogecoin P2P.
func (s *Server) runSendtx(signedHex string, testnet bool, peers string) (string, error) {
signedHex = strings.TrimSpace(signedHex)
if signedHex == "" {
return "", errors.New("empty transaction hex")
}
txDeadline := sendtxExecTimeout()
args := []string{}
if testnet {
args = append(args, "-t")
}
if peers != "" {
args = append(args, "-i", peers)
}
args = append(args, signedHex)
ctx, cancel := context.WithTimeout(context.Background(), txDeadline)
defer cancel()
cmd := exec.CommandContext(ctx, s.sendtxPath(), args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("sendtx: %w — %s", err, truncateStr(out.String(), 800))
}
firstOut := strings.TrimSpace(out.String())
sum := summarizeSendtxOutput(firstOut)
if strings.TrimSpace(peers) == "" && sum.ConnectedNodes == 0 {
fallbackPeers := sendtxFallbackPeers(testnet)
if fallbackPeers != "" {
args2 := []string{}
if testnet {
args2 = append(args2, "-t")
}
args2 = append(args2, "-i", fallbackPeers, signedHex)
ctx2, cancel2 := context.WithTimeout(context.Background(), txDeadline)
defer cancel2()
cmd2 := exec.CommandContext(ctx2, s.sendtxPath(), args2...)
var out2 bytes.Buffer
cmd2.Stdout = &out2
cmd2.Stderr = &out2
if err2 := cmd2.Run(); err2 == nil {
second := strings.TrimSpace(out2.String())
if summarizeSendtxOutput(second).ConnectedNodes > 0 {
return second, nil
}
return strings.TrimSpace(firstOut + "\n\n[retry-with-fallback-peers]\n" + second), nil
}
}
}
// sendtx prints minimal output on success; return combined for debugging
return firstOut, nil
}
func sendtxFallbackPeers(testnet bool) string {
if p := strings.TrimSpace(os.Getenv("PUP_SENDTX_PEERS")); p != "" {
return p
}
if testnet {
return ""
}
return strings.Join([]string{
"54.224.206.12",
"159.203.65.214",
"138.201.221.250",
"178.63.139.172",
"93.243.63.136",
"95.217.56.57",
}, ",")
}
// extractSendtxDiagnosticLines returns non-progress lines from sendtx stdout/stderr merge:
// tool Error:/Warning: lines and any line that looks like a P2P/policy rejection (if printed).
func extractSendtxDiagnosticLines(txt string) []string {
var out []string
seen := map[string]struct{}{}
for _, ln := range strings.Split(strings.ReplaceAll(txt, "\r\n", "\n"), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
low := strings.ToLower(ln)
if strings.HasPrefix(low, "error:") || strings.HasPrefix(low, "warning:") {
if _, ok := seen[ln]; !ok {
seen[ln] = struct{}{}
out = append(out, ln)
}
continue
}
if strings.Contains(low, "reject") ||
strings.Contains(low, "misbehaving") ||
strings.Contains(low, "non-standard") ||
strings.Contains(low, "nonstandard") ||
strings.Contains(low, "insufficient fee") ||
strings.Contains(low, "bad-txns") ||
strings.Contains(low, "txn-") ||
strings.Contains(low, "too many") ||
strings.Contains(low, "dust") {
if _, ok := seen[ln]; !ok {
seen[ln] = struct{}{}
out = append(out, ln)
}
}
}
return out
}
func relayHeuristicErrorLine(diagnosticLines []string) string {
for _, ln := range diagnosticLines {
low := strings.ToLower(strings.TrimSpace(ln))
if strings.HasPrefix(low, "error:") && strings.Contains(low, "relay") {
return strings.TrimSpace(ln)
}
}
return ""
}
type sendtxOutputSummary struct {
BroadcastTxID string `json:"broadcast_txid,omitempty"`
ConnectedNodes int `json:"connected_nodes"`
InformedNodes int `json:"informed_nodes"`
RequestedFromNodes int `json:"requested_from_nodes"`
SeenOnOtherNodes int `json:"seen_on_other_nodes"`
RelayBackReceived bool `json:"relay_back_received"`
LikelyBroadcasted bool `json:"likely_broadcasted"`
Status string `json:"status"` // success | warning | unknown
HumanNote string `json:"human_note"`
SendtxDiagnosticLines []string `json:"sendtx_diagnostic_lines,omitempty"`
RelayHeuristicError string `json:"relay_heuristic_error,omitempty"`
SendtxDiagnosticsNote string `json:"sendtx_diagnostics_note,omitempty"`
}
func summarizeSendtxOutput(raw string) sendtxOutputSummary {
txt := strings.TrimSpace(strings.ReplaceAll(raw, "\r\n", "\n"))
s := sendtxOutputSummary{
Status: "unknown",
HumanNote: "sendtx output unavailable",
}
if txt == "" {
return s
}
s.SendtxDiagnosticLines = extractSendtxDiagnosticLines(txt)
s.RelayHeuristicError = relayHeuristicErrorLine(s.SendtxDiagnosticLines)
if len(s.SendtxDiagnosticLines) > 0 {
s.SendtxDiagnosticsNote = "Verbatim lines from sendtx combined stdout/stderr (not JSON-RPC). Use relay_heuristic_error to separate sendtx relay-back guesses from any real reject lines, if present."
}
lower := strings.ToLower(txt)
connected := strings.Count(lower, "successfully connected to peer")
sent := strings.Count(lower, "tx successfully sent to node")
if m := reSendtxStartTxid.FindStringSubmatch(txt); len(m) >= 2 {
s.BroadcastTxID = normalizeTxid(m[1])
}
s.ConnectedNodes = connected
s.InformedNodes = parseCountAfterLabel(txt, "Informed nodes")
s.RequestedFromNodes = parseCountAfterLabel(txt, "Requested from nodes")
s.SeenOnOtherNodes = parseCountAfterLabel(txt, "Seen on other nodes")
notRelayedBack := strings.Contains(lower, "transaction was not relayed back")
s.RelayBackReceived = !notRelayedBack && (s.SeenOnOtherNodes > 0)
s.LikelyBroadcasted = sent > 0 || s.InformedNodes > 0 || s.RequestedFromNodes > 0
peerAccepted := s.InformedNodes > 0 && s.RequestedFromNodes > 0
switch {
case peerAccepted:
s.Status = "success"
s.HumanNote = "Broadcast accepted by peers (inv/getdata observed). Relay-back was not observed in this short sendtx window."
if s.RelayHeuristicError != "" {
s.HumanNote += " The sendtx tool also printed a relay-back heuristic (see relay_heuristic_error); that is not a Dogecoin Core reject string — peers already requested the tx (getdata)."
}
case s.LikelyBroadcasted && notRelayedBack:
s.Status = "warning"
s.HumanNote = "Broadcast reached peers, but no relay-back was observed in this short window. This often happens with already-seen or delayed-propagation transactions."
case s.LikelyBroadcasted:
s.Status = "success"
s.HumanNote = "Broadcast reached peers over libdogecoin P2P."
default:
s.Status = "unknown"
s.HumanNote = "sendtx did not confirm peer relay in output."
}
return s
}
func parseCountAfterLabel(text, label string) int {
for _, ln := range strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
if !strings.Contains(strings.ToLower(ln), strings.ToLower(label)) {
continue
}
i := strings.Index(ln, ":")
if i < 0 {
continue
}
n := parseIntDefault(strings.TrimSpace(ln[i+1:]), 0)
if n < 0 {
return 0
}
return n
}
return 0
}
func (s *Server) spvLogPath() string {
return filepath.Join(s.storageDir, "spv.log")
}
func (s *Server) spvPidPath() string {
return filepath.Join(s.storageDir, "spv.pid")
}
func (s *Server) spvWatchAddrPath() string {
return filepath.Join(s.storageDir, "spv_watch_addrs.txt")
}
func (s *Server) spvHTTPBaseURL() string {
addr := strings.TrimSpace(os.Getenv("SPV_HTTP_ADDR"))
if addr == "" {
addr = "127.0.0.1:8080"
}
if strings.HasPrefix(addr, "http://") || strings.HasPrefix(addr, "https://") {
return strings.TrimRight(addr, "/")
}
return "http://" + strings.TrimRight(addr, "/")
}
// spvnodeArgs builds argv for libdogecoin spvnode. We intentionally omit -f:
// with -f 0, spvnode treats headers as in-memory only and ignores -h, so
// headers.db never appears on disk and file-based header rollback cannot run.
// -g enables BIP37 filtered blocks: after header sync, historical merkleblocks+tx
// are requested so PQ commitment logs run for wallet-related txs (full MSG_BLOCK
// bodies are only fetched near the tip without -g).
// Pass all watch addresses in a single -a "addr1 addr2" (spvnode getopt keeps only the last -a).
func spvnodeArgs(testnet bool, addrs []string, storageDir string, useCheckpoint bool, httpAddr string) []string {
args := []string{"-c", "-l", "-g"}
if useCheckpoint {
args = append(args, "-p")
}
httpAddr = strings.TrimSpace(httpAddr)
if httpAddr != "" {
args = append(args, "-u", httpAddr)
}
// spvnode getopt keeps only the last -a; wallet.c expects space-separated watch list on one -a.
var watch []string
for _, a := range addrs {
a = strings.TrimSpace(a)
if a == "" {
continue
}
watch = append(watch, a)
}
if len(watch) > 0 {
args = append(args, "-a", strings.Join(watch, " "))
}
args = append(args,
"-w", filepath.Join(storageDir, "spv_wallet.db"),
"-h", filepath.Join(storageDir, "headers.db"),
"-b", "scan",
)
if testnet {
args = append([]string{"-t"}, args...)
}
return args
}
// startSPVNode launches spvnode in the background. Restarts when the watch address set changes.
func (s *Server) startSPVNode(w *WalletFile) {
if strings.TrimSpace(os.Getenv("SPVNODE_ENABLE")) == "0" {
return
}
addrs := w.AllDistinctP2PKHAddresses()
if len(addrs) == 0 {
return
}
if !s.readServicePrefs().SpvEnabled {
s.spvStartMu.Lock()
s.stopSPVNode()
s.spvStartMu.Unlock()
return
}
s.spvStartMu.Lock()
defer s.spvStartMu.Unlock()
want := strings.Join(addrs, "\n")
prev, _ := os.ReadFile(s.spvWatchAddrPath())
pidRunning := false
if b, err := os.ReadFile(s.spvPidPath()); err == nil {
pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
if pid > 0 && spvProcessAlive(pid) {
pidRunning = true
} else {
_ = os.Remove(s.spvPidPath())
}
}
hdb := filepath.Join(s.storageDir, "headers.db")
headersOK := false
if st, err := os.Stat(hdb); err == nil && !st.IsDir() {
headersOK = true
}
if pidRunning && strings.TrimSpace(string(prev)) == want && headersOK {
return
}
s.repairHeadersDBForSPVStart()
if migrated, backup, err := s.migrateLegacyHeadersDB(); err != nil {
log.Printf("[pq-wallet] legacy headers.db migrate failed: %v", err)
return
} else if migrated {
log.Printf("[pq-wallet] migrated unknown-format headers.db to %s", backup)
}
s.stopSPVNode()
testnet := strings.EqualFold(w.Network, "testnet")
var used []string
list := addrs
if len(list) == 0 {
return
}
sort.Strings(list)
logPath := s.spvLogPath()
lf, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
log.Printf("[pq-wallet] spv log %s: %v", logPath, err)
lf, err = os.OpenFile(os.DevNull, os.O_WRONLY, 0600)
if err != nil {
log.Printf("[pq-wallet] spv output sink: %v", err)
return
}
}
prefs := s.readSPVSyncPrefs()
httpAddr := strings.TrimSpace(os.Getenv("SPV_HTTP_ADDR"))
if httpAddr == "" {
httpAddr = "127.0.0.1:8080"
}
args := spvnodeArgs(testnet, list, s.storageDir, prefs.UseCheckpoint, httpAddr)
cmd := exec.Command(s.spvnodePath(), args...)
cmdEnv := os.Environ()
if prefs.UseCheckpoint && prefs.RestoreCheckpointHint > 0 {
cmdEnv = append(cmdEnv, fmt.Sprintf("PQ_SPV_CHECKPOINT_HEIGHT=%d", prefs.RestoreCheckpointHint))
}
cmd.Env = cmdEnv
cmd.Stdout = lf
cmd.Stderr = lf
if err := cmd.Start(); err != nil {
_ = lf.Close()
log.Printf("[pq-wallet] spvnode start addrs=%d: %v", len(list), err)
return
}
started := cmd.Process
used = list
_, _ = fmt.Fprintf(lf, "\n--- spvnode started %s pid=%d watch_addrs=%d ---\n", time.Now().UTC().Format(time.RFC3339), started.Pid, len(used))
_ = os.WriteFile(s.spvPidPath(), []byte(strconv.Itoa(started.Pid)), 0600)
_ = os.WriteFile(s.spvWatchAddrPath(), []byte(strings.Join(used, "\n")), 0600)
go func(proc *os.Process, logf *os.File) {
_, _ = proc.Wait()
_ = logf.Close()
}(started, lf)
log.Printf("[pq-wallet] spvnode pid=%d watch_addrs=%d", started.Pid, len(used))
}
// walletFileFromSPVWatchState builds a minimal WalletFile from spv_watch_addrs.txt (used when the wallet is sealed).
func (s *Server) walletFileFromSPVWatchState() *WalletFile {
st, err := s.loadSPVWatchState()
if err != nil || st == nil || len(st.Addresses) == 0 {
return nil
}
w := &WalletFile{Network: st.Network}
w.Addresses = make([]WalletAddress, 0, len(st.Addresses))
for _, a := range st.Addresses {
a = strings.TrimSpace(a)
if a == "" {
continue
}
w.Addresses = append(w.Addresses, WalletAddress{P2PKH: a})
}
if len(w.Addresses) == 0 {
return nil
}
return w
}
func (s *Server) startSPVNodeFromWatchState() {
if w := s.walletFileFromSPVWatchState(); w != nil {
s.startSPVNode(w)
}
}
func (s *Server) stopSPVNode() {
b, err := os.ReadFile(s.spvPidPath())
if err != nil {
return
}
pid := strings.TrimSpace(string(b))
if pid == "" {
return
}
pidInt, _ := strconv.Atoi(pid)
spvProcessTerminate(pidInt)
_ = os.Remove(s.spvPidPath())
}
func (s *Server) readSPVStatus() map[string]any {
pidPath := s.spvPidPath()
logPath := s.spvLogPath()
out := map[string]any{
"libdogecoin_spvnode": s.spvnodePath(),
"libdogecoin_such": s.suchPath(),
"libdogecoin_sendtx": s.sendtxPath(),
"pq_libdogecoin_bin": strings.TrimSpace(os.Getenv("PQ_LIBDOGECOIN_BIN")),
"pid_file": pidPath,
"log_file": logPath,
"storage_dir": s.storageDir,
"spv_http_url": s.spvHTTPBaseURL(),
}
// Use a larger tail window so we can parse structured raw tx hex for older spends
// and keep direction/amount enrichment stable across refreshes.
if tail, err := readFileTail(logPath, 2*1024*1024); err == nil && strings.TrimSpace(tail) != "" {
out["log_tail"] = tail
}
hdb := filepath.Join(s.storageDir, "headers.db")
wdb := filepath.Join(s.storageDir, "spv_wallet.db")
if _, err := os.Stat(hdb); err == nil {
out["headers_db"] = hdb
out["headers_db_present"] = true
if isLibdogecoinHeadersFileFormat(hdb) {
out["headers_db_format"] = "libdogecoin_file"
} else {
out["headers_db_format"] = "unknown"
}
} else {
out["headers_db_present"] = false
}
prefs := s.readSPVSyncPrefs()
out["use_checkpoint"] = prefs.UseCheckpoint
out["restore_checkpoint_hint"] = prefs.RestoreCheckpointHint
out["spv_checkpoints"] = map[string]any{
"mainnet": spvMainnetCheckpoints,
"testnet": spvTestnetCheckpoints,
"note": "Rollback uses the height you pick from this list (must match vendored chainparams.c). " +
"With use_checkpoint, spvnode runs with -p. pq-wallet exports PQ_SPV_CHECKPOINT_HEIGHT=restore_checkpoint_hint; " +
"patched spvnode applies that exact bundled checkpoint when headers.db is new or empty (not when an on-disk header chain already starts above height 0).",
}
if _, err := os.Stat(wdb); err == nil {
out["spv_wallet_db"] = wdb
out["spv_wallet_db_present"] = true
} else {
out["spv_wallet_db_present"] = false
}
if rawTip, err := s.fetchSPVREST("/getChaintip"); err == nil {
h, bh := parseSPVRESTChaintip(rawTip)
if h > 0 {
out["header_height"] = h
}
if bh != "" {
out["best_block_hash"] = bh
}
}
if rawTS, err := s.fetchSPVREST("/getTimestamp"); err == nil {
if ts := parseSPVRESTTimestamp(rawTS); ts > 0 {
out["header_unix_time"] = ts
}
}
b, err := os.ReadFile(pidPath)
if err != nil {
out["running"] = false
out["hint"] = "SPV process not started yet. It starts automatically after the wallet is created (or restart the pup)."
return out
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
out["pid"] = pid
if pid > 0 {
out["running"] = spvProcessAlive(pid)
}
return out
}
func (s *Server) broadcastLogPath() string {
return filepath.Join(s.storageDir, "broadcast.log")
}
func (s *Server) appendBroadcastLogLine(line string) {
line = strings.TrimSpace(line)
if line == "" {
return
}
p := s.broadcastLogPath()
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
log.Printf("[pq-wallet] broadcast log: %v", err)
return
}
defer f.Close()
_, _ = fmt.Fprintf(f, "%s %s\n", time.Now().UTC().Format(time.RFC3339), line)
}
// logBroadcastDetails appends signed raw hex (optional) and every line of sendtx output to broadcast.log.
// Set PUP_BROADCAST_LOG_SIGNED_HEX=0 to omit hex (privacy / huge txs). Default logs full hex in chunks.
func (s *Server) logBroadcastDetails(sourceTag, txid string, signedHex string, sendOut string, execErr error) {
if s == nil || s.storageDir == "" {
return
}
tag := strings.TrimSpace(sourceTag)
if tag == "" {
tag = "broadcast"
}
hex := strings.TrimSpace(signedHex)
txid = normalizeTxid(strings.TrimSpace(txid))
if execErr != nil {
s.appendBroadcastLogLine(fmt.Sprintf("%s txid=%s signed_hex_len=%d exec_err=%q", tag, txid, len(hex), execErr.Error()))
} else {
s.appendBroadcastLogLine(fmt.Sprintf("%s txid=%s signed_hex_len=%d", tag, txid, len(hex)))
}
if strings.TrimSpace(os.Getenv("PUP_BROADCAST_LOG_SIGNED_HEX")) != "0" && hex != "" {
const chunk = 12000
for off := 0; off < len(hex); off += chunk {
end := off + chunk
if end > len(hex) {
end = len(hex)
}
s.appendBroadcastLogLine(fmt.Sprintf("%s SIGNED_RAW_HEX off=%d len=%d %s", tag, off, end-off, hex[off:end]))
}
}
for _, ln := range strings.Split(strings.ReplaceAll(sendOut, "\r\n", "\n"), "\n") {
ln = strings.TrimRight(ln, "\r")
if strings.TrimSpace(ln) == "" {
continue
}
s.appendBroadcastLogLine(tag + " sendtx| " + ln)
}
}
// logBroadcastPaymentHint stores wallet-known destination info for an outgoing tx.
// This lets tx list rendering show Dogecoin Wallet-style "paid to X amount Y"
// without requiring full transaction fetches.
func (s *Server) logBroadcastPaymentHint(sourceTag, txid, toAddress string, amountDOGE float64) {
if s == nil || s.storageDir == "" {
return
}
txid = normalizeTxid(strings.TrimSpace(txid))
toAddress = strings.TrimSpace(toAddress)
if txid == "" || toAddress == "" || amountDOGE <= 0 {
return
}
tag := strings.TrimSpace(sourceTag)
if tag == "" {
tag = "broadcast"
}
s.appendBroadcastLogLine(fmt.Sprintf("%s PAYMENT_HINT txid=%s to=%s amount_doge=%.8f", tag, txid, toAddress, amountDOGE))
}
// logBroadcastSpentPrevoutHints maps each consumed wallet UTXO (prev txid:vout) to
// the final spending tx + destination metadata. This allows SPV /getTransactions spent rows
// (which are keyed by prevout txid:vout) to be shown as OUT with correct pay-to in UI.
func (s *Server) logBroadcastSpentPrevoutHints(sourceTag, spendTxid, toAddress string, amountDOGE float64, used []ExplorerUTXO) {
if s == nil || s.storageDir == "" {
return
}
spendTxid = normalizeTxid(strings.TrimSpace(spendTxid))
toAddress = strings.TrimSpace(toAddress)
if spendTxid == "" || toAddress == "" || amountDOGE <= 0 || len(used) == 0 {
return
}
tag := strings.TrimSpace(sourceTag)
if tag == "" {
tag = "broadcast"
}
for _, u := range used {
prevTxid := normalizeTxid(strings.TrimSpace(u.TxID))
if prevTxid == "" {
continue
}
s.appendBroadcastLogLine(fmt.Sprintf("%s SPENT_PREVOUT_HINT prev_txid=%s prev_vout=%d spend_txid=%s to=%s amount_doge=%.8f",
tag, prevTxid, u.Vout, spendTxid, toAddress, amountDOGE))
}
}
// logBroadcastPQSafeSummary records PQ carrier / TX_R outcome on broadcast.log for later diagnosis
// (same fields as the send_pq_safe JSON response, without needing the HTTP client).
func (s *Server) logBroadcastPQSafeSummary(
sourceTag, txCTxid, pqMode string,
pqCommitment, carrierFlow, pqRevealRequested, carrierEnvDisabled, falconSigPresent, econDowngraded bool,
pqCarrierExtendErr, txRID, txRErr, pqRevealSkipReason string,
pqMkParts int,
) {
if s == nil || s.storageDir == "" {
return
}
tag := strings.TrimSpace(sourceTag)
if tag == "" {
tag = "broadcast"
}
oneLine := func(s string) string {
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
s = strings.TrimSpace(s)
if len(s) > 500 {
return s[:500] + "…"
}
return s
}
txCTxid = normalizeTxid(strings.TrimSpace(txCTxid))
txRID = normalizeTxid(strings.TrimSpace(txRID))
pqMode = strings.TrimSpace(pqMode)
s.appendBroadcastLogLine(fmt.Sprintf(
"%s PQ_STATUS tx_c_txid=%s pq_mode=%s pq_commitment=%t pq_carrier_flow=%t pq_reveal_requested=%t carrier_env_disabled=%t falcon_sig_present=%t econ_downgraded=%t carrier_extend_err=%q tx_r_txid=%s tx_r_mkpart_parts=%d tx_r_err=%q pq_reveal_skip_reason=%q",
tag, txCTxid, pqMode, pqCommitment, carrierFlow, pqRevealRequested, carrierEnvDisabled, falconSigPresent, econDowngraded,
oneLine(pqCarrierExtendErr), txRID, pqMkParts, oneLine(txRErr), oneLine(pqRevealSkipReason),
))
}
func readFileTail(path string, max int) (string, error) {
b, err := os.ReadFile(path)
if err != nil {
return "", err
}
if len(b) <= max {
return string(b), nil
}
return string(b[len(b)-max:]), nil
}
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
)
func readLastNLinesFromFile(path string, maxBytes, n int) (string, error) {
if n <= 0 {
n = 200
}
raw, err := readFileTail(path, maxBytes)
if err != nil {
return "", err
}
raw = strings.ReplaceAll(raw, "\r\n", "\n")
lines := strings.Split(raw, "\n")
if len(lines) <= n {
return strings.Join(lines, "\n"), nil
}
return strings.Join(lines[len(lines)-n:], "\n"), nil
}
func hash256dLEHex(header80 []byte) string {
if len(header80) < 80 {
return ""
}
first := sha256.Sum256(header80[:80])
second := sha256.Sum256(first[:])
out := make([]byte, 32)
for i := 0; i < 32; i++ {
out[i] = second[31-i]
}
return hex.EncodeToString(out)
}
// augmentSPVLogWithHeaderHashes appends block hash from libdogecoin file-based headers.db to lines that mention a height but have no 64-hex hash yet.
func (s *Server) augmentSPVLogWithHeaderHashes(text string) string {
text = strings.ReplaceAll(text, "\r\n", "\n")
lines := strings.Split(text, "\n")
hdb := filepath.Join(s.storageDir, "headers.db")
for i, ln := range lines {
if reBlockHash.MatchString(ln) {
continue
}
var h int64
if m := reLooseHeight.FindStringSubmatch(ln); len(m) > 1 {
h, _ = strconv.ParseInt(m[1], 10, 64)
} else if m := reBlockAt.FindStringSubmatch(ln); len(m) > 1 {
h, _ = strconv.ParseInt(m[1], 10, 64)
} else if m := reHeaderHeight.FindStringSubmatch(ln); len(m) > 1 {
h, _ = strconv.ParseInt(m[1], 10, 64)
}
if h <= 0 {
continue
}
if !isLibdogecoinHeadersFileFormat(hdb) {
continue
}
hash := libdogecoinHeaderHashHexAtHeight(hdb, h)
if hash == "" {
continue
}
lines[i] = strings.TrimRight(ln, " \t") + " hash=" + hash
}
return strings.Join(lines, "\n")
}
func (s *Server) handleLogsSPV(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
if s.strictSettingsPeekBlocked() {
writeStrictSettingsAuthRequired(w)
return
}
n := 260
if v := r.URL.Query().Get("lines"); v != "" {
if x, err := strconv.Atoi(v); err == nil && x > 0 && x <= 4000 {
n = x
}
}
text, err := readLastNLinesFromFile(s.spvLogPath(), 2<<20, n)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err != nil || strings.TrimSpace(text) == "" {
_, _ = w.Write([]byte("(spv log empty or unavailable)\n"))
return
}
text = s.augmentSPVLogWithHeaderHashes(text)
_, _ = w.Write([]byte(text))
}
func queryBool(q url.Values, key string) bool {
v := strings.TrimSpace(q.Get(key))
if v == "" {
return false
}
switch strings.ToLower(v) {
case "1", "true", "yes", "y", "on":
return true
default:
return false
}
}
func clampInt(v, lo, hi, def int) int {
if v <= 0 {
return def
}
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// handleLogsSPVDeep returns a structured plain-text digest of spv.log plus optional headers.db raw header bytes.
// Query flags (all optional):
// lines=N tail window (default 900, max 4000)
// augment=1|0 (default 1) append hash= from headers.db for height-only lines
// tail=1 include full augmented tail after digest sections
// merkle=1 filter merkle/proof/inclusion-ish lines
// hex=1 filter very long hex lines (likely raw tx / wire dumps)
// addr=1 scan for wallet P2PKH address substrings (requires unlocked/plaintext wallet)
// raw_header=1 include libdogecoin headers.db 80-byte header hex + hash256d check for height=… or best known height
// height=N explicit height for raw_header probe
func (s *Server) handleLogsSPVDeep(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
if s.strictSettingsPeekBlocked() {
writeStrictSettingsAuthRequired(w)
return
}
q := r.URL.Query()
n := clampInt(func() int {
x, _ := strconv.Atoi(strings.TrimSpace(q.Get("lines")))
return x
}(), 50, 4000, 900)
wantAugment := !queryBool(q, "no_augment") && (q.Get("augment") == "" || queryBool(q, "augment"))
wantTail := queryBool(q, "tail")
wantMerkle := queryBool(q, "merkle")
wantHex := queryBool(q, "hex")
wantAddr := queryBool(q, "addr")
wantRaw := queryBool(q, "raw_header")
raw, err := readLastNLinesFromFile(s.spvLogPath(), 3<<20, n)
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
var b strings.Builder
fmt.Fprintf(&b, "pq-wallet SPV deep digest (last %d lines of spv.log)\n", n)
fmt.Fprintf(&b, "log_path=%s\n", s.spvLogPath())
if err != nil {
fmt.Fprintf(&b, "\n")
if os.IsNotExist(err) {
fmt.Fprintf(&b, "(spv.log not found — the file is created when SPV runs and spvnode writes to it. Start SPV from Settings → Background services, or restart the pup; then refresh.)\n")
} else {
fmt.Fprintf(&b, "(read error: %v)\n", err)
}
_, _ = w.Write([]byte(b.String()))
return
}
raw = strings.ReplaceAll(raw, "\r\n", "\n")
aug := raw
if wantAugment {
aug = s.augmentSPVLogWithHeaderHashes(raw)
}
lines := strings.Split(aug, "\n")
// Wallet addresses (optional)
var addrs []string
if wantAddr {
s.mu.Lock()
wf, werr := s.loadWallet()
s.mu.Unlock()
if werr != nil {
if errors.Is(werr, ErrWalletLocked) {
fmt.Fprintf(&b, "\n[addr scan skipped: wallet locked / sealed]\n")
} else {
fmt.Fprintf(&b, "\n[addr scan skipped: %v]\n", werr)
}
} else if wf != nil {
for _, a := range wf.AllDistinctP2PKHAddresses() {
a = strings.TrimSpace(a)
if a != "" {
addrs = append(addrs, a)
}
}
}
}
st, _ := s.loadState()
stateH := int64(0)
stateHash := ""
if st != nil && len(st.Metrics) > 0 {
for i := len(st.Metrics) - 1; i >= 0; i-- {
if st.Metrics[i].HeaderHeight > 0 {
stateH = st.Metrics[i].HeaderHeight
stateHash = strings.TrimSpace(st.Metrics[i].BestBlockHash)
break
}
}
}
hdbPath := filepath.Join(s.storageDir, "headers.db")
var dbMax int64
if isLibdogecoinHeadersFileFormat(hdbPath) {
if tip, err := libdogecoinHeadersFileTipHeight(hdbPath); err == nil {
dbMax = tip
}
}
fmt.Fprintf(&b, "\n[state.json header_height=%d best_block_hash=%s] [headers.db max_height=%d]\n", stateH, stateHash, dbMax)
// Count buckets
var nMerkle, nHexish, nAddrHit int
var merkleSamples, hexSamples, addrSamples []string
push := func(dst *[]string, ln string, cap int) {
if len(*dst) >= cap {
return
}
*dst = append(*dst, trimLineForDebug(ln, 520))
}
lowLines := make([]string, len(lines))
for i := range lines {
lowLines[i] = strings.ToLower(lines[i])
}
for i, ln := range lines {
ll := lowLines[i]
if reSpvConfirmedLine.MatchString(ln) || strings.Contains(ll, "merkleblock") || strings.Contains(ll, "partial_merkle") || strings.Contains(ll, "filterload") {
nMerkle++
push(&merkleSamples, ln, 40)
}
t := strings.TrimSpace(strings.ReplaceAll(ln, " ", ""))
if len(t) >= 160 && reHexOnly.MatchString(t) {
nHexish++
push(&hexSamples, ln, 24)
}
if len(addrs) > 0 {
for _, a := range addrs {
if strings.Contains(ll, strings.ToLower(a)) {
nAddrHit++
push(&addrSamples, ln, 40)
break
}
}
}
}
fmt.Fprintf(&b, "\n== counts ==\n")
fmt.Fprintf(&b, "lines_total=%d\n", len(lines))
fmt.Fprintf(&b, "merkle_or_filter_or_confirmish_lines=%d\n", nMerkle)
fmt.Fprintf(&b, "long_hex_token_lines(>=160 hex chars)=%d\n", nHexish)
if wantAddr {
fmt.Fprintf(&b, "wallet_address_substring_hits=%d (addrs=%d)\n", nAddrHit, len(addrs))
}
if wantMerkle && nMerkle > 0 {
fmt.Fprintf(&b, "\n== merkle / inclusion / filter (sample up to 40) ==\n")
for _, s := range merkleSamples {
b.WriteString(s)
b.WriteByte('\n')
}
}
if wantHex && nHexish > 0 {
fmt.Fprintf(&b, "\n== long hex lines (sample up to 24; truncated) ==\n")
for _, s := range hexSamples {
b.WriteString(s)
b.WriteByte('\n')
}
}
if wantAddr && nAddrHit > 0 {
fmt.Fprintf(&b, "\n== address hits (sample up to 40; truncated) ==\n")
for _, s := range addrSamples {
b.WriteString(s)
b.WriteByte('\n')
}
}
if wantRaw {
hProbe := int64(0)
if v := strings.TrimSpace(q.Get("height")); v != "" {
if x, err := strconv.ParseInt(v, 10, 64); err == nil && x > 0 {
hProbe = x
}
}
if hProbe <= 0 {
if stateH > 0 {
hProbe = stateH
} else if dbMax > 0 {
hProbe = dbMax
}
}
fmt.Fprintf(&b, "\n== headers.db raw header probe ==\n")
fmt.Fprintf(&b, "height_used=%d\n", hProbe)
if hProbe <= 0 {
b.WriteString("(no height available — set ?height=N)\n")
} else if !isLibdogecoinHeadersFileFormat(hdbPath) {
fmt.Fprintf(&b, "format=unknown (not libdogecoin file layout); path=%s\n", hdbPath)
} else {
hx := libdogecoinHeader80HexAtHeight(hdbPath, hProbe)
hashStored := strings.ToLower(strings.TrimSpace(libdogecoinHeaderHashHexAtHeight(hdbPath, hProbe)))
fmt.Fprintf(&b, "meta format=libdogecoin_file path=%s\n", hdbPath)
if hx == "" {
b.WriteString("(no record at height in headers file)\n")
} else {
fmt.Fprintf(&b, "header80_hex_len_chars=%d\n", len(hx))
show := hx
if len(show) > 400 {
show = hx[:400] + "…"
}
fmt.Fprintf(&b, "header80_hex_prefix=%s\n", show)
rawBytes, err := hex.DecodeString(hx)
if err != nil || len(rawBytes) < 80 {
fmt.Fprintf(&b, "decode_to_80b: err=%v len=%d\n", err, len(rawBytes))
} else {
calc := strings.ToLower(hash256dLEHex(rawBytes[:80]))
fmt.Fprintf(&b, "hash256d_first80_le_hex=%s\n", calc)
fmt.Fprintf(&b, "hash_from_headers_file_record=%s\n", hashStored)
if hashStored != "" && calc != "" && hashStored == calc {
b.WriteString("hash_match=YES (first 80 bytes hash to stored header hash field)\n")
} else if hashStored != "" && calc != "" {
b.WriteString("hash_match=NO (layout may differ, or stored hash is not block hash)\n")
}
}
}
}
}
if wantTail {
fmt.Fprintf(&b, "\n== full augmented tail (%d lines) ==\n", len(lines))
b.WriteString(strings.Join(lines, "\n"))
b.WriteByte('\n')
}
_, _ = w.Write([]byte(b.String()))
}
func trimLineForDebug(s string, max int) string {
s = strings.TrimRight(s, "\r\n")
if max <= 0 || len(s) <= max {
return s
}
return s[:max] + "…"
}
// handleLogsMempoolTracker returns a text snapshot of the embedded MemeTracker P2P session (stderr is not captured here).
func (s *Server) handleLogsMempoolTracker(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
if s.strictSettingsPeekBlocked() {
writeStrictSettingsAuthRequired(w)
return
}
s.mu.Lock()
wf, err := s.loadWallet()
s.mu.Unlock()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
if err != nil || wf == nil {
_, _ = w.Write([]byte("(wallet not loaded)\n"))
return
}
eng, err := s.ensureMempoolEngine(wf)
if err != nil {
_, _ = fmt.Fprintf(w, "(mempool engine error: %v)\n", err)
return
}
if eng == nil {
_, _ = w.Write([]byte("(no mempool engine)\n"))
return
}
n, live, workers, nConn := eng.DashboardSnapshot()
var b strings.Builder
fmt.Fprintf(&b, "Embedded MemeTracker — unique tx ids seen on relay (visibility): %d\n", n)
fmt.Fprintf(&b, "P2P worker sessions with active connection: %d\n", nConn)
for _, row := range workers {
fmt.Fprintf(&b, " worker %v connected=%v %v updated=%v\n", row["worker_id"], row["connected"], row["address"], row["updated"])
}
fmt.Fprintf(&b, "Live mempool rows (recent, up to 80):\n")
for _, row := range live {
fmt.Fprintf(&b, " %v tracked=%v %v DOGE\n", row["txid"], row["tracked_match"], row["amount_doge"])
}
if len(live) == 0 {
b.WriteString(" (none in freshness window — waiting for inv/tx traffic)\n")
}
_, _ = w.Write([]byte(b.String()))
}
func (s *Server) handleLogsBroadcast(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET only"})
return
}
if s.strictSettingsPeekBlocked() {
writeStrictSettingsAuthRequired(w)
return
}
n := 200
if v := r.URL.Query().Get("lines"); v != "" {
if x, err := strconv.Atoi(v); err == nil && x > 0 && x <= 2000 {
n = x
}
}
text, err := readLastNLinesFromFile(s.broadcastLogPath(), 2<<20, n)
if err != nil {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("(no broadcast attempts logged yet)\n"))
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(text))
}
// PQ Wallet — educational Dogecoin wallet UI with post-quantum proof context (libdogecoin PR #294).
//
// Standard Dogecoin P2PKH + WIF come from libdogecoin (`such -c generate_private_key` / `generate_public_key`). Post-quantum signing material
// (Falcon-512 / Dilithium2 via liboqs) using the bundled `such` CLI. `sendtx` broadcasts signed txs;
// `spvnode` runs SPV headers + BIP37 watch; broadcasting uses `sendtx` (P2P), not Core RPC.
package main
import (
"embed"
"encoding/json"
"errors"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/inevitable360/silly-pups/pq-wallet/service/mempooltracker"
)
//go:embed static/*
var staticFS embed.FS
// pqWalletAppVersion is shown in /api/health, education JSON, and the UI footer (keep in sync with manifest.json).
const pqWalletAppVersion = "0.0.62"
// pqWalletBuildHash is a release fingerprint (SHA-256 hex of "pq-wallet-<version>"); bump when cutting a release.
const pqWalletBuildHash = "7df3f43a88cf42c50f6f44e8a39f7bc0192db2cc471d873012ecde0e39766697"
type Server struct {
mu sync.Mutex
stateMergeMu sync.Mutex // serializes loadState + SPV/mempool merges + saveState (do not hold s.mu across slow I/O)
spvStartMu sync.Mutex
suchMergeMu sync.Mutex
suchProbeMu sync.Mutex
suchProbeAt time.Time
suchProbeData map[string]any
storageDir string
walletPath string
watchPath string
mempoolMu sync.Mutex
mempoolEngine *mempooltracker.Engine
walletKey []byte
sealSalt []byte
memWallet *WalletFile
unlockUntil time.Time
lastSuchMerge time.Time
lastSuchSpendableDOGE float64
lastSuchSpendableAt time.Time
}
func env(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func (s *Server) generateDogecoinWallet(testnet bool) (*WalletFile, error) {
return s.initHDWallet(testnet)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) handleEducation(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, educationPayload())
}
func (s *Server) handleWalletGet(w http.ResponseWriter, _ *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
wf, err := s.loadWallet()
if err != nil {
if errors.Is(err, ErrWalletLocked) {
writeJSON(w, http.StatusOK, map[string]any{"wallet": nil, "locked": true, "sealed": true})
return
}
if os.IsNotExist(err) {
writeJSON(w, http.StatusOK, map[string]any{"wallet": nil})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if wf == nil {
writeJSON(w, http.StatusOK, map[string]any{"wallet": nil})
return
}
out := map[string]any{"wallet": wf}
if s.hasSealedWallet() {
out["wallet"] = wf.redactedAPIView()
out["keys_redacted"] = true
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleWalletExport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST required"})
return
}
var body struct {
PIN string `json:"pin"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
s.mu.Lock()
defer s.mu.Unlock()
if !s.requireSealedWalletPINForAction(w, body.PIN) {
return
}
wf, err := s.loadWallet()
if err != nil {
if errors.Is(err, ErrWalletLocked) {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "locked", "need_unlock": true})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if wf == nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no wallet"})
return
}
writeJSON(w, http.StatusOK, map[string]any{"wallet": wf})
}
type createBody struct {
Network string `json:"network"`
PQKeys bool `json:"pq_keys"`
// When pq_keys is true: falcon (default), dilithium2, raccoong.
PQAlgo string `json:"pq_algo"`
}
func (s *Server) handleWalletCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "POST required"})
return
}
var body createBody
_ = json.NewDecoder(r.Body).Decode(&body)
testnet := strings.EqualFold(strings.TrimSpace(body.Network), "testnet")
s.mu.Lock()
defer s.mu.Unlock()
if s.hasSealedWallet() {
writeJSON(w, http.StatusConflict, map[string]string{"error": "wallet already exists (sealed on disk)"})
return
}
if _, err := os.Stat(s.walletPath); err == nil {
writeJSON(w, http.StatusConflict, map[string]string{"error": "wallet already exists; delete wallet.json on disk to reset"})
return
}
wf, err := s.generateDogecoinWallet(testnet)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if body.PQKeys {
algo := strings.ToLower(strings.TrimSpace(body.PQAlgo))
var pub, priv string
var err error
var src string
switch algo {
case "dilithium2", "dilithium":
pub, priv, err = s.runSuchDilithium2Keygen(testnet)
src = "such_dilithium2_keygen"
wf.PQScheme = "Dilithium2 (DIL2, liboqs via libdogecoin, experimental)"
case "raccoong", "raccoon", "raccoon_g":
pub, priv, err = s.runSuchRaccoonKeygen(testnet)
src = "such_raccoong_keygen"
wf.PQScheme = "Raccoon-G (RCG4, libdogecoin PQC carrier, experimental)"
default:
pub, priv, err = s.runSuchFalconKeygen(testnet)
src = "such_falcon_keygen"
wf.PQScheme = "Falcon-512 (FLC1, liboqs via libdogecoin, experimental)"
}
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
wf.PQPublicHex = pub
wf.PQPrivateHex = priv
wf.PQSource = src
}
if err := s.saveWallet(wf); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
s.startSPVNode(wf)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "wallet": wf})
}
func staticHandler() http.Handler {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(sub))
}
func main() {
port := env("PUBLIC_PORT", "33880")
storage := env("PQ_STORAGE_DIR", "/storage/pq-wallet")
_ = os.MkdirAll(storage, 0700)
walletPath := filepath.Join(storage, "wallet.json")
srv := &Server{
storageDir: storage,
walletPath: walletPath,
watchPath: filepath.Join(storage, "spv_watch_state.json"),
}
go srv.backgroundMetricsLoop()
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", staticHandler()))
mux.HandleFunc("/logo.png", func(w http.ResponseWriter, r *http.Request) {
b, err := staticFS.ReadFile("static/logo.png")
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(b)
})
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"app_version": pqWalletAppVersion,
"build_hash": pqWalletBuildHash,
})
})
mux.HandleFunc("/api/security/status", srv.handleSecurityStatus)
mux.HandleFunc("/api/security/unlock", srv.handleSecurityUnlock)
mux.HandleFunc("/api/security/lock", srv.handleSecurityLock)
mux.HandleFunc("/api/security/seal", srv.handleSecuritySeal)
mux.HandleFunc("/api/security/unseal", srv.handleSecurityUnseal)
mux.HandleFunc("/api/settings/prefs", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
srv.handleSettingsPrefsGet(w, r)
case http.MethodPost:
srv.handleSettingsPrefsPost(w, r)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "GET or POST only"})
}
})
mux.HandleFunc("/api/education", srv.handleEducation)
mux.HandleFunc("/api/wallet/export", srv.handleWalletExport)
mux.HandleFunc("/api/wallet", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
srv.handleWalletGet(w, r)
case http.MethodPost:
srv.handleWalletCreate(w, r)
case http.MethodDelete:
srv.handleWalletDelete(w, r)
default:
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
})
mux.HandleFunc("/api/wallet/import", srv.handleWalletImport)
mux.HandleFunc("/api/wallet/addresses", srv.handleWalletNewAddress)
mux.HandleFunc("/api/wallet/addresses/", srv.handleWalletDeleteAddress)
mux.HandleFunc("/api/wallet/primary", srv.handleWalletSetPrimary)
mux.HandleFunc("/api/dashboard", srv.handleDashboard)
mux.HandleFunc("/api/metrics", srv.handleMetrics)
mux.HandleFunc("/api/transactions", srv.handleTransactions)
mux.HandleFunc("/api/tx/local/", srv.handleTxLocalDetail)
mux.HandleFunc("/api/spv/status", srv.handleSPVStatus)
mux.HandleFunc("/api/spv/rescan", srv.handleSPVRescan)
mux.HandleFunc("/api/debug/spv-wallet-db", srv.handleDebugSPVWalletDB)
mux.HandleFunc("/api/debug/spv-ledger", srv.handleDebugSPVLedger)
mux.HandleFunc("/api/debug/spv-rest", srv.handleDebugSPVREST)
mux.HandleFunc("/api/services/control", srv.handleServicesControl)
mux.HandleFunc("/api/logs/spv", srv.handleLogsSPV)
mux.HandleFunc("/api/logs/spv-deep", srv.handleLogsSPVDeep)
mux.HandleFunc("/api/logs/mempooltracker", srv.handleLogsMempoolTracker)
mux.HandleFunc("/api/logs/broadcast", srv.handleLogsBroadcast)
mux.HandleFunc("/api/tx/sign", srv.handleTxSign)
mux.HandleFunc("/api/tx/broadcast", srv.handleTxBroadcast)
mux.HandleFunc("/api/send/pq-safe", srv.handleSendPQSafe)
mux.HandleFunc("/api/pq/carrier/status", srv.handlePQCarrierStatus)
mux.HandleFunc("/api/pq/carrier/recover", srv.handlePQCarrierRecover)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
b, err := staticFS.ReadFile("static/index.html")
if err != nil {
http.Error(w, "index missing", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
})
p, err := strconv.Atoi(port)
if err != nil || p <= 0 {
log.Fatalf("bad PUBLIC_PORT: %q", port)
}
addr := ":" + port
log.Printf("[pq-wallet] storage=%s listen=%s", storage, addr)
log.Fatal(http.ListenAndServe(addr, mux))
}
package main
import (
"context"
"os"
"path/filepath"
"strings"
"github.com/inevitable360/silly-pups/pq-wallet/service/mempooltracker"
)
// ensureMempoolEngine starts the embedded MemeTracker P2P watcher once per storage dir / network.
func (s *Server) ensureMempoolEngine(wf *WalletFile) (*mempooltracker.Engine, error) {
if wf == nil {
return nil, nil
}
want := strings.ToLower(strings.TrimSpace(wf.Network))
if want == "" {
want = "mainnet"
}
s.mempoolMu.Lock()
defer s.mempoolMu.Unlock()
if !s.readServicePrefs().MemetrackerEnabled {
if s.mempoolEngine != nil {
s.mempoolEngine.Stop()
s.mempoolEngine = nil
}
return nil, nil
}
if s.mempoolEngine != nil {
if s.mempoolEngine.Network() == want {
return s.mempoolEngine, nil
}
s.mempoolEngine.Stop()
s.mempoolEngine = nil
}
dir := filepath.Join(s.storageDir, "mempooltracker")
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, err
}
eng, err := mempooltracker.Start(mempooltracker.Options{
StorageDir: dir,
Network: want,
})
if err != nil {
return nil, err
}
s.mempoolEngine = eng
return eng, nil
}
// syncMemeTracker uses the embedded mempool tracker to estimate unconfirmed DOGE not yet in wallet state.
func (s *Server) syncMemeTracker(ctx context.Context, wf *WalletFile, st *WalletState) (pending float64, err error) {
_ = ctx
if wf == nil {
return 0, nil
}
eng, err := s.ensureMempoolEngine(wf)
if err != nil {
return 0, err
}
if eng == nil {
return 0, nil
}
addrs := wf.AllDistinctP2PKHAddresses()
if len(addrs) == 0 {
return 0, nil
}
confirmed := make(map[string]struct{})
unconfirmedOut := make(map[string]struct{})
manualPendingOut := 0.0
for _, t := range st.Transactions {
id := normalizeTxid(t.Txid)
if id == "" {
continue
}
if t.Confirmations > 0 {
confirmed[id] = struct{}{}
continue
}
if strings.EqualFold(strings.TrimSpace(t.Direction), "out") {
unconfirmedOut[id] = struct{}{}
if strings.EqualFold(strings.TrimSpace(t.Source), "manual") && t.AmountDOGE > 0 {
manualPendingOut += t.AmountDOGE
}
}
}
var sum float64
var lastErr error
for _, addr := range addrs {
pend, e := eng.PendingMempoolDOGE(addr, confirmed)
if e != nil {
lastErr = e
continue
}
sum += pend
}
// Mempool tracker sums credits to watched wallet addresses. For local sends this includes
// our own change/recovery outputs (TX_C/TX_R), which should not inflate pending as inbound.
// Remove credits for txids we already classify as unconfirmed outgoing, then apply local
// manual-send pending debits so dashboard pending reflects wallet UX expectations.
if len(unconfirmedOut) > 0 {
_, live, _, _ := eng.DashboardSnapshot()
for _, row := range live {
if !truthyAny(row["tracked_match"]) {
continue
}
txid := normalizeTxid(jsonStringAny(row["txid"]))
if txid == "" {
continue
}
if _, ok := unconfirmedOut[txid]; !ok {
continue
}
amt := floatFromAny(row["amount_doge"])
if amt > 0 {
sum -= amt
}
}
}
sum -= manualPendingOut
if lastErr != nil && sum == 0 {
return 0, lastErr
}
return sum, nil
}
// MemeTracker — Dogecoin mempool watcher (open source, MIT License; see LICENSE).
//
// Copyright (c) Paulo Vidal · https://x.com/inevitable360 · Dogecoin Foundation Dev
//
// MemeTracker connects to Dogecoin peers only to observe the relay mempool: after
// handshake it sends the mempool message, then handles inv (requesting getdata only
// for MSG_TX / witness-tx inventory types), tx, and ping. It does not sync blocks,
// headers, or chain state—unlike monolithic SPV samples that also parse blocks.
package mempooltracker
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"embed"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math/big"
mrand "math/rand"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
const (
MAGIC = 0xC0C0C0C0
COMMAND_LEN = 12
MSG_WITNESS_FLAG = 1 << 30
MSG_TX = 1 // inventory type for transactions (and MSG_TX|MSG_WITNESS_FLAG for segwit); never MSG_BLOCK
NODE_NETWORK = 1 << 0
NODE_WITNESS = 1 << 3
GETDATA_BATCH = 16
MAX_TX_FETCH_INV = 64
MEMPOOL_RESYNC_SEC = 90
MEMPOOL_WATCHER_SEC = 3
P2P_READ_IDLE_SEC = 20
SESSION_SEC = 300
MAX_P2P_PAYLOAD = 4 * 1024 * 1024
defaultProcessedCap = 50000
)
//go:embed static/*
var staticFiles embed.FS
var mainnetP2PKHVersion = byte(0x1E)
var testnetP2PKHVersion = byte(0x71)
// Same seed hostnames as memetracker/mainnet Dogecoin DNS.
var mainnetDNSSeeds = []string{
"seed.dogecoin.org",
"seed.dogecoin.net",
"seed.multidoge.org",
"seed2.multidoge.org",
// seed.dogecoin.com omitted: often NXDOMAIN; remaining seeds match chainparams.
}
// ------- Utilities -------
func sha256d(data []byte) [32]byte {
h1 := sha256.Sum256(data)
h2 := sha256.Sum256(h1[:])
return h2
}
func mustEnvDefault(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func envString(key, def string) string {
return mustEnvDefault(key, def)
}
// ------- Base58Check (P2PKH -> hash160) -------
var b58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
func b58Index(b byte) (int64, error) {
idx := strings.IndexByte(b58Alphabet, b)
if idx < 0 {
return 0, fmt.Errorf("invalid base58 char: %q", b)
}
return int64(idx), nil
}
func b58Decode(s string) ([]byte, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, errors.New("empty base58")
}
n := new(big.Int)
for i := 0; i < len(s); i++ {
c := s[i]
v, err := b58Index(c)
if err != nil {
return nil, err
}
n.Mul(n, big.NewInt(58))
n.Add(n, big.NewInt(v))
}
// Leading '1's are leading 0x00 bytes.
pad := 0
for pad < len(s) && s[pad] == '1' {
pad++
}
raw := n.Bytes() // big-endian, no leading zeros
out := make([]byte, 0, pad+len(raw))
out = append(out, make([]byte, pad)...)
out = append(out, raw...)
return out, nil
}
func b58checkDecode(addr string) ([]byte, error) {
raw, err := b58Decode(addr)
if err != nil {
return nil, err
}
if len(raw) < 5 {
return nil, errors.New("invalid address length")
}
payload, chk := raw[:len(raw)-4], raw[len(raw)-4:]
sum := sha256d(payload)
if !strings.EqualFold(hex.EncodeToString(sum[:4]), hex.EncodeToString(chk)) {
return nil, errors.New("bad checksum")
}
return payload, nil
}
func decodePayoutToHash160(address string, network string) ([]byte, error) {
wantVer := mainnetP2PKHVersion
if strings.ToLower(network) != "mainnet" {
wantVer = testnetP2PKHVersion
}
p, err := b58checkDecode(address)
if err != nil {
return nil, err
}
if len(p) != 21 || p[0] != wantVer {
return nil, fmt.Errorf("need P2PKH base58 address for %s", network)
}
return p[1:21], nil
}
// ------- Tx parsing -------
// maxVarIntSlice is the largest count we allow when advancing an offset into a buffer
// (avoids uint64→int overflow that can make the offset negative and panic in readVarInt).
const maxVarIntSlice = uint64(32 * 1024 * 1024)
func readVarInt(data []byte, off *int) (uint64, error) {
if *off < 0 || *off >= len(data) {
return 0, errors.New("eof")
}
b0 := data[*off]
*off++
if b0 < 0xFD {
return uint64(b0), nil
}
if b0 == 0xFD {
if *off+2 > len(data) {
return 0, errors.New("eof")
}
v := binary.LittleEndian.Uint16(data[*off:])
*off += 2
return uint64(v), nil
}
if b0 == 0xFE {
if *off+4 > len(data) {
return 0, errors.New("eof")
}
v := binary.LittleEndian.Uint32(data[*off:])
*off += 4
return uint64(v), nil
}
// 0xFF
if *off+8 > len(data) {
return 0, errors.New("eof")
}
v := binary.LittleEndian.Uint64(data[*off:])
*off += 8
return v, nil
}
func offsetFits(off int, n uint64, bufLen int) bool {
if off < 0 || off > bufLen {
return false
}
if n > maxVarIntSlice || n > uint64(bufLen-off) {
return false
}
if n > uint64(^uint(0)>>1) {
return false
}
return true
}
func offsetAdd(off *int, n uint64, bufLen int) bool {
if !offsetFits(*off, n, bufLen) {
return false
}
*off += int(n)
return true
}
func scriptPubKeyHash160(script []byte) ([]byte, bool) {
// P2PKH: OP_DUP OP_HASH160 0x14 <20> OP_EQUALVERIFY OP_CHECKSIG
if len(script) == 25 &&
script[0] == 0x76 &&
script[1] == 0xA9 &&
script[2] == 0x14 &&
script[23] == 0x88 &&
script[24] == 0xAC {
out := make([]byte, 20)
copy(out, script[3:23])
return out, true
}
// P2SH: OP_HASH160 0x14 <20> OP_EQUAL
if len(script) == 23 &&
script[0] == 0xA9 &&
script[1] == 0x14 &&
script[22] == 0x87 {
out := make([]byte, 20)
copy(out, script[2:22])
return out, true
}
// v0 P2WPKH (OP_0 0x14 <20>)
if len(script) == 22 && script[0] == 0x00 && script[1] == 0x14 {
out := make([]byte, 20)
copy(out, script[2:22])
return out, true
}
return nil, false
}
type txOutput struct {
valueSats int64
script []byte
}
type txInputOutpoint struct {
prevTxid string
vout uint32
}
func parseTxOutputs(raw []byte) ([]txOutput, bool, error) {
if len(raw) < 8 {
return nil, false, nil
}
off := 0
// version (4 bytes)
if off+4 > len(raw) {
return nil, false, errors.New("truncated_version")
}
off += 4
isSegwit := false
if off+2 <= len(raw) && raw[off] == 0 && raw[off+1] == 1 {
isSegwit = true
off += 2
}
// vin count
nin, err := readVarInt(raw, &off)
if err != nil {
return nil, isSegwit, err
}
// skip vin scripts and sequences
for i := 0; i < int(nin); i++ {
// outpoint: 32 hash + 4 vout
if off+36 > len(raw) {
return nil, isSegwit, errors.New("truncated_txin")
}
off += 32 + 4
// scriptSig
slen, err := readVarInt(raw, &off)
if err != nil {
return nil, isSegwit, err
}
if !offsetFits(off, slen, len(raw)) {
return nil, isSegwit, errors.New("truncated_scriptSig")
}
off += int(slen)
// sequence (4 bytes)
if off+4 > len(raw) {
return nil, isSegwit, errors.New("truncated_sequence")
}
off += 4
}
// vin done, now vout count
nout, err := readVarInt(raw, &off)
if err != nil {
return nil, isSegwit, err
}
outs := make([]txOutput, 0, int(nout))
for i := 0; i < int(nout); i++ {
if off+8 > len(raw) {
return nil, isSegwit, errors.New("truncated_value")
}
value := int64(binary.LittleEndian.Uint64(raw[off:]))
off += 8
slen, err := readVarInt(raw, &off)
if err != nil {
return nil, isSegwit, err
}
if !offsetFits(off, slen, len(raw)) {
return nil, isSegwit, errors.New("truncated_pk_script")
}
ns := int(slen)
script := raw[off : off+ns]
off += ns
cp := make([]byte, len(script))
copy(cp, script)
outs = append(outs, txOutput{valueSats: value, script: cp})
}
// Skip witness data after outputs.
if isSegwit {
for i := 0; i < int(nin); i++ {
nstk, err := readVarInt(raw, &off)
if err != nil {
return nil, isSegwit, err
}
for j := 0; j < int(nstk); j++ {
elen, err := readVarInt(raw, &off)
if err != nil {
return nil, isSegwit, err
}
if !offsetFits(off, elen, len(raw)) {
return nil, isSegwit, errors.New("truncated_witness")
}
off += int(elen)
}
}
}
return outs, isSegwit, nil
}
func parseTxInputOutpoints(raw []byte) ([]txInputOutpoint, error) {
if len(raw) < 8 {
return nil, nil
}
off := 0
if off+4 > len(raw) {
return nil, errors.New("truncated_version")
}
off += 4
if off+2 <= len(raw) && raw[off] == 0 && raw[off+1] == 1 {
off += 2
}
nin, err := readVarInt(raw, &off)
if err != nil {
return nil, err
}
out := make([]txInputOutpoint, 0, int(nin))
for i := 0; i < int(nin); i++ {
if off+36 > len(raw) {
return out, errors.New("truncated_txin")
}
prevLE := raw[off : off+32]
rev := make([]byte, 32)
for j := 0; j < 32; j++ {
rev[j] = prevLE[31-j]
}
prevTxid := hex.EncodeToString(rev)
vout := binary.LittleEndian.Uint32(raw[off+32 : off+36])
out = append(out, txInputOutpoint{prevTxid: prevTxid, vout: vout})
off += 36
slen, err := readVarInt(raw, &off)
if err != nil {
return out, err
}
if !offsetAdd(&off, slen, len(raw)) {
return out, errors.New("truncated_scriptSig")
}
if off+4 > len(raw) {
return out, errors.New("truncated_sequence")
}
off += 4
}
return out, nil
}
func txidHex(raw []byte) string {
if len(raw) < 8 {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
off := 4
isSegwit := len(raw) >= off+2 && raw[off] == 0 && raw[off+1] == 1
if isSegwit {
off += 2
}
bodyStart := off
nin64, err := readVarInt(raw, &off)
if err != nil {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
nin := nin64
if nin > uint64(len(raw)) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
// inputs: [prevout(32)+vout(4)+scriptLen+script+sequence(4)] repeated
for i := 0; i < int(nin); i++ {
if off+36 > len(raw) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
off += 36 // prevout hash + vout index
slen64, err := readVarInt(raw, &off)
if err != nil {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
if !offsetFits(off, slen64, len(raw)) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
off += int(slen64)
// sequence
if off+4 > len(raw) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
off += 4
}
nout64, err := readVarInt(raw, &off)
if err != nil {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
if nout64 > uint64(len(raw)) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
for i := 0; i < int(nout64); i++ {
if off+8 > len(raw) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
off += 8
slen64, err := readVarInt(raw, &off)
if err != nil {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
if !offsetFits(off, slen64, len(raw)) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
off += int(slen64)
}
endOutputs := off
rest := endOutputs
if isSegwit {
nwi64, err := readVarInt(raw, &rest)
if err != nil {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
for i := 0; i < int(nwi64); i++ {
ns64, err := readVarInt(raw, &rest)
if err != nil {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
for j := 0; j < int(ns64); j++ {
el64, err := readVarInt(raw, &rest)
if err != nil {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
if !offsetFits(rest, el64, len(raw)) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
rest += int(el64)
}
}
}
var locktime []byte
if rest+4 <= len(raw) {
locktime = raw[rest : rest+4]
} else {
locktime = []byte{0, 0, 0, 0}
}
var preimage []byte
if isSegwit {
if bodyStart > endOutputs || endOutputs > len(raw) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
// preimage = version(4) + body(non-witness, from body_start to end_outputs) + locktime
preimage = append(append([]byte{}, raw[0:4]...), raw[bodyStart:endOutputs]...)
preimage = append(preimage, locktime...)
} else {
if endOutputs > len(raw) {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
preimage = append(append([]byte{}, raw[0:endOutputs]...), locktime...)
}
sum := sha256d(preimage)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
func wtxidHex(raw []byte) string {
sum := sha256d(raw)
rev := make([]byte, 32)
for i := 0; i < 32; i++ {
rev[i] = sum[31-i]
}
return hex.EncodeToString(rev)
}
// ------- P2P protocol -------
func writeVarInt(n int) []byte {
if n < 0xFD {
return []byte{byte(n)}
}
if n <= 0xFFFF {
out := make([]byte, 3)
out[0] = 0xFD
binary.LittleEndian.PutUint16(out[1:], uint16(n))
return out
}
if n <= 0xFFFFFFFF {
out := make([]byte, 5)
out[0] = 0xFE
binary.LittleEndian.PutUint32(out[1:], uint32(n))
return out
}
out := make([]byte, 9)
out[0] = 0xFF
binary.LittleEndian.PutUint64(out[1:], uint64(n))
return out
}
func buildMessage(command string, payload []byte) []byte {
cmd := []byte(command)
if len(cmd) > COMMAND_LEN {
cmd = cmd[:COMMAND_LEN]
}
padded := make([]byte, COMMAND_LEN)
copy(padded, cmd)
checksum := sha256d(payload)
out := make([]byte, 0, 24+len(payload))
hdr := make([]byte, 0, 24)
tmp := make([]byte, 4)
binary.LittleEndian.PutUint32(tmp, uint32(MAGIC))
hdr = append(hdr, tmp...)
hdr = append(hdr, padded...)
size := make([]byte, 4)
binary.LittleEndian.PutUint32(size, uint32(len(payload)))
hdr = append(hdr, size...)
hdr = append(hdr, checksum[:4]...)
out = append(out, hdr...)
out = append(out, payload...)
return out
}
func readExact(conn net.Conn, size int) ([]byte, error) {
out := make([]byte, 0, size)
for len(out) < size {
part := make([]byte, size-len(out))
n, err := conn.Read(part)
if err != nil {
return nil, err
}
if n == 0 {
return nil, io.EOF
}
out = append(out, part[:n]...)
}
return out, nil
}
// Dogecoin mainnet P2P message magic is 0xc0c0c0c0 as a uint32; on the wire it is 4 bytes little-endian.
func dogeMagicWireHexLE() string {
var b [4]byte
binary.LittleEndian.PutUint32(b[:], uint32(MAGIC))
return hex.EncodeToString(b[:])
}
func hexSnippet(b []byte, max int) string {
if len(b) <= max {
return hex.EncodeToString(b)
}
return hex.EncodeToString(b[:max]) + fmt.Sprintf("…(%d more bytes)", len(b)-max)
}
// Summarize our outgoing version payload (same layout as buildVersionPayload).
func summarizeOutgoingVersionPayload(p []byte, destPort int) string {
if len(p) < 81 {
return fmt.Sprintf("short_payload_len=%d", len(p))
}
ver := int32(binary.LittleEndian.Uint32(p[0:4]))
services := binary.LittleEndian.Uint64(p[4:12])
ts := int64(binary.LittleEndian.Uint64(p[12:20]))
off := 20 + 26 + 26 // after addr_recv, addr_from
if len(p) < off+8 {
return fmt.Sprintf("truncated_at=%d", len(p))
}
nonce := binary.LittleEndian.Uint64(p[off : off+8])
off += 8
if off >= len(p) {
return fmt.Sprintf("bad_ua_off len=%d", len(p))
}
uaLen := int(p[off])
off++
if off+uaLen+4+1 > len(p) {
return fmt.Sprintf("bad_ua len=%d uaLen=%d", len(p), uaLen)
}
ua := string(p[off : off+uaLen])
off += uaLen
startH := int32(binary.LittleEndian.Uint32(p[off : off+4]))
relay := p[off+4]
return fmt.Sprintf("proto=%d services=0x%x(NODE_NETWORK=%d NODE_WITNESS=%d) timestamp_unix=%d nonce=0x%x user_agent=%q start_height=%d relay=%t addr_port_big_endian=%d",
ver, services, (services&NODE_NETWORK)>>0, (services&NODE_WITNESS)>>3, ts, nonce, ua, startH, relay != 0, destPort)
}
// First fields of peer's version message (variable user_agent; we only decode fixed prefix + try UA).
func summarizePeerVersionPayload(p []byte) string {
if len(p) < 20 {
return fmt.Sprintf("len=%d (too short for version prefix)", len(p))
}
ver := int32(binary.LittleEndian.Uint32(p[0:4]))
services := binary.LittleEndian.Uint64(p[4:12])
ts := int64(binary.LittleEndian.Uint64(p[12:20]))
off := 20 + 26 + 26
if len(p) < off+8 {
return fmt.Sprintf("proto=%d services=0x%x ts_unix=%d (truncated before nonce, len=%d)", ver, services, ts, len(p))
}
nonce := binary.LittleEndian.Uint64(p[off : off+8])
off += 8
ua := ""
if off >= len(p) {
ua = "(no user_agent)"
} else {
off2 := off
uaLen64, err := readVarInt(p, &off2)
if err != nil || uaLen64 > 4096 || off2+int(uaLen64) > len(p) {
ua = fmt.Sprintf("(user_agent_compact err=%v n=%d)", err, uaLen64)
} else {
ua = string(p[off2 : off2+int(uaLen64)])
off2 += int(uaLen64)
off = off2
}
}
var startH int32
var relay byte
if off+5 <= len(p) {
startH = int32(binary.LittleEndian.Uint32(p[off : off+4]))
relay = p[off+4]
}
return fmt.Sprintf("peer_proto=%d services=0x%x ts_unix=%d nonce=0x%x user_agent=%q start_height=%d relay=%t",
ver, services, ts, nonce, ua, startH, relay != 0)
}
func parseInvPayload(payload []byte) ([]invItem, error) {
off := 0
n64, err := readVarInt(payload, &off)
if err != nil {
return nil, err
}
n := int(n64)
if n > 100000 {
n = 100000
}
out := make([]invItem, 0, n)
for i := 0; i < n; i++ {
if off+36 > len(payload) {
break
}
invType := binary.LittleEndian.Uint32(payload[off:])
h := payload[off+4 : off+36]
cp := make([]byte, 32)
copy(cp, h)
off += 36
out = append(out, invItem{invType: int(invType), hash: cp})
}
return out, nil
}
type invItem struct {
invType int
hash []byte // 32 bytes in wire order
}
func invTypeIsTx(t int) bool {
return (t & ^MSG_WITNESS_FLAG) == MSG_TX
}
func buildGetdataPayload(items []invItem) []byte {
parts := make([]byte, 0, 1+len(items)*36)
parts = append(parts, writeVarInt(len(items))...)
for _, it := range items {
tmp := make([]byte, 4)
binary.LittleEndian.PutUint32(tmp, uint32(it.invType))
parts = append(parts, tmp...)
parts = append(parts, it.hash...)
}
return parts
}
func buildVersionPayload(p2pPort int) []byte {
version := int32(70015)
services := uint64(NODE_NETWORK | NODE_WITNESS)
timestamp := uint64(time.Now().Unix())
// addr_recv: (services=0, addr=16 zero, port big-end)
addrRecv := make([]byte, 8+16+2)
binary.LittleEndian.PutUint64(addrRecv[:8], 0)
// 16 zero already
binary.BigEndian.PutUint16(addrRecv[8+16:], uint16(p2pPort))
addrFrom := make([]byte, 8+16+2)
binary.LittleEndian.PutUint64(addrFrom[:8], 0)
binary.BigEndian.PutUint16(addrFrom[8+16:], uint16(p2pPort))
nonceBytes := make([]byte, 8)
_, _ = rand.Read(nonceBytes)
nonce := binary.LittleEndian.Uint64(nonceBytes)
userAgent := "/MemeTracker:1.0.0/"
uaLen := len(userAgent)
ua := make([]byte, 1+uaLen)
ua[0] = byte(uaLen)
copy(ua[1:], []byte(userAgent))
startHeight := int32(0)
relay := byte(1)
out := make([]byte, 0, 110)
tmp1 := make([]byte, 4)
binary.LittleEndian.PutUint32(tmp1, uint32(version))
out = append(out, tmp1...)
tmp2 := make([]byte, 8)
binary.LittleEndian.PutUint64(tmp2, services)
out = append(out, tmp2...)
tmp3 := make([]byte, 8)
binary.LittleEndian.PutUint64(tmp3, timestamp)
out = append(out, tmp3...)
out = append(out, addrRecv...)
out = append(out, addrFrom...)
tmp4 := make([]byte, 8)
binary.LittleEndian.PutUint64(tmp4, nonce)
out = append(out, tmp4...)
out = append(out, ua...)
tmp5 := make([]byte, 4)
binary.LittleEndian.PutUint32(tmp5, uint32(startHeight))
out = append(out, tmp5...)
out = append(out, relay)
return out
}
// ------- Store (file-backed DB) -------
type TxRecord struct {
Txid string `json:"txid"`
Datetime string `json:"datetime"`
AmountDoge float64 `json:"amount_doge"`
DoubleSpent bool `json:"double_spent"`
}
type AddressData struct {
Address string `json:"address"`
Hash160Hex string `json:"hash160_hex"`
CallbackURL string `json:"callback_url,omitempty"`
TrackedSince time.Time `json:"tracked_since"`
LastRequested time.Time `json:"last_requested"`
Txs []TxRecord `json:"txs"`
}
type Store struct {
mu sync.RWMutex
storageDir string
addressesDir string
listLimit int
retentionDays int
watchByHash map[string]*AddressData // hash160hex -> data
mempoolKick atomic.Bool // set after /track/ so P2P loop sends "mempool" again
}
func (s *Store) kickMempoolResync() {
s.mempoolKick.Store(true)
}
func (s *Store) takeMempoolKick() bool {
return s.mempoolKick.Swap(false)
}
func (s *Store) watcherCount() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.watchByHash)
}
func NewStore(storageDir string, listLimit int, retentionDays int) (*Store, error) {
addressesDir := filepath.Join(storageDir, "addresses")
if err := os.MkdirAll(addressesDir, 0o755); err != nil {
return nil, err
}
s := &Store{
storageDir: storageDir,
addressesDir: addressesDir,
listLimit: listLimit,
retentionDays: retentionDays,
watchByHash: make(map[string]*AddressData),
}
if err := s.load(); err != nil {
return nil, err
}
s.purgeExpiredLocked(time.Now())
return s, nil
}
func (s *Store) fileForHash(hashHex string) string {
return filepath.Join(s.addressesDir, hashHex+".json")
}
func (s *Store) load() error {
entries, err := os.ReadDir(s.addressesDir)
if err != nil {
return err
}
now := time.Now()
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
b, err := os.ReadFile(filepath.Join(s.addressesDir, name))
if err != nil {
continue
}
var ad AddressData
if err := json.Unmarshal(b, &ad); err != nil {
continue
}
if ad.Hash160Hex == "" {
// best-effort: infer from filename
ad.Hash160Hex = strings.TrimSuffix(name, ".json")
}
if ad.Address == "" {
continue
}
// Ensure newest-first and truncated.
if len(ad.Txs) > s.listLimit {
ad.Txs = ad.Txs[:s.listLimit]
}
// Purge old
if now.Sub(ad.LastRequested) > time.Duration(s.retentionDays)*24*time.Hour {
continue
}
if ad.TrackedSince.IsZero() {
ad.TrackedSince = ad.LastRequested
}
s.watchByHash[ad.Hash160Hex] = &ad
}
return nil
}
func (s *Store) purgeExpiredLocked(now time.Time) {
for hashHex, ad := range s.watchByHash {
if now.Sub(ad.LastRequested) > time.Duration(s.retentionDays)*24*time.Hour {
delete(s.watchByHash, hashHex)
_ = os.Remove(s.fileForHash(hashHex))
}
}
}
func (s *Store) persistAddressLocked(ad *AddressData) error {
tmp := struct {
Address string `json:"address"`
Hash160Hex string `json:"hash160_hex"`
CallbackURL string `json:"callback_url,omitempty"`
TrackedSince time.Time `json:"tracked_since"`
LastRequested time.Time `json:"last_requested"`
Txs []TxRecord `json:"txs"`
}{
Address: ad.Address,
Hash160Hex: ad.Hash160Hex,
CallbackURL: ad.CallbackURL,
TrackedSince: ad.TrackedSince,
LastRequested: ad.LastRequested,
Txs: ad.Txs,
}
b, err := json.Marshal(tmp)
if err != nil {
return err
}
fn := s.fileForHash(ad.Hash160Hex)
tmpfn := fn + ".tmp"
if err := os.WriteFile(tmpfn, b, 0o644); err != nil {
return err
}
return os.Rename(tmpfn, fn)
}
func (s *Store) UpsertTracking(address string, hash160 []byte, callbackURL string) (alreadyMonitoring bool, recent []TxRecord, appliedCallback string, err error) {
hashHex := hex.EncodeToString(hash160)
now := time.Now().UTC()
s.mu.Lock()
defer s.mu.Unlock()
if ad, ok := s.watchByHash[hashHex]; ok {
alreadyMonitoring = true
ad.LastRequested = now
if ad.TrackedSince.IsZero() {
ad.TrackedSince = now
}
if strings.TrimSpace(callbackURL) != "" {
ad.CallbackURL = strings.TrimSpace(callbackURL)
}
if err := s.persistAddressLocked(ad); err != nil {
return alreadyMonitoring, nil, "", err
}
s.kickMempoolResync()
return alreadyMonitoring, ad.Txs, ad.CallbackURL, nil
}
ad := &AddressData{
Address: address,
Hash160Hex: hashHex,
CallbackURL: strings.TrimSpace(callbackURL),
TrackedSince: now,
LastRequested: now,
Txs: []TxRecord{},
}
s.watchByHash[hashHex] = ad
if err := s.persistAddressLocked(ad); err != nil {
return false, nil, "", err
}
s.kickMempoolResync()
return false, ad.Txs, ad.CallbackURL, nil
}
func (s *Store) AddTx(hashHex string, txid string, dt time.Time, amountDoge float64, doubleSpent bool) (inserted bool) {
s.mu.Lock()
defer s.mu.Unlock()
ad, ok := s.watchByHash[hashHex]
if !ok {
return false
}
// Dedupe by txid within the stored limited window.
for i := range ad.Txs {
r := ad.Txs[i]
if r.Txid == txid {
// Keep flags up to date if a conflict is discovered later.
if doubleSpent && !ad.Txs[i].DoubleSpent {
ad.Txs[i].DoubleSpent = true
_ = s.persistAddressLocked(ad)
}
return false
}
}
rec := TxRecord{
Txid: txid,
Datetime: dt.UTC().Format(time.RFC3339),
AmountDoge: amountDoge,
DoubleSpent: doubleSpent,
}
// Store newest first.
ad.Txs = append([]TxRecord{rec}, ad.Txs...)
if len(ad.Txs) > s.listLimit {
ad.Txs = ad.Txs[:s.listLimit]
}
_ = s.persistAddressLocked(ad)
return true
}
func (s *Store) MarkTxDoubleSpent(txid string) bool {
txid = strings.TrimSpace(txid)
if txid == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
changed := false
for _, ad := range s.watchByHash {
localChanged := false
for i := range ad.Txs {
if ad.Txs[i].Txid == txid && !ad.Txs[i].DoubleSpent {
ad.Txs[i].DoubleSpent = true
localChanged = true
changed = true
}
}
if localChanged {
_ = s.persistAddressLocked(ad)
}
}
return changed
}
func (s *Store) CallbackTarget(hashHex string) (address string, callbackURL string, ok bool) {
s.mu.RLock()
defer s.mu.RUnlock()
ad, found := s.watchByHash[hashHex]
if !found {
return "", "", false
}
return ad.Address, strings.TrimSpace(ad.CallbackURL), true
}
func (s *Store) GetRecent(address string, hash160 []byte) ([]TxRecord, bool) {
hashHex := hex.EncodeToString(hash160)
s.mu.RLock()
defer s.mu.RUnlock()
ad, ok := s.watchByHash[hashHex]
if !ok {
return nil, false
}
return ad.Txs, true
}
func (s *Store) SetLimits(listLimit, retentionDays int) {
if listLimit < 1 {
listLimit = 1
}
if retentionDays < 1 {
retentionDays = 1
}
s.mu.Lock()
defer s.mu.Unlock()
s.listLimit = listLimit
s.retentionDays = retentionDays
for _, ad := range s.watchByHash {
if len(ad.Txs) > s.listLimit {
ad.Txs = ad.Txs[:s.listLimit]
_ = s.persistAddressLocked(ad)
}
}
}
func (s *Store) RemoveAddress(hashHex string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.watchByHash[hashHex]; !ok {
return false
}
delete(s.watchByHash, hashHex)
_ = os.Remove(s.fileForHash(hashHex))
s.kickMempoolResync()
return true
}
func (s *Store) RemoveTx(hashHex, txid string) bool {
s.mu.Lock()
defer s.mu.Unlock()
ad, ok := s.watchByHash[hashHex]
if !ok {
return false
}
out := ad.Txs[:0]
for _, r := range ad.Txs {
if r.Txid != txid {
out = append(out, r)
}
}
if len(out) == len(ad.Txs) {
return false
}
ad.Txs = out
_ = s.persistAddressLocked(ad)
return true
}
func (s *Store) ListAddressSnapshots() []map[string]any {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]map[string]any, 0, len(s.watchByHash))
for _, ad := range s.watchByHash {
ts := ad.TrackedSince
if ts.IsZero() {
ts = ad.LastRequested
}
out = append(out, map[string]any{
"address": ad.Address,
"hash160_hex": ad.Hash160Hex,
"tracked_since": ts.UTC().Format(time.RFC3339),
"last_requested": ad.LastRequested.UTC().Format(time.RFC3339),
"tx_count": len(ad.Txs),
})
}
return out
}
func (s *Store) FlattenTransactions() []map[string]any {
s.mu.RLock()
defer s.mu.RUnlock()
var rows []map[string]any
for _, ad := range s.watchByHash {
for _, tx := range ad.Txs {
rows = append(rows, map[string]any{
"address": ad.Address,
"hash160_hex": ad.Hash160Hex,
"txid": tx.Txid,
"datetime": tx.Datetime,
"amount_doge": tx.AmountDoge,
"double_spent": tx.DoubleSpent,
})
}
}
sort.Slice(rows, func(i, j int) bool {
ti, ei := time.Parse(time.RFC3339, rows[i]["datetime"].(string))
tj, ej := time.Parse(time.RFC3339, rows[j]["datetime"].(string))
if ei != nil || ej != nil {
return i > j
}
return ti.After(tj)
})
return rows
}
func (s *Store) StoredTransactionRows() int {
s.mu.RLock()
defer s.mu.RUnlock()
n := 0
for _, ad := range s.watchByHash {
n += len(ad.Txs)
}
return n
}
func (s *Store) Limits() (listLimit, retentionDays int) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.listLimit, s.retentionDays
}
func (s *Store) metricsSnapshot() (watched int, totalTx int, latestPayment string) {
s.mu.RLock()
defer s.mu.RUnlock()
watched = len(s.watchByHash)
var latest time.Time
for _, ad := range s.watchByHash {
totalTx += len(ad.Txs)
for _, tx := range ad.Txs {
if tx.Txid == "" {
continue
}
t, err := time.Parse(time.RFC3339, tx.Datetime)
if err != nil {
continue
}
if t.After(latest) {
latest = t
short := tx.Txid
if len(short) > 14 {
short = short[:8] + "…" + short[len(short)-4:]
}
latestPayment = fmt.Sprintf("%s %.8f DOGE %s", short, tx.AmountDoge, tx.Datetime)
}
}
}
if latestPayment == "" {
latestPayment = "—"
}
return
}
// ------- Dogebox metrics (same contract as CORE monitor) -------
type peerSession struct {
WorkerID int `json:"worker_id"`
Address string `json:"address"`
Connected bool `json:"connected"`
Updated time.Time `json:"updated"`
}
type MetricsCollector struct {
mu sync.RWMutex
byWorker map[int]peerSession // one logical session per P2P worker
mempoolTxIDs map[string]struct{}
maxMempoolIDs int
liveTxByID map[string]liveMempoolTx
outpointToTxs map[string]map[string]struct{}
txToOutpoints map[string][]string
}
type liveMempoolTx struct {
Txid string `json:"txid"`
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
TrackedMatch bool `json:"tracked_match"`
DoubleSpent bool `json:"double_spent"`
Address string `json:"address,omitempty"`
AmountDoge float64 `json:"amount_doge,omitempty"`
RawHex string `json:"raw_hex,omitempty"`
}
func NewMetricsCollector(maxMempool int) *MetricsCollector {
if maxMempool <= 0 {
maxMempool = 50000
}
return &MetricsCollector{
byWorker: make(map[int]peerSession),
mempoolTxIDs: make(map[string]struct{}),
maxMempoolIDs: maxMempool,
liveTxByID: make(map[string]liveMempoolTx),
outpointToTxs: make(map[string]map[string]struct{}),
txToOutpoints: make(map[string][]string),
}
}
func (m *MetricsCollector) SetPeerSession(workerID int, addr string, connected bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.byWorker[workerID] = peerSession{
WorkerID: workerID,
Address: addr,
Connected: connected,
Updated: time.Now().UTC(),
}
}
func (m *MetricsCollector) snapshotPeers() (rows []peerSession, connectedN int) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, s := range m.byWorker {
rows = append(rows, s)
if s.Connected {
connectedN++
}
}
return rows, connectedN
}
func (m *MetricsCollector) observeMempoolTxid(txid string) {
if txid == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now().UTC()
if _, ok := m.mempoolTxIDs[txid]; ok {
if row, ok2 := m.liveTxByID[txid]; ok2 {
row.LastSeen = now
m.liveTxByID[txid] = row
}
return
}
for len(m.mempoolTxIDs) >= m.maxMempoolIDs {
for k := range m.mempoolTxIDs {
m.dropTxLocked(k)
break
}
}
m.mempoolTxIDs[txid] = struct{}{}
if row, ok := m.liveTxByID[txid]; ok {
row.LastSeen = now
m.liveTxByID[txid] = row
} else {
m.liveTxByID[txid] = liveMempoolTx{
Txid: txid,
FirstSeen: now,
LastSeen: now,
}
}
}
func (m *MetricsCollector) dropTxLocked(txid string) {
delete(m.mempoolTxIDs, txid)
delete(m.liveTxByID, txid)
ops := m.txToOutpoints[txid]
delete(m.txToOutpoints, txid)
for _, op := range ops {
set := m.outpointToTxs[op]
if set == nil {
continue
}
delete(set, txid)
if len(set) == 0 {
delete(m.outpointToTxs, op)
}
}
}
func (m *MetricsCollector) RegisterTxInputs(txid string, inputs []txInputOutpoint) []string {
txid = strings.TrimSpace(txid)
if txid == "" || len(inputs) == 0 {
return nil
}
m.mu.Lock()
defer m.mu.Unlock()
conflicts := make(map[string]struct{})
ops := make([]string, 0, len(inputs))
for _, in := range inputs {
if in.prevTxid == "" {
continue
}
op := in.prevTxid + ":" + strconv.FormatUint(uint64(in.vout), 10)
ops = append(ops, op)
set := m.outpointToTxs[op]
if set == nil {
set = make(map[string]struct{})
m.outpointToTxs[op] = set
}
for other := range set {
if other != txid {
conflicts[other] = struct{}{}
conflicts[txid] = struct{}{}
}
}
set[txid] = struct{}{}
}
if len(ops) > 0 {
m.txToOutpoints[txid] = ops
}
if len(conflicts) == 0 {
return nil
}
now := time.Now().UTC()
out := make([]string, 0, len(conflicts))
for id := range conflicts {
row, ok := m.liveTxByID[id]
if !ok {
row = liveMempoolTx{Txid: id, FirstSeen: now, LastSeen: now}
}
row.DoubleSpent = true
row.LastSeen = now
m.liveTxByID[id] = row
out = append(out, id)
}
return out
}
func (m *MetricsCollector) IsDoubleSpent(txid string) bool {
m.mu.RLock()
defer m.mu.RUnlock()
row, ok := m.liveTxByID[strings.TrimSpace(txid)]
return ok && row.DoubleSpent
}
func (m *MetricsCollector) markTrackedHit(txid, address string, amountDoge float64) {
if txid == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.liveTxByID[txid]
if !ok {
now := time.Now().UTC()
row = liveMempoolTx{
Txid: txid,
FirstSeen: now,
LastSeen: now,
}
}
row.TrackedMatch = true
if a := strings.TrimSpace(address); a != "" && strings.TrimSpace(row.Address) == "" {
row.Address = a
}
if amountDoge > 0 {
// One tx can pay multiple watched addresses in the same wallet; sum credits for dashboard/list rows.
row.AmountDoge += amountDoge
}
m.liveTxByID[txid] = row
}
func (m *MetricsCollector) setRawHex(txid, rawHex string) {
txid = strings.TrimSpace(txid)
rawHex = strings.TrimSpace(rawHex)
if txid == "" || rawHex == "" {
return
}
m.mu.Lock()
defer m.mu.Unlock()
row, ok := m.liveTxByID[txid]
if !ok {
now := time.Now().UTC()
row = liveMempoolTx{
Txid: txid,
FirstSeen: now,
LastSeen: now,
}
}
row.LastSeen = time.Now().UTC()
row.RawHex = rawHex
m.liveTxByID[txid] = row
}
func (m *MetricsCollector) snapshot() (mempoolN int) {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.mempoolTxIDs)
}
// snapshotLiveMempool returns tx rows still seen "recently enough" to be considered live.
// Without node-level eviction notifications, we use a short freshness window.
func (m *MetricsCollector) snapshotLiveMempool(limit int) []map[string]any {
if limit <= 0 {
limit = 200
}
cutoff := time.Now().UTC().Add(-2 * time.Minute)
m.mu.Lock()
defer m.mu.Unlock()
for txid, row := range m.liveTxByID {
if row.LastSeen.Before(cutoff) {
m.dropTxLocked(txid)
}
}
rows := make([]liveMempoolTx, 0, len(m.liveTxByID))
for _, row := range m.liveTxByID {
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
return rows[i].LastSeen.After(rows[j].LastSeen)
})
if len(rows) > limit {
rows = rows[:limit]
}
out := make([]map[string]any, 0, len(rows))
for _, row := range rows {
out = append(out, map[string]any{
"txid": row.Txid,
"first_seen": row.FirstSeen.Format(time.RFC3339),
"last_seen": row.LastSeen.Format(time.RFC3339),
"tracked_match": row.TrackedMatch,
"double_spent": row.DoubleSpent,
"address": row.Address,
"amount_doge": row.AmountDoge,
"raw_hex": row.RawHex,
})
}
return out
}
func submitDogeboxMetrics(store *Store, col *MetricsCollector) {
host := strings.TrimSpace(os.Getenv("DBX_HOST"))
port := strings.TrimSpace(os.Getenv("DBX_PORT"))
if host == "" || port == "" {
return
}
watched, totalTx, latestPay := store.metricsSnapshot()
mempoolN := col.snapshot()
peerRows, nConn := col.snapshotPeers()
p2pConnected := "no"
if nConn > 0 {
p2pConnected = "yes"
}
peerDetail := fmt.Sprintf("connected_workers=%d", nConn)
if len(peerRows) > 0 {
var b strings.Builder
for _, pr := range peerRows {
st := "idle"
if pr.Connected {
st = "live"
}
if b.Len() > 0 {
b.WriteString("; ")
}
fmt.Fprintf(&b, "w%d:%s:%s", pr.WorkerID, st, pr.Address)
}
peerDetail = b.String()
}
payload := map[string]interface{}{
"tracked_transactions": map[string]interface{}{"value": totalTx},
"watched_addresses": map[string]interface{}{"value": watched},
"mempool_tx_count": map[string]interface{}{"value": mempoolN},
"p2p_connected": map[string]interface{}{"value": p2pConnected},
"p2p_peer_detail": map[string]interface{}{"value": peerDetail},
"latest_tracked_payment": map[string]interface{}{"value": latestPay},
}
body, err := json.Marshal(payload)
if err != nil {
log.Printf("[MTR-METRICS] marshal: %v", err)
return
}
url := fmt.Sprintf("http://%s:%s/dbx/metrics", host, port)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
log.Printf("[MTR-METRICS] request: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("[MTR-METRICS] post: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
log.Printf("[MTR-METRICS] status=%d body=%s", resp.StatusCode, string(b))
}
}
// ------- P2P watcher -------
type ProcessedSet struct {
mu sync.Mutex
m map[string]struct{}
order []string
cap int
}
type PaymentCallbackPayload struct {
Address string `json:"address"`
Txid string `json:"txid"`
AmountDoge float64 `json:"payment_amount"`
Datetime string `json:"datetime"`
}
func notifyCallback(callbackURL string, payload PaymentCallbackPayload) {
u := strings.TrimSpace(callbackURL)
if u == "" {
return
}
body, err := json.Marshal(payload)
if err != nil {
return
}
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(body))
if err != nil {
log.Printf("[MTR-CB] invalid callback url=%q err=%v", u, err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "MemeTracker/1.0")
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("[MTR-CB] notify failed url=%q txid=%s err=%v", u, payload.Txid, err)
return
}
_ = resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Printf("[MTR-CB] notify non-2xx url=%q txid=%s status=%d", u, payload.Txid, resp.StatusCode)
}
}
func NewProcessedSet() *ProcessedSet {
return &ProcessedSet{
m: make(map[string]struct{}),
cap: defaultProcessedCap,
}
}
func (ps *ProcessedSet) Has(k string) bool {
ps.mu.Lock()
defer ps.mu.Unlock()
_, ok := ps.m[k]
return ok
}
func (ps *ProcessedSet) Add(k string) {
ps.mu.Lock()
defer ps.mu.Unlock()
if _, ok := ps.m[k]; ok {
return
}
ps.m[k] = struct{}{}
ps.order = append(ps.order, k)
if ps.cap <= 0 {
ps.cap = defaultProcessedCap
}
for len(ps.order) > ps.cap {
old := ps.order[0]
ps.order = ps.order[1:]
delete(ps.m, old)
}
}
func (ps *ProcessedSet) Remove(k string) {
ps.mu.Lock()
defer ps.mu.Unlock()
delete(ps.m, k)
}
func chooseSeeds(network string) ([]string, int) {
if strings.ToLower(network) == "mainnet" {
return mainnetDNSSeeds, 22556
}
// Simplified testnet support.
return []string{"seed.testnet.dogecoin.org"}, 44556
}
func shufflePeerIPs(workerID int, ips []string) []string {
if len(ips) <= 1 {
return ips
}
out := make([]string, len(ips))
copy(out, ips)
rnd := mrand.New(mrand.NewSource(time.Now().UnixNano() + int64(workerID)*1_000_003))
rnd.Shuffle(len(out), func(i, j int) { out[i], out[j] = out[j], out[i] })
return out
}
// mempoolSniffer runs several parallel P2P sessions (like the arcade pup rotating seeds/peers)
// so inv/getdata gossip reaches MemeTracker faster and more reliably than a single connection.
// Closing stop unblocks workers between peers (active sessions may finish naturally up to SESSION_SEC).
func mempoolSniffer(store *Store, network string, p2pHost string, p2pPort int, p2pLog int, mcol *MetricsCollector, processed *ProcessedSet, parallel int, stop <-chan struct{}) {
if parallel < 1 {
parallel = 1
}
if parallel > 8 {
parallel = 8
}
for w := 0; w < parallel; w++ {
go mempoolP2PWorker(w, parallel, store, network, p2pHost, p2pPort, p2pLog, mcol, processed, stop)
}
}
func sleepOrStop(d time.Duration, stop <-chan struct{}) bool {
select {
case <-stop:
return true
case <-time.After(d):
return false
}
}
func mempoolP2PWorker(workerID, parallel int, store *Store, network, p2pHost string, p2pPort, p2pLog int, mcol *MetricsCollector, processed *ProcessedSet, stop <-chan struct{}) {
seeds, defaultPort := chooseSeeds(network)
if p2pPort == 0 {
p2pPort = defaultPort
}
if p2pHost != "" {
seeds = []string{p2pHost}
}
logf := func(format string, args ...any) {
if p2pLog <= 0 {
return
}
log.Printf("[MTR-P2P] [w%d] "+format, append([]any{workerID}, args...)...)
}
logv := func(format string, args ...any) {
if p2pLog < 2 {
return
}
log.Printf("[MTR-P2P] [w%d:v] "+format, append([]any{workerID}, args...)...)
}
for round := 0; ; round++ {
select {
case <-stop:
return
default:
}
seedHost := seeds[(round*parallel+workerID)%len(seeds)]
ips, err := net.LookupHost(seedHost)
if err != nil || len(ips) == 0 {
log.Printf("[MTR-P2P] [w%d] DNS resolve failed seed=%s:%d err=%v", workerID, seedHost, p2pPort, err)
if sleepOrStop(3*time.Second, stop) {
return
}
continue
}
if len(ips) > 12 {
ips = ips[:12]
}
// Stride peers by worker so parallel goroutines do not all dial the same IP at once
// (peers often drop duplicate inbound links from the same host).
shuffled := shufflePeerIPs(workerID, ips)
for idx := workerID; idx < len(shuffled); idx += parallel {
select {
case <-stop:
return
default:
}
peer := shuffled[idx]
if peer == "" {
continue
}
logf("connecting TCP %s:%d …", peer, p2pPort)
conn, err := net.DialTimeout("tcp", net.JoinHostPort(peer, strconv.Itoa(p2pPort)), 8*time.Second)
if err != nil {
logf("connect failed: %v", err)
mcol.SetPeerSession(workerID, "", false)
continue
}
// Do not SetDeadline on the whole conn: sessions run up to SESSION_SEC; a short
// absolute deadline caused peers to be abandoned and payments missed.
_ = conn.SetDeadline(time.Time{})
stateLastPeer := net.JoinHostPort(peer, strconv.Itoa(p2pPort))
mcol.SetPeerSession(workerID, stateLastPeer, true)
memetrackerP2PSession(conn, stateLastPeer, store, processed, mcol, p2pPort, logf, logv)
mcol.SetPeerSession(workerID, stateLastPeer, false)
_ = conn.Close()
}
if sleepOrStop(5*time.Second, stop) {
return
}
}
}
func memetrackerP2PSession(conn net.Conn, stateLastPeer string, store *Store, processed *ProcessedSet, mcol *MetricsCollector, p2pPort int, logf, logv func(string, ...any)) {
gotVerack := false
mempoolSent := false
lastMempoolResync := time.Time{}
start := time.Now()
var sessionExit error // set on non-timeout read/write failure or bad checksum
logf("connected peer=%s handshake start (Dogecoin P2P magic_u32le=0x%x wire_magic_4b_le_hex=%s)", stateLastPeer, MAGIC, dogeMagicWireHexLE())
verOut := buildVersionPayload(p2pPort)
verMsg := buildMessage("version", verOut)
logf("sending VERSION cmd payload_len=%d total_msg_bytes=%d — %s", len(verOut), len(verMsg), summarizeOutgoingVersionPayload(verOut, p2pPort))
logv("outgoing version raw payload hex (first 128b)=%s", hexSnippet(verOut, 128))
logv("message framing: all header fields little-endian except command is 12-byte ASCII null-padded; payload length u32le; checksum=first4bytes(sha256(sha256(payload)))")
_ = conn.SetDeadline(time.Time{})
_ = conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
_, werr := conn.Write(verMsg)
_ = conn.SetWriteDeadline(time.Time{})
if werr != nil {
logf("write version failed: %v", werr)
return
}
logv("wrote version message ok")
readLoop:
// Compare to time.Duration seconds — bare SESSION_SEC (300) is converted to 300ns, not 300s.
for time.Since(start) < time.Duration(SESSION_SEC)*time.Second {
_ = conn.SetReadDeadline(time.Now().Add(P2P_READ_IDLE_SEC * time.Second))
header, err := readExact(conn, 24)
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
_ = conn.SetReadDeadline(time.Time{})
logv("read deadline (%ds idle) no header yet — gotVerack=%v mempoolSent=%v", P2P_READ_IDLE_SEC, gotVerack, mempoolSent)
if gotVerack && mempoolSent {
if store.takeMempoolKick() {
_, _ = conn.Write(buildMessage("mempool", nil))
lastMempoolResync = time.Now()
logf("mempool (after /track/ or resync request)")
}
nw := store.watcherCount()
interval := MEMPOOL_RESYNC_SEC
if nw > 0 {
interval = MEMPOOL_WATCHER_SEC
}
if time.Since(lastMempoolResync) >= time.Duration(interval)*time.Second {
_, _ = conn.Write(buildMessage("mempool", nil))
lastMempoolResync = time.Now()
logf("mempool periodic watchers=%d interval=%ds", nw, interval)
}
}
continue
}
sessionExit = err
logf("read message header failed peer=%s err=%v (gotVerack=%v mempoolSent=%v)", stateLastPeer, err, gotVerack, mempoolSent)
logv("header read fatal: %#v", err)
break readLoop
}
_ = conn.SetReadDeadline(time.Time{})
magic := binary.LittleEndian.Uint32(header[0:4])
cmdRaw := header[4:16]
cmd := strings.TrimRight(string(cmdRaw), "\x00")
size := binary.LittleEndian.Uint32(header[16:20])
chkWire := header[20:24]
logv("recv header24: magic_u32le=0x%x cmd12=%q payload_len_u32le=%d checksum4=%x cmd_raw_bytes=%q",
magic, cmd, size, chkWire, hex.EncodeToString(cmdRaw))
if magic != uint32(MAGIC) {
logf("wrong magic peer=%s got_u32le=0x%x want_u32le=0x%x (LE bytes got_hex=%s want_hex=%s) — skipping 24b, stream may be misaligned (TLS/wrong chain?)",
stateLastPeer, magic, MAGIC, hex.EncodeToString(header[0:4]), dogeMagicWireHexLE())
logv("full_header24_hex=%s", hex.EncodeToString(header))
continue
}
if size > MAX_P2P_PAYLOAD {
logf("reject absurd payload_len peer=%s cmd=%s size=%d", stateLastPeer, cmd, size)
sessionExit = fmt.Errorf("absurd payload size %d", size)
break readLoop
}
payload := []byte{}
if size > 0 {
logv("reading payload %d bytes for cmd=%s", size, cmd)
payload, err = readExact(conn, int(size))
if err != nil {
sessionExit = err
logf("read payload failed peer=%s cmd=%s want=%d err=%v", stateLastPeer, cmd, size, err)
logv("payload partial hex=%s", hexSnippet(payload, 64))
break readLoop
}
}
hash2 := sha256d(payload)
wantSum := hash2[:4]
if !bytes.Equal(chkWire, wantSum) {
sessionExit = fmt.Errorf("checksum mismatch")
logf("P2P checksum mismatch peer=%s cmd=%s payload_len=%d wire_checksum4=%x computed_double_sha256_4=%x — closing",
stateLastPeer, cmd, len(payload), chkWire, wantSum)
logv("payload_head_hex=%s", hexSnippet(payload, 256))
break readLoop
}
logv("checksum ok cmd=%s payload_len=%d", cmd, len(payload))
switch cmd {
case "version":
logf("recv VERSION from peer=%s — %s", stateLastPeer, summarizePeerVersionPayload(payload))
logv("peer version payload head hex=%s", hexSnippet(payload, 256))
ack := buildMessage("verack", nil)
_, werr := conn.Write(ack)
if werr != nil {
sessionExit = werr
logf("write verack failed: %v", werr)
break readLoop
}
logf("sent VERACK (%d bytes) awaiting peer VERACK", len(ack))
logv("verack message hex=%s", hex.EncodeToString(ack))
case "verack":
gotVerack = true
logf("recv VERACK from peer=%s — handshake version negotiation complete (little-endian fields were already validated on VERSION)", stateLastPeer)
if !mempoolSent {
mem := buildMessage("mempool", nil)
_, werr := conn.Write(mem)
if werr != nil {
sessionExit = werr
logf("write mempool failed: %v", werr)
break readLoop
}
mempoolSent = true
lastMempoolResync = time.Now()
logf("sent MEMPOOL request to peer=%s (%d bytes) — expecting inv/tx for relayed txs", stateLastPeer, len(mem))
logv("mempool msg hex=%s", hex.EncodeToString(mem))
}
case "ping":
logv("recv PING payload_len=%d", len(payload))
pong := buildMessage("pong", payload)
_, werr := conn.Write(pong)
if werr != nil {
sessionExit = werr
logf("write pong failed: %v", werr)
break readLoop
}
logv("sent PONG %d bytes", len(pong))
case "tx":
txidEarly := txidHex(payload)
mcol.observeMempoolTxid(txidEarly)
mcol.setRawHex(txidEarly, hex.EncodeToString(payload))
logv("recv TX raw len=%d txid_le=%s", len(payload), txidEarly)
store.mu.RLock()
hasWatchers := len(store.watchByHash) > 0
store.mu.RUnlock()
if !hasWatchers {
logv("tx %s ignored (no watched addresses)", txidEarly)
continue
}
outs, _, err := parseTxOutputs(payload)
if err != nil {
log.Printf("[MTR-P2P] parse tx %s: %v", txidEarly, err)
continue
}
txid := txidHex(payload)
wtxid := wtxidHex(payload)
inputs, _ := parseTxInputOutpoints(payload)
conflictedTxids := mcol.RegisterTxInputs(txid, inputs)
if len(conflictedTxids) > 0 {
for _, ctid := range conflictedTxids {
_ = store.MarkTxDoubleSpent(ctid)
}
logf("double spend conflict detected peer=%s txid=%s conflicts=%d", stateLastPeer, txid, len(conflictedTxids))
}
if processed.Has(txid) || processed.Has(wtxid) {
continue
}
amtByHash := make(map[string]int64)
store.mu.RLock()
for _, o := range outs {
h160, ok := scriptPubKeyHash160(o.script)
if !ok {
continue
}
hashHex := hex.EncodeToString(h160)
if _, watching := store.watchByHash[hashHex]; watching {
amtByHash[hashHex] += o.valueSats
}
}
store.mu.RUnlock()
if len(amtByHash) == 0 {
continue
}
dt := time.Now().UTC()
matchedAny := false
for hashHex, sats := range amtByHash {
if sats <= 0 {
continue
}
amountDoge := float64(sats) / 1e8
isDS := mcol.IsDoubleSpent(txid)
if store.AddTx(hashHex, txid, dt, amountDoge, isDS) {
matchedAny = true
addr, cbURL, ok := store.CallbackTarget(hashHex)
mcol.markTrackedHit(txid, addr, amountDoge)
if ok && cbURL != "" {
go notifyCallback(cbURL, PaymentCallbackPayload{
Address: addr,
Txid: txid,
AmountDoge: amountDoge,
Datetime: dt.Format(time.RFC3339),
})
}
}
}
if matchedAny {
processed.Add(txid)
processed.Add(wtxid)
logf("tx matched watched address(es) peer=%s txid=%s", stateLastPeer, txid)
} else {
logv("tx %s had watched outputs but nothing new stored (e.g. duplicate)", txid)
}
case "inv":
if !gotVerack || !mempoolSent {
logv("INV dropped (handshake incomplete) gotVerack=%v mempoolSent=%v", gotVerack, mempoolSent)
continue
}
if len(payload) == 0 {
logv("INV empty payload")
continue
}
invs, err := parseInvPayload(payload)
if err != nil {
logf("INV parse error peer=%s: %v", stateLastPeer, err)
logv("inv payload head hex=%s", hexSnippet(payload, 128))
continue
}
txLike := 0
for _, it := range invs {
if invTypeIsTx(it.invType) {
txLike++
}
}
logf("recv INV peer=%s entries=%d tx_like=%d (inv vector: type u32le + hash 32 bytes wire order per entry; hash is internal byte order)",
stateLastPeer, len(invs), txLike)
logv("inv payload_len=%d parse_ok entries=%d", len(payload), len(invs))
for _, it := range invs {
if invTypeIsTx(it.invType) {
mcol.observeMempoolTxid(reverseBytesToHex(it.hash))
}
}
store.mu.RLock()
hasWatchers := len(store.watchByHash) > 0
store.mu.RUnlock()
if !hasWatchers {
continue
}
// Same strategy as arcade/server.py: getdata all new tx invs (dedupe by inv type+hash), not only when already seen as txid.
fetch := make([]invItem, 0, MAX_TX_FETCH_INV)
invSeen := make(map[string]struct{})
for _, it := range invs {
if !invTypeIsTx(it.invType) {
continue
}
invKey := fmt.Sprintf("%x:%x", it.invType, it.hash)
if _, ok := invSeen[invKey]; ok {
continue
}
invSeen[invKey] = struct{}{}
txHashHex := reverseBytesToHex(it.hash)
if processed.Has(txHashHex) {
continue
}
fetch = append(fetch, it)
if len(fetch) >= MAX_TX_FETCH_INV {
break
}
}
getdataFail := false
for i := 0; i < len(fetch); i += GETDATA_BATCH {
j := i + GETDATA_BATCH
if j > len(fetch) {
j = len(fetch)
}
pl := buildGetdataPayload(fetch[i:j])
gd := buildMessage("getdata", pl)
if _, werr := conn.Write(gd); werr != nil {
sessionExit = werr
logf("write getdata failed peer=%s: %v", stateLastPeer, werr)
getdataFail = true
break
}
logv("sent GETDATA batch [%d:%d) n=%d msg_bytes=%d (tx hashes in getdata use same wire order as inv)", i, j, j-i, len(gd))
}
if getdataFail {
break readLoop
}
if len(fetch) > 0 {
logf("getdata dispatched total_tx_inv=%d peer=%s", len(fetch), stateLastPeer)
}
default:
logf("recv cmd=%q peer=%s payload_len=%d (magic+checksum validated; not handled in switch)", cmd, stateLastPeer, len(payload))
logv("unhandled payload head hex=%s", hexSnippet(payload, 128))
}
if gotVerack && mempoolSent {
nw := store.watcherCount()
interval := MEMPOOL_RESYNC_SEC
if nw > 0 {
interval = MEMPOOL_WATCHER_SEC
}
if time.Since(lastMempoolResync) >= time.Duration(interval)*time.Second {
_, werr := conn.Write(buildMessage("mempool", nil))
if werr != nil {
sessionExit = werr
logf("periodic mempool write failed: %v", werr)
break readLoop
}
lastMempoolResync = time.Now()
logv("periodic MEMPOOL resent interval=%ds watchers=%d", interval, nw)
}
}
}
_ = conn.SetReadDeadline(time.Time{})
if sessionExit != nil {
logf("session end peer=%s gotVerack=%v mempoolSent=%v duration=%s err=%v",
stateLastPeer, gotVerack, mempoolSent, time.Since(start).Truncate(time.Millisecond), sessionExit)
} else {
logf("session end peer=%s gotVerack=%v mempoolSent=%v duration=%s (session cap %ds, no wire error)",
stateLastPeer, gotVerack, mempoolSent, time.Since(start).Truncate(time.Millisecond), SESSION_SEC)
}
}
func reverseBytesToHex(b []byte) string {
// Used to convert inventory hashes to the same endianness as txidHex() returns.
r := make([]byte, len(b))
for i := 0; i < len(b); i++ {
r[i] = b[len(b)-1-i]
}
return hex.EncodeToString(r)
}
// ------- HTTP API -------
func deriveCallbackURL(r *http.Request, address string) string {
// Preferred explicit callback from caller.
qCB := strings.TrimSpace(r.URL.Query().Get("callback"))
if qCB != "" {
if u, err := url.ParseRequestURI(qCB); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
return qCB
}
}
hCB := strings.TrimSpace(r.Header.Get("X-Callback-Url"))
if hCB != "" {
if u, err := url.ParseRequestURI(hCB); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
return hCB
}
}
// Fallback: infer from requester IP and configurable callback port.
host := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
if host != "" {
if idx := strings.Index(host, ","); idx >= 0 {
host = strings.TrimSpace(host[:idx])
}
}
if host == "" {
h, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err == nil {
host = strings.TrimSpace(h)
}
}
if host == "" {
return ""
}
scheme := strings.TrimSpace(envString("MTR_CALLBACK_SCHEME", "http"))
if scheme != "https" {
scheme = "http"
}
port := envInt("MTR_CALLBACK_PORT", 10001)
return fmt.Sprintf("%s://%s/%s/", scheme, net.JoinHostPort(host, strconv.Itoa(port)), url.PathEscape(address))
}
func defaultStorageDir() string {
if runtime.GOOS == "windows" {
d, err := os.UserConfigDir()
if err == nil && d != "" {
return filepath.Join(d, "MemeTracker", "data")
}
return filepath.Join(".", "memetracker-data")
}
if h, err := os.UserHomeDir(); err == nil && h != "" {
return filepath.Join(h, ".memetracker", "data")
}
return filepath.Join(".", "memetracker-data")
}
type diskSettings struct {
ListLimit int `json:"list_limit"`
RetentionDays int `json:"retention_days"`
}
func readDiskSettings(path string) (diskSettings, bool) {
b, err := os.ReadFile(path)
if err != nil {
return diskSettings{}, false
}
var s diskSettings
if json.Unmarshal(b, &s) != nil {
return diskSettings{}, false
}
return s, true
}
func writeDiskSettings(path string, s diskSettings) error {
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// MemeTrackerConfig is the full on-disk configuration (memetracker_config.json).
// P2P and retention jobs start only after this file validates as complete (on boot)
// or after POST /api/start.
type MemeTrackerConfig struct {
HTTPPort int `json:"http_port"`
HTTPBind string `json:"http_bind"`
Network string `json:"network"`
StorageDir string `json:"storage_dir"`
ListLimit int `json:"list_limit"`
RetentionDays int `json:"retention_days"`
P2PHost string `json:"p2p_host"`
P2PPort int `json:"p2p_port"`
P2PParallel int `json:"p2p_parallel"`
P2PLog int `json:"p2p_log"`
APIAllowedIPs []string `json:"api_allowed_ips,omitempty"` // empty or omitted = allow all IPs on /api/* and /track/*
}
func (c *MemeTrackerConfig) ApplyDefaults() {
if c.HTTPPort == 0 {
c.HTTPPort = 33555
}
c.HTTPBind = strings.TrimSpace(c.HTTPBind)
if c.HTTPBind == "" {
c.HTTPBind = "0.0.0.0"
}
c.Network = strings.TrimSpace(c.Network)
if c.Network == "" {
c.Network = "mainnet"
}
if c.ListLimit == 0 {
c.ListLimit = 10
}
if c.RetentionDays == 0 {
c.RetentionDays = 7
}
if c.P2PPort == 0 {
c.P2PPort = 22556
}
if c.P2PParallel == 0 {
c.P2PParallel = 1
}
if c.P2PLog < 0 {
c.P2PLog = 0
}
if c.P2PLog > 2 {
c.P2PLog = 2
}
}
func (c *MemeTrackerConfig) IsComplete() bool {
c.ApplyDefaults()
if c.HTTPPort < 1 || c.HTTPPort > 65535 {
return false
}
if strings.TrimSpace(c.HTTPBind) == "" {
return false
}
n := strings.ToLower(strings.TrimSpace(c.Network))
if n != "mainnet" && n != "testnet" {
return false
}
if c.ListLimit < 1 || c.RetentionDays < 1 {
return false
}
if c.P2PPort < 1 || c.P2PPort > 65535 {
return false
}
if c.P2PParallel < 1 || c.P2PParallel > 8 {
return false
}
return true
}
func normalizeAPIAllowedIPs(in []string) []string {
var out []string
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
out = append(out, s)
}
return out
}
// clientIPForAPI returns the client address for access control.
// Set MTR_TRUST_XFF=1 to use the first hop in X-Forwarded-For (only behind a trusted reverse proxy).
func clientIPForAPI(r *http.Request) string {
if strings.TrimSpace(os.Getenv("MTR_TRUST_XFF")) == "1" {
xff := strings.TrimSpace(r.Header.Get("X-Forwarded-For"))
if xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
}
host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr))
if err != nil {
return strings.TrimSpace(r.RemoteAddr)
}
return host
}
func ipAllowedForAPI(host string, rules []string) bool {
rules = normalizeAPIAllowedIPs(rules)
if len(rules) == 0 {
return true
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
for _, rule := range rules {
rule = strings.TrimSpace(rule)
if rule == "" {
continue
}
if strings.Contains(rule, "/") {
_, cidr, err := net.ParseCIDR(rule)
if err != nil {
continue
}
if cidr.Contains(ip) {
return true
}
continue
}
if rIP := net.ParseIP(rule); rIP != nil && rIP.Equal(ip) {
return true
}
}
return false
}
func apiPathNeedsIPCheck(path string) bool {
if strings.HasPrefix(path, "/api/") {
return true
}
if strings.HasPrefix(path, "/track/") {
return true
}
return false
}
func apiAccessMiddleware(app *appState, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !apiPathNeedsIPCheck(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
cfg := app.snapshotCfg()
cl := clientIPForAPI(r)
if cl == "" {
cl = "unknown"
}
if !ipAllowedForAPI(cl, cfg.APIAllowedIPs) {
writeJSONError(w, http.StatusForbidden, fmt.Sprintf("API access denied for IP %s; add this address or your subnet (CIDR) to api_allowed_ips in memetracker_config.json or the API access screen, or clear the list to allow all", cl))
return
}
next.ServeHTTP(w, r)
})
}
func writeMemeTrackerConfigFile(path string, c *MemeTrackerConfig) error {
cp := *c
cp.ApplyDefaults()
b, err := json.MarshalIndent(&cp, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
type appState struct {
mu sync.RWMutex
cfg MemeTrackerConfig
configPath string
dataDir string
store *Store
mcol *MetricsCollector
processed *ProcessedSet
settingsPath string
httpPort int
httpBind string
p2pMu sync.Mutex
p2pStarted bool
p2pStopCh chan struct{} // closed to signal P2P workers + metrics loop to exit
}
func runMetricsLoop(store *Store, col *MetricsCollector, stop <-chan struct{}) {
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
submitDogeboxMetrics(store, col)
}
}
}
func (a *appState) snapshotCfg() MemeTrackerConfig {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg
}
func (a *appState) setCfg(c MemeTrackerConfig) {
a.mu.Lock()
defer a.mu.Unlock()
a.cfg = c
}
func (a *appState) isP2PRunning() bool {
a.p2pMu.Lock()
defer a.p2pMu.Unlock()
return a.p2pStarted
}
func (a *appState) startP2P() {
a.p2pMu.Lock()
if a.p2pStarted {
a.p2pMu.Unlock()
return
}
stopCh := make(chan struct{})
a.p2pStopCh = stopCh
a.p2pStarted = true
a.p2pMu.Unlock()
cfg := a.snapshotCfg()
network := strings.ToLower(strings.TrimSpace(cfg.Network))
host := strings.TrimSpace(cfg.P2PHost)
go mempoolSniffer(a.store, network, host, cfg.P2PPort, cfg.P2PLog, a.mcol, a.processed, cfg.P2PParallel, stopCh)
go runMetricsLoop(a.store, a.mcol, stopCh)
log.Printf("[MTR] P2P mempool watcher started (network=%s, p2p_parallel=%d)", network, cfg.P2PParallel)
}
func (a *appState) stopP2P() {
a.p2pMu.Lock()
if !a.p2pStarted || a.p2pStopCh == nil {
a.p2pMu.Unlock()
return
}
ch := a.p2pStopCh
a.p2pStopCh = nil
a.p2pStarted = false
a.p2pMu.Unlock()
close(ch)
log.Printf("[MTR] P2P mempool watcher stop requested (workers exit between peer sessions)")
}
func openBrowser(urlStr string) {
if strings.TrimSpace(os.Getenv("MTR_NO_BROWSER")) != "" {
return
}
var cmd *exec.Cmd
switch runtime.GOOS {
case "windows":
cmd = exec.Command("cmd", "/c", "start", "", urlStr)
case "darwin":
cmd = exec.Command("open", urlStr)
default:
cmd = exec.Command("xdg-open", urlStr)
}
cmd.Stdout = nil
cmd.Stderr = nil
_ = cmd.Start()
}
func serveIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
b, err := staticFiles.ReadFile("static/index.html")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(b)
}
func serveLogo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
b, err := staticFiles.ReadFile("static/logo.png")
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(b)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeJSONError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func apiStatus(w http.ResponseWriter, r *http.Request, app *appState) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
cfg := app.snapshotCfg()
ll, rd := app.store.Limits()
peers, nConn := app.mcol.snapshotPeers()
peerOut := make([]map[string]any, 0, len(peers))
for _, p := range peers {
peerOut = append(peerOut, map[string]any{
"worker_id": p.WorkerID,
"address": p.Address,
"connected": p.Connected,
"updated": p.Updated.UTC().Format(time.RFC3339),
})
}
allowCopy := cfg.APIAllowedIPs
if allowCopy == nil {
allowCopy = []string{}
}
fc := map[string]any{
"http_port": cfg.HTTPPort,
"http_bind": cfg.HTTPBind,
"network": cfg.Network,
"storage_dir": app.dataDir,
"list_limit": cfg.ListLimit,
"retention_days": cfg.RetentionDays,
"p2p_host": cfg.P2PHost,
"p2p_port": cfg.P2PPort,
"p2p_parallel": cfg.P2PParallel,
"p2p_log": cfg.P2PLog,
"api_allowed_ips": allowCopy,
}
writeJSON(w, http.StatusOK, map[string]any{
"p2p_running": app.isP2PRunning(),
"memetracker_config_path": app.configPath,
"watched_addresses": app.store.watcherCount(),
"stored_transaction_rows": app.store.StoredTransactionRows(),
"mempool_tx_count": app.mcol.snapshot(),
"mempool_transactions": app.mcol.snapshotLiveMempool(250),
"peers_connected_count": nConn,
"peers": peerOut,
"addresses": app.store.ListAddressSnapshots(),
"transactions": app.store.FlattenTransactions(),
"full_config": fc,
"config": map[string]any{
"network": strings.ToLower(cfg.Network),
"mtr_http_bind": app.httpBind,
"mtr_http_port": app.httpPort,
"mtr_storage_dir": app.dataDir,
"p2p_host": cfg.P2PHost,
"p2p_port": cfg.P2PPort,
"p2p_parallel": cfg.P2PParallel,
"p2p_log": cfg.P2PLog,
"list_limit": ll,
"retention_days": rd,
"settings_file_note": "list_limit and retention_days sync to settings.json from the Configuration tab when saved",
},
})
}
func apiGetConfig(w http.ResponseWriter, r *http.Request, store *Store) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
ll, rd := store.Limits()
writeJSON(w, http.StatusOK, map[string]any{"list_limit": ll, "retention_days": rd})
}
func apiPostConfig(w http.ResponseWriter, r *http.Request, app *appState) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var body struct {
ListLimit int `json:"list_limit"`
RetentionDays int `json:"retention_days"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json")
return
}
if body.ListLimit < 1 || body.RetentionDays < 1 {
writeJSONError(w, http.StatusBadRequest, "list_limit and retention_days must be >= 1")
return
}
app.store.SetLimits(body.ListLimit, body.RetentionDays)
app.mu.Lock()
app.cfg.ListLimit = body.ListLimit
app.cfg.RetentionDays = body.RetentionDays
app.mu.Unlock()
if err := writeDiskSettings(app.settingsPath, diskSettings{ListLimit: body.ListLimit, RetentionDays: body.RetentionDays}); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func apiDeleteAddress(w http.ResponseWriter, r *http.Request, store *Store) {
if r.Method != http.MethodDelete {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
id := strings.TrimPrefix(r.URL.Path, "/api/addresses/")
id = strings.TrimSpace(strings.Trim(id, "/"))
if id == "" {
writeJSONError(w, http.StatusBadRequest, "missing id")
return
}
if !store.RemoveAddress(strings.ToLower(id)) {
writeJSONError(w, http.StatusNotFound, "address not tracked")
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func apiDeleteTransaction(w http.ResponseWriter, r *http.Request, store *Store, processed *ProcessedSet) {
if r.Method != http.MethodDelete {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
txid := strings.TrimSpace(r.URL.Query().Get("txid"))
hashHex := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("hash160_hex")))
if txid == "" || hashHex == "" {
writeJSONError(w, http.StatusBadRequest, "txid and hash160_hex required")
return
}
if !store.RemoveTx(hashHex, txid) {
writeJSONError(w, http.StatusNotFound, "transaction not found")
return
}
processed.Remove(txid)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func apiPostStart(w http.ResponseWriter, r *http.Request, app *appState) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var body MemeTrackerConfig
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json")
return
}
body.APIAllowedIPs = normalizeAPIAllowedIPs(body.APIAllowedIPs)
body.ApplyDefaults()
if !body.IsComplete() {
writeJSONError(w, http.StatusBadRequest, "incomplete configuration: need valid network (mainnet/testnet), ports, list_limit, retention_days, p2p_parallel 1–8")
return
}
newDataDir := filepath.Clean(strings.TrimSpace(body.StorageDir))
if newDataDir == "" {
newDataDir = app.dataDir
}
body.StorageDir = newDataDir
if newDataDir != app.dataDir {
if err := writeMemeTrackerConfigFile(app.configPath, &body); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"restart_required": true,
"message": "storage_dir does not match the running data directory; restart MemeTracker to apply. Config file was saved.",
"p2p_running": false,
})
return
}
if err := writeMemeTrackerConfigFile(app.configPath, &body); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
app.setCfg(body)
app.store.SetLimits(body.ListLimit, body.RetentionDays)
if err := writeDiskSettings(app.settingsPath, diskSettings{ListLimit: body.ListLimit, RetentionDays: body.RetentionDays}); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
app.startP2P()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "p2p_running": true})
}
func apiPostStop(w http.ResponseWriter, r *http.Request, app *appState) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
app.stopP2P()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "p2p_running": false})
}
func apiPostAllowlist(w http.ResponseWriter, r *http.Request, app *appState) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var body struct {
APIAllowedIPs []string `json:"api_allowed_ips"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid json")
return
}
normalized := normalizeAPIAllowedIPs(body.APIAllowedIPs)
app.mu.Lock()
app.cfg.APIAllowedIPs = normalized
cfgCopy := app.cfg
app.mu.Unlock()
if err := writeMemeTrackerConfigFile(app.configPath, &cfgCopy); err != nil {
writeJSONError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "api_allowed_ips": normalized})
}
// Options configures embedded mempool tracker storage and P2P (MemeTracker-compatible env still applies).
type Options struct {
StorageDir string
Network string // mainnet/testnet; empty uses MTR_NETWORK / file
P2PHost string
P2PPort int
P2PLog int
P2PParallel int
ListLimit int
RetentionDays int
}
// Engine runs MemeTracker P2P logic in-process (no separate HTTP server).
type Engine struct {
app *appState
}
// Start initializes storage and starts the P2P mempool watcher (embedded MemeTracker).
func Start(opts Options) (*Engine, error) {
log.SetOutput(os.Stderr)
storageDir := strings.TrimSpace(opts.StorageDir)
if storageDir == "" {
return nil, errors.New("mempooltracker: StorageDir required")
}
storageDir = filepath.Clean(storageDir)
configPath := filepath.Join(storageDir, "memetracker_config.json")
var fileCfg MemeTrackerConfig
configFileRead := false
if b, err := os.ReadFile(configPath); err == nil {
if json.Unmarshal(b, &fileCfg) == nil {
configFileRead = true
}
}
fileCfg.ApplyDefaults()
publicPort := envInt("MTR_HTTP_PORT", envInt("PUBLIC_PORT", 33555))
bindIP := envString("MTR_HTTP_BIND", envString("DBX_PUP_IP", "0.0.0.0"))
if configFileRead {
if fileCfg.HTTPPort > 0 {
publicPort = fileCfg.HTTPPort
}
if strings.TrimSpace(fileCfg.HTTPBind) != "" {
bindIP = fileCfg.HTTPBind
}
}
listLimit := envInt("MTR_LIST_LIMIT", envInt("LIST_LIMIT", 10))
retentionDays := envInt("MTR_RETENTION_DAYS", envInt("RETENTION_DAYS", 7))
network := strings.ToLower(envString("MTR_NETWORK", envString("NETWORK", "mainnet")))
p2pHost := strings.TrimSpace(envString("MTR_P2P_HOST", envString("P2P_HOST", "")))
p2pPort := envInt("MTR_P2P_PORT", envInt("P2P_PORT", 22556))
p2pLog := envInt("MTR_P2P_LOG", envInt("P2P_LOG", 1))
p2pParallel := envInt("MTR_P2P_PARALLEL", envInt("P2P_PARALLEL", 1))
if configFileRead {
if fileCfg.ListLimit > 0 {
listLimit = fileCfg.ListLimit
}
if fileCfg.RetentionDays > 0 {
retentionDays = fileCfg.RetentionDays
}
if strings.TrimSpace(fileCfg.Network) != "" {
network = strings.ToLower(fileCfg.Network)
}
if fileCfg.P2PPort > 0 {
p2pPort = fileCfg.P2PPort
}
p2pParallel = fileCfg.P2PParallel
p2pLog = fileCfg.P2PLog
if strings.TrimSpace(fileCfg.P2PHost) != "" {
p2pHost = strings.TrimSpace(fileCfg.P2PHost)
}
fileCfg.ApplyDefaults()
}
if opts.ListLimit > 0 {
listLimit = opts.ListLimit
}
if opts.RetentionDays > 0 {
retentionDays = opts.RetentionDays
}
if strings.TrimSpace(opts.Network) != "" {
network = strings.ToLower(strings.TrimSpace(opts.Network))
}
if opts.P2PPort > 0 {
p2pPort = opts.P2PPort
}
if opts.P2PParallel > 0 {
p2pParallel = opts.P2PParallel
}
if strings.TrimSpace(opts.P2PHost) != "" {
p2pHost = strings.TrimSpace(opts.P2PHost)
}
settingsPath := filepath.Join(storageDir, "settings.json")
if ds, ok := readDiskSettings(settingsPath); ok {
if ds.ListLimit >= 1 {
listLimit = ds.ListLimit
}
if ds.RetentionDays >= 1 {
retentionDays = ds.RetentionDays
}
}
store, err := NewStore(storageDir, listLimit, retentionDays)
if err != nil {
return nil, err
}
go func() {
t := time.NewTicker(1 * time.Minute)
defer t.Stop()
for now := range t.C {
store.mu.Lock()
store.purgeExpiredLocked(now.UTC())
store.mu.Unlock()
}
}()
mcol := NewMetricsCollector(5000)
processed := NewProcessedSet()
effectiveCfg := MemeTrackerConfig{
HTTPPort: publicPort,
HTTPBind: bindIP,
Network: network,
StorageDir: storageDir,
ListLimit: listLimit,
RetentionDays: retentionDays,
P2PHost: p2pHost,
P2PPort: p2pPort,
P2PParallel: p2pParallel,
P2PLog: p2pLog,
}
effectiveCfg.ApplyDefaults()
if configFileRead {
effectiveCfg.APIAllowedIPs = normalizeAPIAllowedIPs(fileCfg.APIAllowedIPs)
}
app := &appState{
cfg: effectiveCfg,
configPath: configPath,
dataDir: storageDir,
store: store,
mcol: mcol,
processed: processed,
settingsPath: settingsPath,
httpPort: publicPort,
httpBind: bindIP,
}
app.startP2P()
ll, rd := store.Limits()
log.Printf("[MTR] embedded engine (storage=%s p2p_running=%v config=%s)", storageDir, app.isP2PRunning(), configPath)
log.Printf("[MTR] effective listLimit=%d retentionDays=%d (network=%s)", ll, rd, network)
return &Engine{app: app}, nil
}
// PendingMempoolDOGE registers the address with the watcher and sums mempool amounts for txs not in confirmedTxids.
func (e *Engine) PendingMempoolDOGE(address string, confirmedTxids map[string]struct{}) (float64, error) {
if e == nil || e.app == nil {
return 0, nil
}
if !e.app.isP2PRunning() {
return 0, errors.New("mempool watcher not running")
}
addr := strings.TrimSpace(address)
if addr == "" {
return 0, nil
}
netw := strings.ToLower(e.app.snapshotCfg().Network)
hash160, err := decodePayoutToHash160(addr, netw)
if err != nil {
return 0, err
}
_, recents, _, err := e.app.store.UpsertTracking(addr, hash160, "")
if err != nil {
return 0, err
}
var sum float64
for _, tx := range recents {
id := strings.ToLower(strings.TrimSpace(tx.Txid))
if id == "" {
continue
}
if _, ok := confirmedTxids[id]; ok {
continue
}
sum += tx.AmountDoge
}
return sum, nil
}
// Network returns the configured chain (mainnet/testnet).
func (e *Engine) Network() string {
if e == nil || e.app == nil {
return ""
}
return strings.ToLower(strings.TrimSpace(e.app.snapshotCfg().Network))
}
// DashboardSnapshot exposes mempool relay visibility and worker sessions for the pq-wallet UI (no standalone MemeTracker HTTP server).
func (e *Engine) DashboardSnapshot() (mempoolCount int, live []map[string]any, workers []map[string]any, workersConnected int) {
if e == nil || e.app == nil || !e.app.isP2PRunning() {
return 0, nil, nil, 0
}
mempoolCount = e.app.mcol.snapshot()
live = e.app.mcol.snapshotLiveMempool(80)
rows, nConn := e.app.mcol.snapshotPeers()
sort.Slice(rows, func(i, j int) bool { return rows[i].WorkerID < rows[j].WorkerID })
for _, p := range rows {
workers = append(workers, map[string]any{
"worker_id": p.WorkerID,
"address": p.Address,
"connected": p.Connected,
"updated": p.Updated.UTC().Format(time.RFC3339),
})
}
return mempoolCount, live, workers, nConn
}
// Stop shuts down P2P mempool workers (e.g. before switching networks).
func (e *Engine) Stop() {
if e == nil || e.app == nil {
return
}
e.app.stopP2P()
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>MemeTracker — Dogecoin mempool watcher</title>
<link rel="icon" href="/logo.png" type="image/png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Comic+Neue:ital,wght@0,300;0,400;0,700;1,300;1,400&display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"/>
<style>
:root {
color-scheme: dark;
--bg: #0c1018;
--surface: #151d2c;
--surface2: #1c2638;
--text: #e8edf5;
--muted: #8b9cb8;
--accent: #f2a900;
--accent-dim: #c2a633;
--danger: #e85d5d;
--ok: #5dbe8a;
--border: rgba(255,255,255,.08);
--radius: 10px;
--font: "Comic Neue", "Comic Sans MS", cursive, system-ui, sans-serif;
--sidebar-w: 280px;
--sidebar-rail: 72px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
font-family: var(--font);
background: radial-gradient(1000px 500px at 0% 0%, rgba(242,169,0,.08), transparent),
radial-gradient(800px 400px at 100% 0%, rgba(194,166,51,.06), transparent), var(--bg);
color: var(--text);
line-height: 1.5;
}
.nav-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
z-index: 90;
opacity: 0;
transition: opacity .2s;
}
.nav-backdrop.visible { display: block; opacity: 1; }
.sidebar {
position: fixed;
top: 0;
left: 0;
width: min(var(--sidebar-w), 88vw);
height: 100vh;
background: var(--surface);
border-right: 1px solid var(--border);
z-index: 100;
transform: translateX(-100%);
transition: transform .22s ease;
display: flex;
flex-direction: column;
padding: 1rem 0 2rem;
overflow-y: auto;
}
.sidebar.open { transform: translateX(0); }
.material-icons {
font-family: "Material Icons";
font-weight: normal;
font-style: normal;
font-size: 1.25rem;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
vertical-align: -0.2em;
-webkit-font-smoothing: antialiased;
}
.btn .material-icons { font-size: 1.1rem; vertical-align: middle; }
.btn-sm .material-icons { font-size: 1rem; }
.btn-with-icon, .side-nav button { display: inline-flex; align-items: center; gap: .45rem; }
.side-nav .material-icons.side-nav-ico { font-size: 1.35rem; opacity: .95; flex-shrink: 0; }
.sidebar-brand {
padding: 0 1rem 1rem;
border-bottom: 1px solid var(--border);
margin-bottom: .75rem;
}
.sidebar-brand img { width: 40px; height: 40px; vertical-align: middle; margin-right: .5rem; }
.sidebar-brand strong { font-size: 1.05rem; display: block; margin-top: .35rem; }
.sidebar-brand span { font-size: .75rem; color: var(--muted); }
.sidebar-close {
position: absolute;
top: .5rem;
right: .5rem;
background: transparent;
border: none;
color: var(--muted);
cursor: pointer;
padding: .25rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.sidebar-close:hover { color: var(--text); }
.sidebar-close .material-icons { font-size: 1.5rem; }
.side-nav { display: flex; flex-direction: column; gap: .15rem; padding: 0 .5rem; }
.side-nav button {
justify-content: flex-start;
padding: .65rem 1rem;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text);
font-size: .92rem;
cursor: pointer;
width: 100%;
font-family: inherit;
}
.side-nav button:hover { background: var(--surface2); }
.side-nav button.active { background: rgba(242,169,0,.18); color: var(--accent); font-weight: 600; }
.page-wrap { min-height: 100vh; display: flex; flex-direction: column; }
header.topbar {
display: flex;
align-items: center;
gap: .75rem;
padding: .75rem 1rem;
border-bottom: 1px solid var(--border);
background: rgba(21,29,44,.88);
backdrop-filter: blur(8px);
position: sticky;
top: 0;
z-index: 50;
}
.nav-hamburger, .nav-sidebar-toggle {
flex-shrink: 0;
width: 42px;
height: 42px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.nav-hamburger:hover, .nav-sidebar-toggle:hover { border-color: var(--accent-dim); }
.nav-sidebar-toggle { display: none; }
.nav-sidebar-toggle .material-icons { font-size: 1.35rem; }
.topbar-titles { min-width: 0; }
.topbar-titles h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -.02em;
}
.topbar-titles .sub { color: var(--muted); margin: 0; font-size: .78rem; }
main.main-content {
flex: 1;
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 1rem 1rem 3rem;
}
@media (min-width: 880px) {
.nav-backdrop { display: none !important; }
.sidebar {
transform: translateX(0);
box-shadow: 4px 0 24px rgba(0,0,0,.2);
width: var(--sidebar-w);
}
body.sidebar-rail .sidebar {
width: var(--sidebar-rail);
}
body.sidebar-rail .sidebar-brand strong,
body.sidebar-rail .sidebar-brand > span,
body.sidebar-rail .side-nav .side-nav-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
white-space: nowrap;
border: 0;
}
body.sidebar-rail .sidebar-brand > div { display: flex; justify-content: center; }
body.sidebar-rail .sidebar-brand img { margin: 0 !important; }
body.sidebar-rail .side-nav button {
justify-content: center;
padding: .65rem .4rem;
}
body.sidebar-rail .page-wrap {
margin-left: var(--sidebar-rail) !important;
}
.sidebar-close { display: none; }
.nav-hamburger { display: none; }
.nav-sidebar-toggle { display: inline-flex; }
.page-wrap { margin-left: var(--sidebar-w); transition: margin-left .22s ease; }
}
section.panel { display: none; }
section.panel.active { display: block; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.15rem 1.25rem;
margin-bottom: 1rem;
}
.card h2 { margin: 0 0 .75rem; font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--accent-dim); }
.grid2 { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: .85rem; }
.stat { padding: .85rem; background: var(--surface2); border-radius: 8px; border: 1px solid var(--border); }
.statVAL { font-size: 1.25rem; font-weight: 700; color: var(--accent); }
.statLBL { font-size: .8rem; color: var(--muted); }
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 -.25rem; padding: 0 .25rem; }
table.responsive { width: 100%; border-collapse: collapse; font-size: .85rem; min-width: 520px; }
.responsive th, .responsive td { text-align: left; padding: .5rem .55rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.responsive th { color: var(--muted); font-weight: 600; font-size: .7rem; text-transform: uppercase; letter-spacing: .04em; }
.responsive tr:hover td { background: rgba(255,255,255,.03); }
@media (max-width: 720px) {
table.responsive { min-width: 0; }
table.responsive thead { display: none; }
table.responsive tr { display: block; border: 1px solid var(--border); border-radius: 8px; margin-bottom: .65rem; background: var(--surface2); }
table.responsive td {
display: block; text-align: right; padding: .45rem .65rem;
border-bottom: 1px solid var(--border);
}
table.responsive td:last-child { border-bottom: none; }
table.responsive td::before {
content: attr(data-label);
float: left; font-weight: 600; color: var(--muted); font-size: .72rem;
text-transform: uppercase; letter-spacing: .04em; max-width: 45%;
text-align: left;
}
table.responsive td.row-actions { text-align: center; }
table.responsive td.row-actions::before { float: none; display: block; margin-bottom: .35rem; }
}
.mono { font-family: ui-monospace, "Cascadia Code", monospace; font-size: .82em; word-break: break-all; }
.btn {
display: inline-flex; align-items: center; gap: .35rem; padding: .35rem .6rem; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface2); color: var(--text); cursor: pointer; font-size: .78rem; font-family: inherit;
}
a.btn { text-decoration: none; }
.btn:hover { border-color: var(--accent-dim); }
.btn-sm { padding: .25rem .45rem; font-size: .72rem; }
.btn-danger { border-color: rgba(232,93,93,.5); color: var(--danger); }
.btn-ghost { background: transparent; }
label { display: block; font-size: .78rem; color: var(--muted); margin-bottom: .25rem; }
input, select, textarea {
width: 100%; max-width: 100%; padding: .5rem .65rem; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg); color: var(--text); margin-bottom: .75rem; font-family: inherit;
}
textarea.code { min-height: 88px; font-family: ui-monospace, monospace; font-size: .8rem; }
.muted { color: var(--muted); font-size: .88rem; }
.help-block h3 { font-size: .95rem; margin: 1.25rem 0 .5rem; color: var(--accent-dim); }
.help-block pre, .help-block code {
background: rgba(0,0,0,.25); border: 1px solid var(--border); border-radius: 8px;
padding: .75rem; overflow-x: auto; font-size: .76rem; display: block; white-space: pre-wrap; word-break: break-all;
}
.msg { padding: .65rem .85rem; border-radius: 8px; margin: .5rem 0; font-size: .88rem; }
.msg.ok { background: rgba(93,190,138,.12); border: 1px solid rgba(93,190,138,.35); }
.msg.err { background: rgba(232,93,93,.12); border: 1px solid rgba(232,93,93,.35); }
.pill { display: inline-block; font-size: .68rem; font-weight: 600; padding: .15rem .4rem; border-radius: 999px; background: rgba(93,190,138,.2); color: var(--ok); }
.pill.off { background: rgba(139,156,184,.2); color: var(--muted); }
.pill.warn { background: rgba(232,93,93,.2); color: var(--danger); border: 1px solid rgba(232,93,93,.45); }
.banner-start {
background: linear-gradient(135deg, rgba(242,169,0,.18), rgba(28,38,56,.9));
border: 1px solid rgba(242,169,0,.35); border-radius: var(--radius); padding: 1rem 1.25rem; margin-bottom: 1rem;
}
.banner-start h2 { margin: 0 0 .75rem; font-size: .85rem; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); }
.btn-primary { background: rgba(242,169,0,.25); border-color: var(--accent); color: var(--accent); font-weight: 600; padding: .5rem 1.25rem; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 0 1rem; }
.hidden { display: none !important; }
.track-row { display: flex; flex-wrap: wrap; gap: .5rem; align-items: flex-end; margin-bottom: 1rem; }
.track-row input { margin-bottom: 0; flex: 1; min-width: 160px; }
.tx-links { display: flex; flex-wrap: wrap; gap: .35rem; align-items: center; justify-content: flex-end; }
.modal-root {
position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center;
padding: 1rem; background: rgba(0,0,0,.55); backdrop-filter: blur(4px);
}
.modal-root.hidden { display: none; }
.modal-card {
background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.35rem 1.5rem; max-width: 420px; width: 100%;
box-shadow: 0 20px 60px rgba(0,0,0,.4);
}
.modal-card h3 { margin: 0 0 .65rem; font-size: 1.1rem; }
.modal-card .modal-body { margin: 0 0 1.15rem; line-height: 1.55; }
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; flex-wrap: wrap; }
.dash-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: .75rem; margin-bottom: .75rem; }
.dash-head h2 { margin: 0; }
.dash-meta { font-size: .72rem; color: var(--muted); text-align: right; }
.dash-meta strong { color: var(--accent-dim); font-weight: 600; }
.stat-rownote { font-size: .7rem; color: var(--muted); margin-top: .35rem; line-height: 1.35; }
.chart-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 1rem;
margin-top: .5rem;
}
.chart-box {
position: relative;
height: 210px;
padding: .5rem .25rem .25rem;
background: var(--surface2);
border-radius: 8px;
border: 1px solid var(--border);
}
.chart-box .chart-title {
font-size: .68rem;
text-transform: uppercase;
letter-spacing: .06em;
color: var(--muted);
margin: 0 0 .35rem .35rem;
}
.txid-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: .4rem; max-width: 100%; vertical-align: middle; }
.copy-tx-badge {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: .15rem .38rem;
border-radius: 6px;
border: 1px solid rgba(242,169,0,.45);
background: rgba(242,169,0,.14);
color: var(--accent);
cursor: pointer;
font: inherit;
line-height: 1;
vertical-align: middle;
}
.copy-tx-badge:hover { border-color: var(--accent); background: rgba(242,169,0,.22); }
.copy-tx-badge .material-icons { font-size: .95rem; }
td.txid-cell { word-break: break-all; }
</style>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
<div id="nav-backdrop" class="nav-backdrop" aria-hidden="true"></div>
<aside id="sidebar" class="sidebar" aria-label="Main navigation">
<button type="button" class="sidebar-close" id="sidebar-close" aria-label="Close menu"><span class="material-icons" aria-hidden="true">close</span></button>
<div class="sidebar-brand">
<div><img src="/logo.png" alt="" width="40" height="40"/><strong>MemeTracker</strong></div>
<span>Dogecoin mempool watcher</span>
</div>
<nav class="side-nav">
<button type="button" class="active" data-tab="dash"><span class="material-icons side-nav-ico" aria-hidden="true">dashboard</span><span class="side-nav-label">Dashboard</span></button>
<button type="button" data-tab="cfg"><span class="material-icons side-nav-ico" aria-hidden="true">tune</span><span class="side-nav-label">Configuration</span></button>
<button type="button" data-tab="addr"><span class="material-icons side-nav-ico" aria-hidden="true">account_balance_wallet</span><span class="side-nav-label">Tracked addresses</span></button>
<button type="button" data-tab="tx"><span class="material-icons side-nav-ico" aria-hidden="true">receipt_long</span><span class="side-nav-label">Transactions</span></button>
<button type="button" data-tab="peers"><span class="material-icons side-nav-ico" aria-hidden="true">device_hub</span><span class="side-nav-label">Peers & mempool</span></button>
<button type="button" data-tab="security"><span class="material-icons side-nav-ico" aria-hidden="true">shield</span><span class="side-nav-label">API access (IPs)</span></button>
<button type="button" data-tab="help"><span class="material-icons side-nav-ico" aria-hidden="true">menu_book</span><span class="side-nav-label">Help & API docs</span></button>
</nav>
</aside>
<div class="page-wrap">
<header class="topbar">
<button type="button" class="nav-hamburger" id="nav-open" aria-label="Open menu" aria-expanded="false"><span class="material-icons" aria-hidden="true">menu</span></button>
<button type="button" class="nav-sidebar-toggle" id="nav-sidebar-toggle" title="Collapse or expand sidebar" aria-label="Collapse or expand sidebar"><span class="material-icons" id="nav-sidebar-toggle-icon" aria-hidden="true">chevron_left</span></button>
<div class="topbar-titles">
<h1>MemeTracker</h1>
<p class="sub">Dashboard — use the menu to navigate</p>
</div>
</header>
<main class="main-content">
<div id="flash"></div>
<div id="wizard-banner" class="banner-start">
<h2>Start MemeTracker</h2>
<p class="muted" style="margin:0 0 1rem">P2P mempool watching is <strong>off</strong> until you save a complete configuration and click Start, or place a complete <span class="mono">memetracker_config.json</span> and restart. HTTP port / bind apply after <strong>restart</strong> once saved.</p>
<form id="form-start">
<div class="form-grid">
<div><label>HTTP port (next restart)</label><input name="http_port" type="number" min="1" max="65535" required/></div>
<div><label>HTTP bind (next restart)</label><input name="http_bind" type="text" placeholder="0.0.0.0" required/></div>
<div><label>Network</label>
<select name="network" required>
<option value="mainnet">mainnet</option>
<option value="testnet">testnet</option>
</select>
</div>
<div><label>Data directory</label><input name="storage_dir" type="text" placeholder="path for addresses/"/></div>
<div><label>Max txs per address</label><input name="list_limit" type="number" min="1" max="10000" required/></div>
<div><label>Retention (days)</label><input name="retention_days" type="number" min="1" max="3650" required/></div>
<div><label>P2P host (optional)</label><input name="p2p_host" type="text" placeholder="empty = DNS seeds"/></div>
<div><label>P2P port</label><input name="p2p_port" type="number" min="1" max="65535" required/></div>
<div><label>P2P parallel workers (1–8)</label><input name="p2p_parallel" type="number" min="1" max="8" required/></div>
<div><label>P2P log level (0–2)</label><input name="p2p_log" type="number" min="0" max="2" required/></div>
</div>
<div style="grid-column:1/-1">
<label>API allowed IPs (optional)</label>
<textarea class="code" name="api_allowed_ips_text" rows="3" placeholder="One per line or comma-separated. Empty = allow all. Example: 127.0.0.1 192.168.1.0/24"></textarea>
<p class="muted" style="margin:0 0 .5rem;font-size:.78rem">Restrict <span class="mono">/api/*</span> and <span class="mono">/track/*</span>. Include <span class="mono">127.0.0.1</span> if you use this UI locally when listing specific IPs.</p>
</div>
<p style="margin:1rem 0 .5rem"><button type="submit" class="btn btn-primary btn-with-icon" id="btn-start"><span class="material-icons" aria-hidden="true">play_arrow</span><span>Start MemeTracker</span></button></p>
</form>
<p class="muted" style="margin:0;font-size:.8rem">Config file: <span class="mono" id="cfg-path-hint">—</span></p>
</div>
<div id="running-banner" class="banner-start hidden" style="border-color: rgba(93,190,138,.35); background: linear-gradient(135deg, rgba(93,190,138,.12), rgba(28,38,56,.9));">
<p style="margin:0;display:flex;flex-wrap:wrap;align-items:center;gap:.75rem">
<span><span class="pill">P2P running</span> Workers stop between peer sessions (long sessions may take up to a few minutes).</span>
<button type="button" class="btn btn-danger btn-with-icon" id="btn-stop"><span class="material-icons" aria-hidden="true">stop</span><span>Stop P2P</span></button>
</p>
</div>
<section id="panel-dash" class="panel active">
<div class="card">
<div class="dash-head">
<div>
<h2>Overview</h2>
<p class="muted" style="margin:.35rem 0 0">Live snapshot from the server. Mempool and worker counts move while P2P is running.</p>
</div>
<div class="dash-meta" id="dash-meta">
<span id="dash-updated">—</span><br/>
<span>Poll <strong>~4s</strong> · mempool = unique txids on the wire</span>
</div>
</div>
<div class="grid2" id="dash-stats"></div>
</div>
<div class="card">
<h2>Live trends</h2>
<p class="muted" style="margin-top:.25rem">Charts accumulate samples from this tab while it is open (~every 4 seconds). Reload clears history.</p>
<div class="chart-grid">
<div class="chart-box">
<div class="chart-title">Mempool · unique tx ids seen</div>
<canvas id="chart-mempool" aria-label="Mempool count over time"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">P2P · workers with an active peer</div>
<canvas id="chart-peers" aria-label="Connected workers over time"></canvas>
</div>
<div class="chart-box">
<div class="chart-title">MemeTracker · stored rows & addresses</div>
<canvas id="chart-store" aria-label="Stored data over time"></canvas>
</div>
</div>
</div>
<div class="card">
<h2>Mempool Live Feed</h2>
<p class="muted" style="margin-top:.25rem">Real-time mempool transactions seen by this node. Rows tagged <span class="pill">tracked</span> pay a watched address. Rows disappear when not seen again (stale/evicted).</p>
<div class="table-wrap">
<table class="responsive" id="table-mempool-live">
<thead><tr><th>Last seen (UTC)</th><th>Txid</th><th>Status</th><th>Address</th><th>DOGE</th><th>Actions</th></tr></thead>
<tbody id="tbody-mempool-live"></tbody>
</table>
</div>
</div>
<p class="muted">Automation base URL: <span class="mono" id="api-base"></span></p>
</section>
<section id="panel-cfg" class="panel">
<div class="card">
<h2>Runtime settings</h2>
<p class="muted">Updates <span class="mono">settings.json</span> and applies list cap / retention immediately. Does not change P2P or HTTP bind until you edit the main config and restart.</p>
<form id="form-cfg">
<label>Max stored transactions per address</label>
<input name="list_limit" type="number" min="1" max="10000"/>
<label>Retention (days) after last /track refresh</label>
<input name="retention_days" type="number" min="1" max="3650"/>
<button type="submit" class="btn btn-with-icon"><span class="material-icons" aria-hidden="true">save</span><span>Save settings</span></button>
</form>
</div>
<div class="card">
<h2>Effective configuration (read-only)</h2>
<pre id="cfg-readonly" class="mono" style="margin:0;font-size:.78rem;white-space:pre-wrap;"></pre>
</div>
</section>
<section id="panel-addr" class="panel">
<div class="card">
<h2>Tracked addresses</h2>
<p class="muted">Add and manage watched addresses. Remove clears the address file and all stored tx rows for it.</p>
<div class="track-row" style="margin-top:.75rem">
<input id="track-addr" type="text" placeholder="Mainnet P2PKH address…"/>
<button type="button" class="btn btn-with-icon" id="btn-track"><span class="material-icons" aria-hidden="true">add_location_alt</span><span>Add address</span></button>
</div>
<div class="table-wrap">
<table class="responsive" id="table-addr">
<thead><tr><th>Address</th><th>Hash160</th><th>Tracked since</th><th>Last refresh</th><th>Tx count</th><th></th></tr></thead>
<tbody id="tbody-addr"></tbody>
</table>
</div>
</div>
</section>
<section id="panel-tx" class="panel">
<div class="card">
<h2>Transactions</h2>
<p class="muted">Rows detected from mempool traffic paying your watched addresses. Use the copy badge after each txid, or open on sochain.</p>
<div class="table-wrap">
<table class="responsive" id="table-tx">
<thead><tr><th>Time (UTC)</th><th>Address</th><th>Txid</th><th>DOGE</th><th>Explorer / remove</th></tr></thead>
<tbody id="tbody-tx"></tbody>
</table>
</div>
</div>
</section>
<section id="panel-peers" class="panel">
<div class="card">
<h2>Peers & mempool</h2>
<p class="muted">Parallel workers each maintain one session at a time. Mempool count = unique tx ids recently seen on the wire.</p>
<div class="grid2" id="peer-stats"></div>
<div class="table-wrap" style="margin-top:1rem">
<table class="responsive" id="table-peers">
<thead><tr><th>Worker</th><th>Peer</th><th>Status</th><th>Updated</th></tr></thead>
<tbody id="tbody-peers"></tbody>
</table>
</div>
</div>
</section>
<section id="panel-security" class="panel">
<div class="card">
<h2>API IP access</h2>
<p class="muted">When the list is <strong>empty</strong>, all IPs may call <span class="mono">/api/*</span> and <span class="mono">/track/*</span>. When you add entries, only matching IPv4/IPv6 addresses or CIDR ranges can use those paths (this page still loads; include <span class="mono">127.0.0.1</span> for local dashboard refresh). Behind a reverse proxy, set env <span class="mono">MTR_TRUST_XFF=1</span> on the server — see README.</p>
<form id="form-allowlist">
<label>Allowed IPs / CIDRs (one per line)</label>
<textarea class="code" id="allowlist-text" name="allowlist" rows="5" placeholder="127.0.0.1 10.0.0.0/8"></textarea>
<button type="submit" class="btn btn-primary btn-with-icon"><span class="material-icons" aria-hidden="true">vpn_key</span><span>Save API allowlist</span></button>
</form>
</div>
</section>
<section id="panel-help" class="panel">
<div class="card help-block">
<h2>How MemeTracker works</h2>
<p>MemeTracker connects to the Dogecoin P2P network, requests the mempool, and listens for transaction inventory and raw transactions. It does <strong>not</strong> sync the blockchain. When a transaction pays one of your tracked P2PKH-style outputs, the app records txid, time, and DOGE amount and stores it under your data directory.</p>
<h3>Dashboard</h3>
<p>Summary counters plus <strong>live trend charts</strong> (mempool visibility, connected workers, stored rows and tracked addresses) built from browser polling while you keep the dashboard open.</p>
<h3>Start / Stop</h3>
<p><strong>Start</strong> writes <span class="mono">memetracker_config.json</span> and launches workers. <strong>Stop</strong> signals workers to exit between connections (an active peer session may take a short while). A complete config file on disk can auto-start P2P when the process launches.</p>
<h3>Configuration</h3>
<p>Adjusts retention and per-address tx cap in <span class="mono">settings.json</span> without restarting P2P.</p>
<h3>Track address</h3>
<p>Same as <span class="mono">GET|POST /track/{address}</span>. Keeps the address watched and bumps retention.</p>
<h3>Tracked addresses / Transactions</h3>
<p>On-disk mirror of what you are watching and what was detected. Removing an address deletes its file and tx history in the app.</p>
<h3>API access</h3>
<p>Optional IP allowlist for JSON/track endpoints (see section above).</p>
</div>
<div class="card help-block">
<h2>HTTP API reference</h2>
<p>Replace <code>BASE</code> with your server origin, e.g. <span class="mono">http://127.0.0.1:33555</span>.</p>
<h3>Status & health</h3>
<pre><code>curl -s BASE/api/status
curl -s BASE/healthz</code></pre>
<h3>Start / Stop P2P</h3>
<pre><code>curl -s -X POST BASE/api/start -H "Content-Type: application/json" -d "{\"http_port\":33555,\"http_bind\":\"0.0.0.0\",\"network\":\"mainnet\",\"storage_dir\":\"\",\"list_limit\":10,\"retention_days\":7,\"p2p_host\":\"\",\"p2p_port\":22556,\"p2p_parallel\":3,\"p2p_log\":1}"
curl -s -X POST BASE/api/stop</code></pre>
<h3>Runtime limits</h3>
<pre><code>curl -s -X POST BASE/api/config -H "Content-Type: application/json" -d "{\"list_limit\":20,\"retention_days\":14}"</code></pre>
<h3>API allowlist</h3>
<pre><code>curl -s -X POST BASE/api/allowlist -H "Content-Type: application/json" -d "{\"api_allowed_ips\":[\"127.0.0.1\",\"10.0.0.0/24\"]}"</code></pre>
<h3>Track address</h3>
<pre><code>curl -s "BASE/track/YourP2PKHAddress"</code></pre>
<h3>Remove address / tx row</h3>
<pre><code>curl -s -X DELETE "BASE/api/addresses/{hash160_hex}"
curl -s -X DELETE "BASE/api/transactions?txid=...&hash160_hex=..."</code></pre>
<p class="muted">All API responses are JSON unless noted. Errors include an <span class="mono">error</span> string. A complete <span class="mono">memetracker_config.json</span> shape is required for <span class="mono">/api/start</span>; you may include <span class="mono">api_allowed_ips</span> as an array of strings.</p>
</div>
</section>
<footer style="margin-top:2rem;padding-top:1rem;border-top:1px solid var(--border);font-size:.88rem;color:var(--accent-dim);text-align:center;line-height:1.5">
Coded with love to all Dogecoin Community
</footer>
</main>
</div>
<div id="modal-root" class="modal-root hidden" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal-card">
<h3 id="modal-title">Confirm</h3>
<p id="modal-body" class="modal-body muted"></p>
<div class="modal-actions">
<button type="button" class="btn btn-with-icon" id="modal-cancel"><span class="material-icons" aria-hidden="true">close</span><span>Cancel</span></button>
<button type="button" class="btn btn-danger btn-with-icon" id="modal-confirm"><span class="material-icons" aria-hidden="true">check</span><span>Confirm</span></button>
</div>
</div>
</div>
<script>
(function(){
if (typeof Chart !== "undefined" && Chart.defaults) {
Chart.defaults.font.family = '"Comic Neue", "Comic Sans MS", cursive, system-ui, sans-serif';
Chart.defaults.color = "#8b9cb8";
}
const $ = (s) => document.querySelector(s);
const flash = $("#flash");
function showFlash(text, ok) {
flash.innerHTML = '<div class="msg '+(ok?'ok':'err')+'">'+escapeHtml(text)+'</div>';
setTimeout(function() { flash.innerHTML = ''; }, 6500);
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function parseIPList(text) {
return text.split(/[\n,]+/).map(function(s) { return s.trim(); }).filter(Boolean);
}
function sochainTxURL(txid) {
return "https://sochain.com/tx/DOGE/" + encodeURIComponent(txid);
}
document.getElementById("api-base").textContent = window.location.origin;
const sidebar = $("#sidebar");
const backdrop = $("#nav-backdrop");
function openNav() {
sidebar.classList.add("open");
backdrop.classList.add("visible");
$("#nav-open").setAttribute("aria-expanded", "true");
}
function closeNav() {
sidebar.classList.remove("open");
backdrop.classList.remove("visible");
$("#nav-open").setAttribute("aria-expanded", "false");
}
$("#nav-open").addEventListener("click", openNav);
$("#sidebar-close").addEventListener("click", closeNav);
backdrop.addEventListener("click", closeNav);
window.addEventListener("keydown", function(e) {
if (e.key === "Escape") closeNav();
});
var RAIL_KEY = "mtrSidebarRail";
function syncSidebarRailIcon() {
var icon = $("#nav-sidebar-toggle-icon");
if (!icon) return;
icon.textContent = document.body.classList.contains("sidebar-rail") ? "chevron_right" : "chevron_left";
}
function applySidebarRailFromStorage() {
if (!window.matchMedia("(min-width: 880px)").matches) {
document.body.classList.remove("sidebar-rail");
syncSidebarRailIcon();
return;
}
if (localStorage.getItem(RAIL_KEY) === "1") document.body.classList.add("sidebar-rail");
else document.body.classList.remove("sidebar-rail");
syncSidebarRailIcon();
}
var navSidebarToggle = $("#nav-sidebar-toggle");
if (navSidebarToggle) {
navSidebarToggle.addEventListener("click", function() {
if (!window.matchMedia("(min-width: 880px)").matches) return;
document.body.classList.toggle("sidebar-rail");
localStorage.setItem(RAIL_KEY, document.body.classList.contains("sidebar-rail") ? "1" : "0");
syncSidebarRailIcon();
});
}
applySidebarRailFromStorage();
window.addEventListener("resize", applySidebarRailFromStorage);
function showPanel(tab) {
document.querySelectorAll(".side-nav button").forEach(function(b) {
b.classList.toggle("active", b.getAttribute("data-tab") === tab);
});
document.querySelectorAll("section.panel").forEach(function(p) {
p.classList.toggle("active", p.id === "panel-" + tab);
});
if (window.matchMedia("(max-width: 879px)").matches) closeNav();
}
document.querySelectorAll(".side-nav button").forEach(function(btn) {
btn.addEventListener("click", function() {
showPanel(btn.getAttribute("data-tab"));
});
});
function confirmModal(opts) {
return new Promise(function(resolve) {
const root = $("#modal-root");
const titleEl = $("#modal-title");
const bodyEl = $("#modal-body");
const btnOk = $("#modal-confirm");
const btnCancel = $("#modal-cancel");
titleEl.textContent = opts.title || "Confirm";
bodyEl.textContent = opts.body || "";
var confirmLabel = escapeHtml(opts.confirmText || "Confirm");
btnOk.innerHTML = '<span class="material-icons" aria-hidden="true">check</span><span>' + confirmLabel + "</span>";
btnOk.className = "btn btn-with-icon " + (opts.danger ? "btn-danger" : "btn-primary");
root.classList.remove("hidden");
function finish(v) {
root.classList.add("hidden");
btnCancel.removeEventListener("click", onCancel);
btnOk.removeEventListener("click", onOk);
root.removeEventListener("click", onBackdrop);
resolve(v);
}
function onCancel() { finish(false); }
function onOk() { finish(true); }
function onBackdrop(ev) { if (ev.target === root) onCancel(); }
btnCancel.addEventListener("click", onCancel);
btnOk.addEventListener("click", onOk);
root.addEventListener("click", onBackdrop);
});
}
async function api(path, opts) {
const r = await fetch(path, Object.assign({ headers: { "Accept": "application/json" } }, opts || {}));
const t = await r.text();
let j = null;
try { j = JSON.parse(t); } catch (_) {}
if (!r.ok) throw new Error((j && j.error) || t || r.statusText);
return j;
}
var HIST_MAX = 90;
var hist = { t: [], mempool: [], peers: [], stored: [], addrs: [] };
var dashCharts = { mempool: null, peers: null, store: null };
function chartTheme() {
return {
accent: "rgba(242, 169, 0, 0.95)",
accentFill: "rgba(242, 169, 0, 0.14)",
ok: "rgba(93, 190, 138, 0.9)",
okFill: "rgba(93, 190, 138, 0.12)",
cyan: "rgba(100, 181, 246, 0.9)",
tick: "#8b9cb8",
grid: "rgba(255,255,255,0.07)"
};
}
function baseChartOpts(T) {
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { labels: { color: T.tick, boxWidth: 11, font: { size: 11 } } },
tooltip: {
titleColor: "#e8edf5",
bodyColor: "#e8edf5",
backgroundColor: "rgba(21,29,44,.95)",
borderColor: "rgba(255,255,255,.12)",
borderWidth: 1
}
},
scales: {
x: {
ticks: { color: T.tick, maxRotation: 0, autoSkip: true, maxTicksLimit: 7 },
grid: { color: T.grid }
},
y: {
ticks: { color: T.tick },
grid: { color: T.grid },
beginAtZero: true
}
}
};
}
function ensureDashCharts() {
if (typeof Chart === "undefined") return;
var T = chartTheme();
if ($("#chart-mempool") && !dashCharts.mempool) {
dashCharts.mempool = new Chart($("#chart-mempool"), {
type: "line",
data: {
labels: [],
datasets: [{
label: "Mempool",
data: [],
borderColor: T.accent,
backgroundColor: T.accentFill,
fill: true,
tension: 0.25,
pointRadius: 0,
borderWidth: 2
}]
},
options: baseChartOpts(T)
});
}
if ($("#chart-peers") && !dashCharts.peers) {
var po = baseChartOpts(T);
po.scales.y.ticks.stepSize = 1;
dashCharts.peers = new Chart($("#chart-peers"), {
type: "line",
data: {
labels: [],
datasets: [{
label: "Connected workers",
data: [],
borderColor: T.ok,
backgroundColor: T.okFill,
fill: true,
tension: 0.25,
pointRadius: 0,
borderWidth: 2
}]
},
options: po
});
}
if ($("#chart-store") && !dashCharts.store) {
dashCharts.store = new Chart($("#chart-store"), {
type: "line",
data: {
labels: [],
datasets: [
{
label: "Stored tx rows",
data: [],
borderColor: T.accent,
backgroundColor: "transparent",
tension: 0.25,
pointRadius: 0,
borderWidth: 2
},
{
label: "Tracked addresses",
data: [],
borderColor: T.cyan,
backgroundColor: "transparent",
tension: 0.25,
pointRadius: 0,
borderWidth: 2
}
]
},
options: baseChartOpts(T)
});
}
}
function recordHistory(st) {
var lbl = new Date().toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit" });
hist.t.push(lbl);
hist.mempool.push(Number(st.mempool_tx_count) || 0);
hist.peers.push(Number(st.peers_connected_count) || 0);
hist.stored.push(Number(st.stored_transaction_rows) || 0);
hist.addrs.push(Number(st.watched_addresses) || 0);
while (hist.t.length > HIST_MAX) {
hist.t.shift();
hist.mempool.shift();
hist.peers.shift();
hist.stored.shift();
hist.addrs.shift();
}
}
function updateDashCharts() {
if (typeof Chart === "undefined") return;
ensureDashCharts();
if (!dashCharts.mempool || !dashCharts.peers || !dashCharts.store) return;
dashCharts.mempool.data.labels = hist.t.slice();
dashCharts.mempool.data.datasets[0].data = hist.mempool.slice();
dashCharts.mempool.update("none");
dashCharts.peers.data.labels = hist.t.slice();
dashCharts.peers.data.datasets[0].data = hist.peers.slice();
dashCharts.peers.update("none");
dashCharts.store.data.labels = hist.t.slice();
dashCharts.store.data.datasets[0].data = hist.stored.slice();
dashCharts.store.data.datasets[1].data = hist.addrs.slice();
dashCharts.store.update("none");
}
function fillStartForm(fc) {
if (!fc) return;
const f = $("#form-start");
f.http_port.value = fc.http_port;
f.http_bind.value = fc.http_bind || "";
f.network.value = fc.network || "mainnet";
f.storage_dir.value = fc.storage_dir || "";
f.list_limit.value = fc.list_limit;
f.retention_days.value = fc.retention_days;
f.p2p_host.value = fc.p2p_host || "";
f.p2p_port.value = fc.p2p_port;
f.p2p_parallel.value = fc.p2p_parallel;
f.p2p_log.value = fc.p2p_log;
var ips = fc.api_allowed_ips;
f.api_allowed_ips_text.value = Array.isArray(ips) && ips.length ? ips.join("\n") : "";
}
async function refreshAll() {
try {
const st = await api("/api/status");
var run = !!st.p2p_running;
$("#wizard-banner").classList.toggle("hidden", run);
$("#running-banner").classList.toggle("hidden", !run);
if (st.memetracker_config_path) $("#cfg-path-hint").textContent = st.memetracker_config_path;
fillStartForm(st.full_config);
var allow = (st.full_config && st.full_config.api_allowed_ips) || [];
$("#allowlist-text").value = Array.isArray(allow) && allow.length ? allow.join("\n") : "";
$("#dash-updated").textContent = "Updated " + new Date().toLocaleString(undefined, { dateStyle: "short", timeStyle: "medium" });
var dash = $("#dash-stats");
dash.innerHTML = "";
var statRows = [
{ k: "P2P running", v: run ? "yes" : "no", hint: run ? "Workers rotate peers between sessions." : "Start from the banner or use a complete memetracker_config.json." },
{ k: "Tracked addresses", v: st.watched_addresses },
{ k: "Stored tx rows", v: st.stored_transaction_rows },
{ k: "Mempool (unique tx ids)", v: st.mempool_tx_count, hint: run ? "Relay visibility on current peer sessions." : "Starts counting when P2P is running." },
{ k: "P2P workers connected", v: st.peers_connected_count, hint: run ? "Sessions with an open peer right now." : "—" }
];
statRows.forEach(function(row) {
var d = document.createElement("div");
d.className = "stat";
var hint = row.hint && row.hint !== "—" ? '<div class="stat-rownote">' + escapeHtml(row.hint) + "</div>" : "";
d.innerHTML = '<div class="statVAL">' + escapeHtml(String(row.v)) + '</div><div class="statLBL">' + escapeHtml(row.k) + "</div>" + hint;
dash.appendChild(d);
});
recordHistory(st);
updateDashCharts();
$("#cfg-readonly").textContent = JSON.stringify(st.config, null, 2);
var cfg = await api("/api/config");
var form = $("#form-cfg");
form.list_limit.value = cfg.list_limit;
form.retention_days.value = cfg.retention_days;
var tbody = $("#tbody-addr");
tbody.innerHTML = "";
(st.addresses || []).forEach(function(a) {
var tr = document.createElement("tr");
tr.innerHTML = '<td data-label="Address" class="mono">' + escapeHtml(a.address) + '</td>' +
'<td data-label="Hash160" class="mono">' + escapeHtml(a.hash160_hex) + '</td>' +
'<td data-label="Tracked since">' + escapeHtml(a.tracked_since) + '</td>' +
'<td data-label="Last refresh">' + escapeHtml(a.last_requested) + '</td>' +
'<td data-label="Tx count">' + escapeHtml(String(a.tx_count)) + '</td>' +
'<td data-label="" class="row-actions"><button type="button" class="btn btn-danger btn-sm btn-with-icon" data-rm-addr="' + escapeHtml(a.hash160_hex) + '"><span class="material-icons" aria-hidden="true">delete_outline</span><span>Remove</span></button></td>';
tbody.appendChild(tr);
});
var tbx = $("#tbody-tx");
tbx.innerHTML = "";
(st.transactions || []).forEach(function(tx) {
var tr = document.createElement("tr");
var txidEsc = escapeHtml(tx.txid);
var explore = sochainTxURL(tx.txid);
var dsBadge = tx.double_spent ? '<span class="pill warn">Double spent detected</span> ' : '';
tr.innerHTML = '<td data-label="Time">' + escapeHtml(tx.datetime) + '</td>' +
'<td data-label="Address" class="mono">' + escapeHtml(tx.address) + '</td>' +
'<td data-label="Txid" class="mono txid-cell"><span class="txid-line">' + txidEsc +
'<button type="button" class="copy-tx-badge" data-copy-tx="' + escapeHtml(tx.txid) + '" title="Copy txid" aria-label="Copy txid"><span class="material-icons" aria-hidden="true">content_copy</span></button></span></td>' +
'<td data-label="DOGE">' + dsBadge + escapeHtml(Number(tx.amount_doge).toFixed(8)) + '</td>' +
'<td data-label="Explorer / remove" class="row-actions"><div class="tx-links">' +
'<a class="btn btn-sm btn-with-icon" href="' + explore + '" target="_blank" rel="noopener noreferrer"><span class="material-icons" aria-hidden="true">open_in_new</span><span>sochain</span></a>' +
'<button type="button" class="btn btn-danger btn-sm btn-with-icon" data-rm-tx="' + encodeURIComponent(tx.hash160_hex) + '" data-txid="' + escapeHtml(tx.txid) + '"><span class="material-icons" aria-hidden="true">delete_outline</span><span>Remove</span></button>' +
'</div></td>';
tbx.appendChild(tr);
});
var tml = $("#tbody-mempool-live");
tml.innerHTML = "";
(st.mempool_transactions || []).forEach(function(tx) {
var txid = String(tx.txid || "");
var txidEsc = escapeHtml(txid);
var tracked = !!tx.tracked_match;
var doubleSpent = !!tx.double_spent;
var status = tracked ? '<span class="pill">tracked</span>' : '<span class="pill off">seen</span>';
if (doubleSpent) status += ' <span class="pill warn">Double spent detected</span>';
var addr = tracked && tx.address ? escapeHtml(String(tx.address)) : "—";
var amt = tracked && tx.amount_doge ? escapeHtml(Number(tx.amount_doge).toFixed(8)) : "—";
var explore = sochainTxURL(txid);
var tr = document.createElement("tr");
tr.innerHTML =
'<td data-label="Last seen (UTC)">' + escapeHtml(String(tx.last_seen || "—")) + '</td>' +
'<td data-label="Txid" class="mono txid-cell"><span class="txid-line">' +
'<a href="' + explore + '" target="_blank" rel="noopener noreferrer">' + txidEsc + '</a>' +
'<button type="button" class="copy-tx-badge" data-copy-tx="' + txidEsc + '" title="Copy txid" aria-label="Copy txid"><span class="material-icons" aria-hidden="true">content_copy</span></button></span></td>' +
'<td data-label="Status">' + status + '</td>' +
'<td data-label="Address" class="mono">' + addr + '</td>' +
'<td data-label="DOGE">' + amt + '</td>' +
'<td data-label="Actions" class="row-actions"><a class="btn btn-sm btn-with-icon" href="' + explore + '" target="_blank" rel="noopener noreferrer"><span class="material-icons" aria-hidden="true">open_in_new</span><span>sochain</span></a></td>';
tml.appendChild(tr);
});
var ps = $("#peer-stats");
ps.innerHTML = "";
[["Mempool unique tx", st.mempool_tx_count]].forEach(function(row) {
var d = document.createElement("div");
d.className = "stat";
d.innerHTML = '<div class="statVAL">' + escapeHtml(String(row[1])) + '</div><div class="statLBL">' + escapeHtml(row[0]) + '</div>';
ps.appendChild(d);
});
var tp = $("#tbody-peers");
tp.innerHTML = "";
(st.peers || []).forEach(function(p) {
var tr = document.createElement("tr");
var pill = p.connected ? '<span class="pill">connected</span>' : '<span class="pill off">idle</span>';
tr.innerHTML = '<td data-label="Worker">' + escapeHtml(String(p.worker_id)) + '</td>' +
'<td data-label="Peer" class="mono">' + escapeHtml(p.address || "—") + '</td>' +
'<td data-label="Status">' + pill + '</td>' +
'<td data-label="Updated">' + escapeHtml(p.updated) + '</td>';
tp.appendChild(tr);
});
} catch (e) {
console.error(e);
}
}
document.body.addEventListener("click", async function(ev) {
var copyB = ev.target.closest("[data-copy-tx]");
if (copyB) {
var txid = copyB.getAttribute("data-copy-tx");
try {
await navigator.clipboard.writeText(txid);
showFlash("Txid copied to clipboard.", true);
} catch (_) {
showFlash("Could not copy (clipboard permission).", false);
}
return;
}
var b = ev.target.closest("[data-rm-addr]");
if (b) {
var id = b.getAttribute("data-rm-addr");
var ok = await confirmModal({
title: "Remove tracked address",
body: "Remove this address and all stored transactions for it from MemeTracker? This cannot be undone.",
confirmText: "Remove",
danger: true
});
if (!ok) return;
try {
await api("/api/addresses/" + id, { method: "DELETE" });
showFlash("Address removed.", true);
refreshAll();
} catch (e) { showFlash(e.message, false); }
return;
}
var t = ev.target.closest("[data-rm-tx]");
if (t) {
var hash = decodeURIComponent(t.getAttribute("data-rm-tx"));
var txid = t.getAttribute("data-txid");
var ok2 = await confirmModal({
title: "Remove transaction row",
body: "Remove this row from the store only (not from the blockchain)?",
confirmText: "Remove",
danger: true
});
if (!ok2) return;
try {
await api("/api/transactions?hash160_hex=" + encodeURIComponent(hash) + "&txid=" + encodeURIComponent(txid), { method: "DELETE" });
showFlash("Transaction removed.", true);
refreshAll();
} catch (e) { showFlash(e.message, false); }
}
});
$("#form-start").addEventListener("submit", async function(ev) {
ev.preventDefault();
var f = ev.target;
var body = {
http_port: parseInt(f.http_port.value, 10),
http_bind: f.http_bind.value.trim(),
network: f.network.value,
storage_dir: f.storage_dir.value.trim(),
list_limit: parseInt(f.list_limit.value, 10),
retention_days: parseInt(f.retention_days.value, 10),
p2p_host: f.p2p_host.value.trim(),
p2p_port: parseInt(f.p2p_port.value, 10),
p2p_parallel: parseInt(f.p2p_parallel.value, 10),
p2p_log: parseInt(f.p2p_log.value, 10),
api_allowed_ips: parseIPList(f.api_allowed_ips_text.value || "")
};
try {
var r = await fetch("/api/start", { method: "POST", headers: { "Content-Type": "application/json", "Accept": "application/json" }, body: JSON.stringify(body) });
var t = await r.text();
var j = null;
try { j = JSON.parse(t); } catch (_) {}
if (!r.ok) throw new Error((j && j.error) || t);
if (j && j.restart_required) {
showFlash((j.message || "Restart MemeTracker to apply storage path.") + " Then click Start again if needed.", true);
} else {
showFlash("MemeTracker P2P watcher started.", true);
}
refreshAll();
} catch (e) { showFlash(e.message, false); }
});
$("#btn-stop").addEventListener("click", async function() {
var ok = await confirmModal({
title: "Stop P2P watcher",
body: "Stop mempool monitoring? Workers will disconnect between peer sessions (active sessions may take a short while). The web UI stays online. You can click Start again, or if memetracker_config.json is complete the next full app restart will auto-start P2P.",
confirmText: "Stop P2P",
danger: true
});
if (!ok) return;
try {
await api("/api/stop", { method: "POST" });
showFlash("P2P watcher stopped.", true);
refreshAll();
} catch (e) { showFlash(e.message, false); }
});
$("#btn-track").addEventListener("click", async function() {
var addr = $("#track-addr").value.trim();
if (!addr) return;
try {
await api("/track/" + encodeURI(addr), { method: "POST" });
showFlash("Now tracking " + addr, true);
$("#track-addr").value = "";
refreshAll();
} catch (e) { showFlash(e.message, false); }
});
$("#form-cfg").addEventListener("submit", async function(ev) {
ev.preventDefault();
var fd = new FormData(ev.target);
try {
await api("/api/config", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
list_limit: parseInt(fd.get("list_limit"), 10),
retention_days: parseInt(fd.get("retention_days"), 10)
})
});
showFlash("Settings saved.", true);
refreshAll();
} catch (e) { showFlash(e.message, false); }
});
$("#form-allowlist").addEventListener("submit", async function(ev) {
ev.preventDefault();
var ips = parseIPList($("#allowlist-text").value || "");
try {
await api("/api/allowlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ api_allowed_ips: ips })
});
showFlash("API allowlist saved to memetracker_config.json.", true);
refreshAll();
} catch (e) { showFlash(e.message, false); }
});
setInterval(refreshAll, 4000);
refreshAll();
})();
</script>
</body>
</html>