Compare commits

...

5 Commits

Author SHA1 Message Date
hermes d8af2aa17c scripts: add sign-snapshot.sh for signed UTXO snapshot provenance
Generates a UTXO snapshot via dumputxoset RPC, signs a provenance message
(height, blockhash, snapshot sha256) with signmessage, and emits a signed
manifest.json. Verification via ./sign-snapshot.sh verify <manifest> <snap>
or verifymessage RPC on any node.

Pairs with the requireCheckpoint trust-gate patch — local snapshots no
longer require a known checkpoint, so signing provenance is the way to
establish authority for a snapshot.
2026-06-18 02:39:43 -07:00
hermes e15de97be3 utxosnapshot: gate requireCheckpoint on trust source
Local file snapshots (init.cpp) skip the known-checkpoint gate; P2P-delivered
snapshots (bootstrap.cpp) keep it. Rationale: the checkpoint gate exists to
prevent malicious peers from injecting fake UTXO sets. Local file loads come
from operator-trusted sources (filesystem access already grants equal power),
so the gate is unnecessary friction.
2026-06-18 02:34:51 -07:00
triangles-bot c606253c41 utxosnapshot: build address index when loading a UTXO snapshot (fast-start nodes get balances) [v5.9.17] 2026-06-16 20:37:44 -07:00
triangles-bot b2dfb627cc main: build address index during FastImport (fix-in-place, v5.9.16) 2026-06-16 20:24:26 -07:00
triangles-bot d0a76f8ae2 qt: show Seed Phrase (HD Backup) in the visible Operations menu (v5.9.15)
The HD seed action was only added to the standard Qt menu bar, which the
skinned GUI hides. Add it to menuOperationsRequested() so users can actually
reach Generate / Reveal-for-backup / Restore from the Operations menu.
2026-06-16 16:24:12 -07:00
8 changed files with 282 additions and 9 deletions
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# ============================================================================
# Triangles UTXO Snapshot Signer
# ============================================================================
# Generates a UTXO snapshot from the current node, signs its provenance
# message with the wallet's signing address, and writes the signed manifest.
#
# Usage:
# ./sign-snapshot.sh [snapshot-name]
#
# Default snapshot name: tri-utxo-snapshot-<timestamp>.utx
# Output (in this dir):
# <snapshot-name> - the UTXO snapshot binary
# <snapshot-name>.sig - base64 signature
# <snapshot-name>.msg - signed message (human-readable provenance)
# <snapshot-name>.manifest.json - signed manifest (drop into bootstrap dir)
# <snapshot-name>.pubkey - signing address
#
# Requirements:
# - trianglesd running with RPC enabled
# - wallet unlocked (or passphrase set in triangles.conf)
# - jq installed (apt: jq / brew: jq)
#
# Verification:
# ./sign-snapshot.sh verify <manifest.json> <snapshot-file>
# OR via RPC:
# verifymessage <addr> <sig> <msg>
# ============================================================================
set -euo pipefail
# ----- Config (override via env) -----
RPC_USER="${RPC_USER:-trianglesrpc}"
RPC_PASS="${RPC_PASS:-2KVK2FvLZBW9Hxv4a2Uj3dMRDAXdh4ei6S5tdZ3z2Mme}"
RPC_HOST="${RPC_HOST:-127.0.0.1}"
RPC_PORT="${RPC_PORT:-19112}"
SIGN_ACCOUNT="${SIGN_ACCOUNT:-}" # blank = use default account
NHEADERS="${NHEADERS:-2000}"
SNAP_DIR="${SNAP_DIR:-.}"
# ----- Helpers -----
rpc() {
local method="$1"; shift
local params="$1"; shift || true
curl -s --user "${RPC_USER}:${RPC_PASS}" \
-X POST -H 'Content-Type: application/json' \
--data "{\"jsonrpc\":\"1.0\",\"method\":\"${method}\",\"params\":${params}}" \
"http://${RPC_HOST}:${RPC_PORT}/"
}
rpc_field() {
local method="$1"; shift
local params="$1"; shift || true
local field="$1"; shift
rpc "$method" "$params" | jq -r ".result.${field} // empty"
}
sha256_file() { sha256sum "$1" | awk '{print $1}'; }
# ----- Verify mode -----
if [[ "${1:-}" == "verify" ]]; then
MANIFEST="${2:?usage: $0 verify <manifest.json> <snapshot-file>}"
SNAP="${3:?usage: $0 verify <manifest.json> <snapshot-file>}"
ADDR=$(jq -r '.signing_address' "$MANIFEST")
SIG=$(jq -r '.signature' "$MANIFEST")
MSG=$(jq -r '.message' "$MANIFEST")
EXPECTED_SHA=$(jq -r '.snapshot_sha256' "$MANIFEST")
echo "==> Verifying snapshot provenance..."
echo " Address: $ADDR"
echo " Message: $MSG"
ACTUAL_SHA=$(sha256_file "$SNAP")
if [[ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]]; then
echo "FAIL: snapshot sha256 mismatch"
echo " expected: $EXPECTED_SHA"
echo " actual: $ACTUAL_SHA"
exit 1
fi
echo "OK: sha256 matches"
PARAMS=$(jq -nc --arg a "$ADDR" --arg s "$SIG" --arg m "$MSG" \
'[$a, $s, $m]')
RESULT=$(rpc verifymessage "$PARAMS" | jq -r '.result')
if [[ "$RESULT" == "true" ]]; then
echo "OK: signature valid — snapshot was signed by $ADDR"
exit 0
else
echo "FAIL: signature does not verify"
exit 1
fi
fi
# ----- Generate + sign -----
SNAP_NAME="${1:-tri-utxo-snapshot-$(date -u +%Y%m%dT%H%M%SZ).utx}"
SNAP_PATH="${SNAP_DIR}/${SNAP_NAME}"
echo "==> Step 1/5: querying chain state..."
HEIGHT=$(rpc_field getblockcount '[]' '' || echo "")
if [[ -z "$HEIGHT" ]]; then
rpc_field getblockcount '[]' '' # re-run for error visibility
echo "FAIL: RPC getblockcount failed"; exit 1
fi
HEIGHT=$(rpc getblockcount '[]' | jq -r '.result')
BLOCKHASH=$(rpc getbestblockhash '[]' | jq -r '.result')
echo " height: $HEIGHT"
echo " blockhash:$BLOCKHASH"
echo "==> Step 2/5: selecting signing address..."
if [[ -n "$SIGN_ACCOUNT" ]]; then
PARAMS=$(jq -nc --arg a "$SIGN_ACCOUNT" '[$a]')
else
PARAMS='[""]'
fi
ADDR=$(rpc getaccountaddress "$PARAMS" | jq -r '.result')
echo " signer: $ADDR"
echo "==> Step 3/5: dumping UTXO snapshot..."
PARAMS=$(jq -nc --arg f "$SNAP_PATH" --argjson n "$NHEADERS" '[$f, $n]')
DUMP_RESULT=$(rpc dumputxoset "$PARAMS")
echo "$DUMP_RESULT" | jq -r '.result // .error.message // .'
SIZE=$(echo "$DUMP_RESULT" | jq -r '.result.file_size // empty')
if [[ -z "$SIZE" ]]; then
echo "FAIL: dumputxoset failed"; exit 1
fi
echo " size: $SIZE bytes"
echo "==> Step 4/5: signing provenance message..."
SHA=$(sha256_file "$SNAP_PATH")
MSG="Triangles UTXO Snapshot $(date -u +%Y-%m-%d): height=$HEIGHT hash=$BLOCKHASH sha256=$SHA"
echo " message: $MSG"
PARAMS=$(jq -nc --arg a "$ADDR" --arg m "$MSG" '[$a, $m]')
SIG=$(rpc signmessage "$PARAMS" | jq -r '.result')
echo " sig: $SIG"
echo "==> Step 5/5: writing manifest + sidecars..."
MANIFEST_PATH="${SNAP_PATH}.manifest.json"
jq -n \
--arg name "$SNAP_NAME" \
--arg height "$HEIGHT" \
--arg hash "$BLOCKHASH" \
--arg sha "$SHA" \
--arg size "$SIZE" \
--arg msg "$MSG" \
--arg sig "$SIG" \
--arg addr "$ADDR" \
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--arg ver "$(rpc getnetworkinfo '[]' | jq -r '.result.version // "unknown"')" \
'{
schema: "triangles-utxo-snapshot-signed/v1",
name: $name,
generated_utc: $ts,
daemon_version: $ver,
chain_tip: { height: ($height | tonumber), blockhash: $hash },
snapshot_sha256: $sha,
snapshot_bytes: ($size | tonumber),
signing_address: $addr,
message: $msg,
signature: $sig
}' > "$MANIFEST_PATH"
# Sidecar files for easy reading
echo "$ADDR" > "${SNAP_PATH}.pubkey"
echo "$MSG" > "${SNAP_PATH}.msg"
echo "$SIG" > "${SNAP_PATH}.sig"
echo ""
echo "============================================================"
echo "Snapshot signed."
echo " snapshot: $SNAP_PATH"
echo " signature: ${SNAP_PATH}.sig"
echo " manifest: $MANIFEST_PATH"
echo " signer: $ADDR"
echo " sha256: $SHA"
echo "============================================================"
echo ""
echo "To verify on any node:"
echo " verifymessage $ADDR \\"
echo " '$SIG' \\"
echo " '$MSG'"
echo ""
echo "Or run: $0 verify $MANIFEST_PATH $SNAP_PATH"
+5 -2
View File
@@ -792,8 +792,11 @@ bool DownloadUtxoSnapshot(const std::string& host,
printf("Bootstrap: UTXO snapshot downloaded, loading into database...\n");
// Load the snapshot into a fresh active chain DB
if (!UtxoSnapshot::LoadSnapshot(tmpPath, dataDir, strError)) {
// Load the snapshot into a fresh active chain DB. P2P-delivered
// snapshots keep the checkpoint gate on (requireCheckpoint=true) —
// the manifest height+hash already passed IsKnownCheckpoint above,
// and we re-check here as defense in depth.
if (!UtxoSnapshot::LoadSnapshot(tmpPath, dataDir, strError, /*requireCheckpoint=*/true)) {
fs::remove(tmpPath);
return false;
}
+1 -1
View File
@@ -8,7 +8,7 @@
// These need to be macros, as version.cpp's and triangles-qt.rc's voodoo requires it
#define CLIENT_VERSION_MAJOR 5
#define CLIENT_VERSION_MINOR 9
#define CLIENT_VERSION_REVISION 14
#define CLIENT_VERSION_REVISION 17
#define CLIENT_VERSION_BUILD 0
// Converts the parameter X to a string after macro replacement on X has been performed.
+6 -1
View File
@@ -1025,8 +1025,13 @@ bool AppInit2()
printf("Found utxo-snapshot.bin — loading UTXO snapshot...\n");
uiInterface.InitMessage(_("Loading UTXO snapshot..."));
// Local file load: skip the checkpoint gate. The operator has
// filesystem access, so the trust model is already equivalent
// to direct chain state modification — a malicious local file
// is no worse than a malicious chain DB. P2P-delivered
// snapshots (SnapshotNet) keep the checkpoint gate on.
std::string strError;
if (UtxoSnapshot::LoadSnapshot(snapshotFile, dataPath, strError)) {
if (UtxoSnapshot::LoadSnapshot(snapshotFile, dataPath, strError, /*requireCheckpoint=*/false)) {
printf("UTXO snapshot loaded successfully.\n");
} else {
printf("UTXO snapshot load failed: %s\n", strError.c_str());
+30 -1
View File
@@ -3771,8 +3771,9 @@ bool FastImportBlockFile()
int64_t nFees = 0;
unsigned int nTxPos = nBlockPos + ::GetSerializeSize(CBlock(), SER_DISK, CLIENT_VERSION)
- (2 * GetSizeOfCompactSize(0)) + GetSizeOfCompactSize(block.vtx.size());
for (const CTransaction& tx : block.vtx)
for (size_t nTxIdx = 0; nTxIdx < block.vtx.size(); nTxIdx++)
{
const CTransaction& tx = block.vtx[nTxIdx];
uint256 hashTx = tx.GetHash();
CDiskTxPos posThisTx(1, nBlockPos, nTxPos);
txdb.UpdateTxIndex(hashTx, CTxIndex(posThisTx, tx.vout.size()));
@@ -3790,6 +3791,18 @@ bool FastImportBlockFile()
CUtxoEntry utxo;
if (txdb.ReadUtxo(txin.prevout.hash, txin.prevout.n, utxo))
nTxValueIn += utxo.nValue;
if (fAddressIndex && !utxo.scriptPubKey.empty() && utxo.nValue != 0)
{
int nAType; uint160 aHash;
if (GetAddressFromScript(utxo.scriptPubKey, nAType, aHash))
{
txdb.EraseAddressUtxo(nAType, aHash, txin.prevout.hash, txin.prevout.n);
int64_t nABal = 0;
txdb.ReadAddressBalance(nAType, aHash, nABal);
nABal -= utxo.nValue;
txdb.WriteAddressBalance(nAType, aHash, nABal);
}
}
txdb.EraseUtxo(txin.prevout.hash, txin.prevout.n);
}
nBlockValueIn += nTxValueIn;
@@ -3808,6 +3821,20 @@ bool FastImportBlockFile()
utxo.fCoinStake = tx.IsCoinStake();
utxo.nTxTime = tx.nTime;
txdb.WriteUtxo(hashTx, k, utxo);
if (fAddressIndex && !tx.vout[k].scriptPubKey.empty() && tx.vout[k].nValue != 0)
{
int nAType; uint160 aHash;
if (GetAddressFromScript(tx.vout[k].scriptPubKey, nAType, aHash))
{
txdb.WriteAddressUtxo(nAType, aHash, hashTx, k,
tx.vout[k].nValue, pindexNew->nHeight, tx.vout[k].scriptPubKey);
int64_t nABal = 0;
txdb.ReadAddressBalance(nAType, aHash, nABal);
nABal += tx.vout[k].nValue;
txdb.WriteAddressBalance(nAType, aHash, nABal);
txdb.WriteAddressTxId(nAType, aHash, pindexNew->nHeight, (int)nTxIdx, hashTx);
}
}
}
}
}
@@ -3859,6 +3886,8 @@ bool FastImportBlockFile()
// Final commit
if (pindexBest)
{
if (fAddressIndex)
UpdateAddressIndexSyncState(txdb, pindexBest);
txdb.WriteHashBestChain(hashBestChain);
// Write sync checkpoint
+5
View File
@@ -1453,6 +1453,7 @@ void TrianglesGUI::menuOperationsRequested()
QAction* unlockWalletStaking = menu.addAction(QIcon(":/menu_16/unlock"), tr("&Unlock Wallet...").remove('&').remove("..."));
QAction* lockWallet = menu.addAction(QIcon(":/menu_16/lock"), tr("&Lock Wallet...").remove('&').remove("..."));
QAction* changePassword = menu.addAction(QIcon(":/menu_16/passphrase"), tr("&Change Passphrase...").remove('&').remove("..."));
QAction* hdSeed = menu.addAction(QIcon(":/menu_16/passphrase"), tr("Seed Phrase (HD Backup)..."));
QAction* signMessage = menu.addAction(QIcon(":/menu_16/sign"), tr("Sign &message...").remove('&').remove("..."));
QAction* verifySignature = menu.addAction(QIcon(":/menu_16/verify"), tr("&Verify message...").remove('&').remove("..."));
@@ -1513,6 +1514,10 @@ void TrianglesGUI::menuOperationsRequested()
if (walletModel->getEncryptionStatus() == WalletModel::Unlocked || walletModel->getEncryptionStatus() == WalletModel::Locked)
changePassphrase();
}
else if (selected == hdSeed)
{
hdSeedManager();
}
else if (selected == signMessage)
{
gotoSignMessageTab();
+48 -3
View File
@@ -8,6 +8,12 @@
#include "checkpoints.h"
#include "util.h"
#include "ui_interface.h"
#include "addressindex.h"
#include <variant>
// defined in main.cpp
extern bool fAddressIndex;
#include <filesystem>
@@ -190,13 +196,38 @@ bool DumpSnapshot(const fs::path& destPath,
return true;
}
// Extract (type, hash160) from a scriptPubKey for the address index.
// Mirrors GetAddressFromScript() in main.cpp (which is file-static there).
static bool SnapAddressFromScript(const CScript& script, int& nType, uint160& hashBytes)
{
CTxDestination dest;
if (!ExtractDestination(script, dest))
return false;
if (const CKeyID* keyId = std::get_if<CKeyID>(&dest)) {
nType = ADDR_TYPE_P2PKH; hashBytes = *keyId; return true;
}
if (const CScriptID* scriptId = std::get_if<CScriptID>(&dest)) {
nType = ADDR_TYPE_P2SH; hashBytes = *scriptId; return true;
}
return false;
}
// ---------------------------------------------------------------------------
// LoadSnapshot - load a UTXO snapshot into a fresh LevelDB
//
// `requireCheckpoint` controls whether the snapshot's tip block must be a
// known checkpoint. This gate exists to prevent malicious P2P peers from
// tricking the daemon into accepting a fake UTXO set at an arbitrary
// height on an alternate chain. Local file loads (operator already has
// filesystem access, so the trust model is the same as editing the chain
// state directly) skip the gate via requireCheckpoint=false. P2P-delivered
// snapshots (SnapshotNet) keep the gate on.
// ---------------------------------------------------------------------------
bool LoadSnapshot(const fs::path& snapshotPath,
const fs::path& /*dataDir — unused; resolved per-backend via GetChainDataDir()*/,
std::string& strError)
std::string& strError,
bool requireCheckpoint)
{
FILE* file = fopen(snapshotPath.string().c_str(), "rb");
if (!file) {
@@ -252,8 +283,9 @@ bool LoadSnapshot(const fs::path& snapshotPath,
return false;
}
// Verify snapshot block is a known checkpoint
if (!Checkpoints::IsKnownCheckpoint(height, blockHash)) {
// Verify snapshot block is a known checkpoint (only for P2P-delivered
// snapshots — local files are operator-trusted and can be at any height)
if (requireCheckpoint && !Checkpoints::IsKnownCheckpoint(height, blockHash)) {
fclose(file);
strError = "Snapshot block " + blockHash.ToString() + " at height "
+ std::to_string(height) + " is not a known checkpoint";
@@ -383,6 +415,19 @@ bool LoadSnapshot(const fs::path& snapshotPath,
strError = "WriteUtxo failed at index " + std::to_string(i);
break;
}
// Address index: snapshot UTXOs are all unspent -> credit balance + record UTXO.
if (::fAddressIndex && !entry.scriptPubKey.empty() && entry.nValue != 0) {
int nAType; uint160 aHash;
if (SnapAddressFromScript(entry.scriptPubKey, nAType, aHash)) {
txdb.WriteAddressUtxo(nAType, aHash, txhash, nIndex,
entry.nValue, entry.nHeight, entry.scriptPubKey);
int64_t nABal = 0;
txdb.ReadAddressBalance(nAType, aHash, nABal);
nABal += entry.nValue;
txdb.WriteAddressBalance(nAType, aHash, nABal);
}
}
nBatchSize++;
if (nBatchSize >= 50000) {
+5 -1
View File
@@ -29,10 +29,14 @@ namespace UtxoSnapshot {
// Load a UTXO snapshot from a file into a fresh LevelDB.
// Writes block index entries, UTXOs, hashBestChain, and dbformat.
// The LevelDB must NOT be open yet (call before LoadBlockIndex).
// `requireCheckpoint` enforces that the snapshot tip is a known
// checkpoint (for P2P-delivered snapshots). Local loads from a
// trusted operator pass false.
// Returns true on success, sets strError on failure.
bool LoadSnapshot(const std::filesystem::path& snapshotPath,
const std::filesystem::path& dataDir,
std::string& strError);
std::string& strError,
bool requireCheckpoint);
} // namespace UtxoSnapshot