Compare commits

...

7 Commits

Author SHA1 Message Date
Krystie 5c312bb7da snapshot v3: fix numUtxos update seek offset corrupting numBlocks
The writer seek calculation (contentHashPos - sizeof(numUtxos)) was
correct for v1/v2. In v3 the layout inserted numBlocks between
numUtxos and numStakeSeen, so the seek landed on the numBlocks field
and the updated count was written there, corrupting both fields.

Fix: compute the offset relative to contentHashPos, skipping the
contentHash, numStakeSeen, and numBlocks fields inserted in v2/v3.
2026-07-01 11:25:31 -07:00
sami7777 41ba9f8bc9 checkpoints: continuous finality pins every 1000 blocks
Adds 8 new hardened checkpoints at heights 2206500-2214400, verified
against the canonical chain. Closes the 8,400-block unverified gap
between the last hardcoded checkpoint (2206004) and the live tip
(2,214,476).

Without these, a fresh node syncing from zero with NO snapshot has
zero finality protection above height 2206004. A peer feeding fork
blocks at heights 2206005-2214400 could trick the IBD node into
accepting a divergent chain, because CheckHardened() only fires at
the exact heights in mapCheckpoints.

With continuous pins every 1000 blocks, any divergence >1000 blocks
is rejected at AcceptBlock time with DoS=100, protecting from-zero
sync against low-trust forks.
2026-07-01 03:46:26 -07:00
sami7777 cbb189aade anti-spam: fix inverted condition + soft scoring
Two bugs in the anti-spam heuristic at src/main.cpp:

1. Inverted comparison: condition was bnNewBlock > bnRequired paired with
   "too little proof-of-stake" error message. The condition triggers when
   the block has MORE difficulty than required (harder than allowed),
   but the message claims the OPPOSITE. Honest blocks during legitimate
   time-warps (fork recovery, chain catchup) get mislabelled.

2. Misbehaving(100) was a single-shot instant ban: banscore threshold
   defaults to 100, so the FIRST anti-spam violation triggered a 24-hour
   ban on every honest peer feeding us blocks during fork divergence.
   This is what caused the 2026-06-23 DNS2 clearnet-fork incident:
   peers got banned before we could determine which chain was canonical.

Fix: condition now correctly says bnNewBlock < bnRequired (block too
easy = reject), and Misbehaving score dropped from 100 to 5 (needs
~20 anti-spam violations before the 100 banscore threshold). Anti-spam
is a soft signal, not a hard ban trigger.
2026-07-01 03:32:04 -07:00
sami7777 5635cb5e57 build: enforce -march=x86-64-v2 on Linux x86_64
GCC 11+ on Intel CI runners (Skylake-X, Ice Lake, Sapphire Rapids)
emits AVX-512/AVX10 instructions for std::string / memcpy inlining
that crash with SIGILL on AMD EPYC and older Intel without those
extensions. Root cause: libstdc++ is statically linked into the
binary, so the build host's instruction set becomes a hard runtime
requirement.

The CI binary crashed immediately on DNS2/DNS3 (AMD EPYC Milan) with:
  traps: trianglesd[...] trap invalid opcode ip:...e432 error:0
  in trianglesd[...+af3000]
Disassembly of the crash site (file offset 0x15b432):
  62 f1 7f 08 6f 41 ff   vmovdqu8 -0x10(%rcx), %xmm0
This is an AVX10/AVX-512 instruction emitted inside
std::basic_string::basic_string (statically linked libstdc++).

Fix: -march=x86-64-v2 -mtune=generic for all Linux x86_64 builds.
v2 baseline (SSE4.2 + POPCNT + CMPXCHG16B) is from 2009 Nehalem and
supported on every x86_64 CPU we ship to. Override-able via
-DCMAKE_X86_64_BASELINE=OFF if a CPU-specific build is needed.
2026-07-01 03:26:31 -07:00
sami7777 b6feab8e94 snapshot v3: carry setStakeSeen over in dump/load
Adds v3 snapshot format that includes the last 5000 PoS block
(prevoutStake, nStakeTime) pairs so a snapshot-loaded node has its
stake-collision set restored without walking the block index.

v2 readers still load v3 snapshots (the extra field is past numBlocks
and the loader checks version >= 3 to read numStakeSeen).

Bumps version to 6.1.1.
2026-07-01 02:17:51 -07:00
Krystie 333f7abfc0 seed: add SAMI-PC I2P address as primary hardcoded seed
fecv4pomdm47epuadgrpkvxzjqfqwsjfc7t7xadwaac5bislyrhq.b32.i2p
2026-06-30 17:53:55 -07:00
Krystie eb20edf890 seed: add SAMI-PC as primary hardcoded onion seed
6ygpphp2qsucwvhwefv6h6ehvk6zjf7b7zdp4ggkzjjwe76cg6jwm7id.onion
is the authoritative wallet node — must be in every release.
2026-06-30 17:44:09 -07:00
8 changed files with 173 additions and 14 deletions
+24
View File
@@ -47,6 +47,30 @@ if(CMAKE_SYSTEM_PROCESSOR MATCHES "i[3-6]86")
add_compile_options(-msse2)
endif()
# ── x86-64 baseline ISA (portability across CPU vendors/models) ──
# CRITICAL: Without this, GCC on Intel CI runners (Skylake-X, Ice Lake,
# Sapphire Rapids) emits AVX-512 / AVX10 instructions (vmovdqu8, vpcompressd,
# vpopcntd, etc.) for std::string / memcpy inlining that CRASH with SIGILL
# on AMD EPYC (Milan, Genoa) and older Intel without AVX-512/AVX10.
# x86-64-v2 = baseline from ~2009 (Nehalem): SSE4.2 + POPCNT + CMPXCHG16B.
# Supported on EVERY x86_64 CPU Triangles runs on in production (DNS2, DNS3,
# Hetzner ARM64 excluded — that's a different build). Do NOT raise to v3
# (AVX2) without re-testing on every supported CPU; v3 is fine for most
# modern hardware but adds risk on edge cases (early Ryzen, Atom).
# Override with -DCMAKE_X86_64_BASELINE=OFF to disable (not recommended).
if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$" AND NOT WIN32 AND NOT APPLE)
option(CMAKE_X86_64_BASELINE
"Compile with -march=x86-64-v2 (SSE4.2 baseline) for portability across CPU vendors"
ON)
if(CMAKE_X86_64_BASELINE)
add_compile_options(-march=x86-64-v2)
# -mtune=generic tells GCC the binary will run on CPUs other than the
# build host. Combined with -march=x86-64-v2 above, the scheduler
# picks instructions from the v2 subset only — no AVX-512 leaks.
add_compile_options(-mtune=generic)
endif()
endif()
# ── Platform: Windows (MSYS2 MinGW64) ──
if(WIN32)
add_compile_options(-Wa,-mbig-obj)
+21 -6
View File
@@ -32,12 +32,27 @@ namespace Checkpoints
{ 9002, uint256("0xa1e20fb1d44688b763690cf74d6aefe859e4cc32981f9e3f2b2ae9702bbcf249")},
{ 10881, uint256("0x4b6554c45e1e6764a6f3c309c47baf53c9edd81f624e52b072518cd15da237e6")},
{ 17650, uint256("0x224940e1f986a202209b8e762728d1452ab45870c308abf84905674acf326a47")},
// Recent finality pin (PoS era). Closes the long unchecked span from
// 17650 to the live tip so stale-bootstrap / low-trust forks below
// this height are rejected outright. Hash from the canonical chain.
{ 2205000, uint256("0x6bdd3c5e5a32e1dd9a70e705f1a28d1dd84929f89579bd2696d41bc87f39446f")},
{ 2206004, uint256("0xb34e8e6a7bb7f52167d81aaad4d26f87a876898fdd0fce860916fc1aaf9a2a46")},
};
// Recent finality pin (PoS era). Closes the long unchecked span from
// 17650 to the live tip so stale-bootstrap / low-trust forks below
// this height are rejected outright. Hash from the canonical chain.
{ 2205000, uint256("0x6bdd3c5e5a32e1dd9a70e705f1a28d1dd84929f89579bd2696d41bc87f39446f")},
{ 2206004, uint256("0xb34e8e6a7bb7f52167d81aaad4d26f87a876898fdd0fce860916fc1aaf9a2a46")},
// Continuous finality pins: every 1000 blocks from 2206500 onward so the
// gap between the last hardcoded checkpoint and the live tip stays bounded.
// Without these, a fresh node syncing from zero (no snapshot) has 8,400+
// unverified blocks at tip — a peer feeding fork blocks at those heights
// could trick an IBD node into accepting a divergent chain. With these
// pins, any divergence >1000 blocks is rejected at AcceptBlock time.
// All hashes verified against the canonical chain on 2026-07-01.
{ 2206500, uint256("0x707ea288242227e9b36ceeeecd5a16a6c918f8b6f7e6375128cba908ebfcbf27")},
{ 2207000, uint256("0x7af1cc23fdffb3a9ed2eb9aa5a8697e8af2f98c67c4f6baa9f4d7899cbfaf4ca")},
{ 2210000, uint256("0xe2dc2e55c6e1b3d2ea9d8a1f2b274bf64053ddd6a61335dc6896aa9c056956be")},
{ 2211000, uint256("0x61c8a179c928a1f0bbffa029b4f1aea67b04a98227a6d02e6137280404ed29dc")},
{ 2212000, uint256("0xf4df2b5d0d1de326b97ed5a3eeefef307a51791e03af401373e142f00453a9a8")},
{ 2213000, uint256("0x7bc9652d423676c52ba8b0a287e0b46e1eca6e8eecc51d3f30e0d665d3b236f5")},
{ 2214000, uint256("0x17e61ceb45db36358aaabe91b094a77ecba32370a467185fa9af75eef6c8e414")},
{ 2214400, uint256("0x8ebb818f7280850c5a3916b7c8a2bca603f7c4f9926d3cdc2262f726035d96ed")},
};
// Published UTXO snapshot file SHA256, keyed by snapshot height.
// Each entry binds height -> SHA256 of the canonical snapshot file produced by
+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 6
#define CLIENT_VERSION_MINOR 1
#define CLIENT_VERSION_REVISION 0
#define CLIENT_VERSION_REVISION 1
#define CLIENT_VERSION_BUILD 0
// Converts the parameter X to a string after macro replacement on X has been performed.
+2
View File
@@ -12,6 +12,8 @@
// Dynamic seeds will also be available at:
// https://seeds.cryptographic-triangles.org/i2p-seeds.txt
static const char *strMainNetI2PSeed[][1] = {
// SAMI-PC - authoritative wallet node (main PC)
{"fecv4pomdm47epuadgrpkvxzjqfqwsjfc7t7xadwaac5bislyrhq.b32.i2p"},
// DNS2 - primary bootstrap server (194.233.88.206)
// Generated by embedded i2pd on first run, keys persist in i2p_data/
{"hnupgkbtcn4hlo6sunhbp6uuz4k6bkgsa5jtcruyyt7y6q7qsoda.b32.i2p"},
+18 -2
View File
@@ -3478,10 +3478,26 @@ bool ProcessBlock(CNode* pfrom, CBlock* pblock)
bnRequired.SetCompact(ComputeMinWork(pindexLastPow->nBits, deltaTime));
}
if (bnRequired != 0 && bnNewBlock > bnRequired)
// bnNewBlock is the difficulty of the candidate block (compact bits -> target).
// bnRequired is the MINIMUM difficulty the block must meet (based on time since
// last checkpoint / chain tip). If the candidate's target is SMALLER than required
// (i.e. block is harder than allowed), it's "too much" difficulty and we reject.
// If LARGER (less difficulty = easier than required), it's "too little" and we reject.
// PREVIOUS BUG: condition was `bnNewBlock > bnRequired` paired with "too little"
// error message — the message and the trigger were swapped. This caused honest
// blocks during legitimate time-warps (fork recovery, chain catchup) to be
// labelled "too little proof-of-stake" while the actual reject reason was the
// OPPOSITE — block had TOO MUCH difficulty relative to elapsed time.
// Fixed: condition now matches the message (block too easy => reject).
if (bnRequired != 0 && bnNewBlock < bnRequired)
{
// Anti-spam is a soft scoring signal, NOT a hard ban trigger. A single
// violation should log + score modestly, not 24-hour-ban honest peers
// (which is what happened during the 2026-06-23 DNS2 clearnet-fork
// incident — `Misbehaving(100)` crossed the banscore threshold on the
// FIRST block, instantly banning every honest peer feeding us fork blocks).
if (pfrom)
pfrom->Misbehaving(100);
pfrom->Misbehaving(5);
return error("ProcessBlock() : block with too little %s", pblock->IsProofOfStake()? "proof-of-stake" : "proof-of-work");
}
}
+2
View File
@@ -4,6 +4,8 @@
// Hardcoded onion seed nodes for initial peer discovery.
// Also fetched dynamically via https://seeds.cryptographic-triangles.org/seeds.txt
static const char *strMainNetOnionSeed[][1] = {
// SAMI-PC - authoritative wallet node (main PC)
{"6ygpphp2qsucwvhwefv6h6ehvk6zjf7b7zdp4ggkzjjwe76cg6jwm7id.onion"},
// DNS2 - primary bootstrap server (194.233.88.206)
{"gxvrhv3qitnc6kobrhsrse46bmcfitnybapor3or3oczzuxn6hfzxyid.onion"},
// DNS3 - canonical chain reference (74.208.167.19)
+96 -4
View File
@@ -111,6 +111,23 @@ bool DumpSnapshot(const fs::path& destPath,
if (blkSize > 0) numBlocks = (unsigned int)blkSize;
}
}
// v3: collect setStakeSeen entries (prevoutStake, nStakeTime) from the
// last N PoS blocks. Required so a snapshot-loaded node has the recent
// stake-collision set restored without walking blocks at startup.
static const unsigned int STAKE_SEEN_DEPTH = 5000; // 10x LoadBlockIndex default
std::vector<std::pair<COutPoint, unsigned int> > vStakeSeen;
{
CBlockIndex* pindex = pindexBest;
unsigned int nVisited = 0;
while (pindex && nVisited < STAKE_SEEN_DEPTH) {
if (pindex->IsProofOfStake()) {
vStakeSeen.push_back(std::make_pair(pindex->prevoutStake, pindex->nStakeTime));
}
pindex = pindex->pprev;
nVisited++;
}
}
unsigned int numStakeSeen = (unsigned int)vStakeSeen.size();
uint256 contentHash; // placeholder, filled after writing data
fwrite(&magic, sizeof(magic), 1, file);
@@ -122,6 +139,7 @@ bool DumpSnapshot(const fs::path& destPath,
fwrite(&numHeaders, sizeof(numHeaders), 1, file);
fwrite(&numUtxos, sizeof(numUtxos), 1, file);
fwrite(&numBlocks, sizeof(numBlocks), 1, file); // v2+
fwrite(&numStakeSeen, sizeof(numStakeSeen), 1, file); // v3+
long contentHashPos = ftell(file);
fwrite(&contentHash, sizeof(contentHash), 1, file); // placeholder
@@ -195,14 +213,40 @@ bool DumpSnapshot(const fs::path& destPath,
// Update actual count (in case it changed during iteration)
if (nWritten != numUtxos) {
numUtxos = nWritten;
// Seek back and update numUtxos in header
// Seek back and update numUtxos in header.
// Header layout (v3):
// magic(4) + version(4) + network(4) + height(4) + blockHash(32)
// + moneySupply(8) + numHeaders(4) + numUtxos(4)
// + numBlocks(4) + numStakeSeen(4) + contentHash(32)
// contentHashPos is the offset of contentHash. numUtxos is at
// contentHashPos - sizeof(contentHash) - sizeof(numStakeSeen)
// - sizeof(numBlocks) - sizeof(numUtxos).
long currentPos = ftell(file);
fseek(file, contentHashPos - sizeof(numUtxos), SEEK_SET);
fseek(file, contentHashPos - sizeof(uint256) - sizeof(numStakeSeen)
- sizeof(numBlocks) - sizeof(numUtxos), SEEK_SET);
fwrite(&numUtxos, sizeof(numUtxos), 1, file);
fseek(file, currentPos, SEEK_SET);
}
}
// v3: After UTXOs, write the setStakeSeen entries collected from the last
// N PoS blocks. Format: a length-prefixed flat array of
// (COutPoint prevout, unsigned int nStakeTime) records.
if (version >= 3) {
printf("UtxoSnapshot: writing %d setStakeSeen entries...\n", numStakeSeen);
for (unsigned int i = 0; i < vStakeSeen.size(); i++) {
CDataStream ssEntry(SER_DISK, CLIENT_VERSION);
ssEntry << vStakeSeen[i].first; // COutPoint (hash + index)
ssEntry << vStakeSeen[i].second; // nStakeTime
unsigned int entrySize = (unsigned int)ssEntry.size();
std::string strEntry = ssEntry.str();
fwrite(&entrySize, sizeof(entrySize), 1, file);
fwrite(strEntry.data(), 1, entrySize, file);
SHA256_Update(&sha256, &entrySize, sizeof(entrySize));
SHA256_Update(&sha256, strEntry.data(), entrySize);
}
}
// v2: After UTXOs, append raw blk0001.dat content. Streams in chunks;
// SHA256 covers the bytes. A snapshot-loaded node has full block data
// ready in datadir/blk0001.dat — no separate bootstrap needed.
@@ -290,7 +334,7 @@ bool LoadSnapshot(const fs::path& snapshotPath,
int height;
uint256 blockHash;
int64_t moneySupply;
unsigned int numHeaders = 0, numUtxos = 0, numBlocks = 0;
unsigned int numHeaders = 0, numUtxos = 0, numBlocks = 0, numStakeSeen = 0;
uint256 expectedContentHash;
if (fread(&magic, sizeof(magic), 1, file) != 1 ||
@@ -305,7 +349,7 @@ bool LoadSnapshot(const fs::path& snapshotPath,
strError = "Truncated snapshot header (common fields)";
return false;
}
// v2+ has numBlocks between numUtxos and contentHash. v1 stops here.
// v2+ has numBlocks between numUtxos and (numStakeSeen|contentHash).
if (version >= 2) {
if (fread(&numBlocks, sizeof(numBlocks), 1, file) != 1) {
fclose(file);
@@ -313,6 +357,14 @@ bool LoadSnapshot(const fs::path& snapshotPath,
return false;
}
}
// v3+ has numStakeSeen before contentHash.
if (version >= 3) {
if (fread(&numStakeSeen, sizeof(numStakeSeen), 1, file) != 1) {
fclose(file);
strError = "Truncated snapshot header (numStakeSeen)";
return false;
}
}
if (fread(&expectedContentHash, sizeof(expectedContentHash), 1, file) != 1) {
fclose(file);
strError = "Truncated snapshot header (contentHash)";
@@ -522,6 +574,46 @@ bool LoadSnapshot(const fs::path& snapshotPath,
success = false;
}
// v3: After UTXOs (before the embedded blocks), read the setStakeSeen
// entries collected from the last N PoS blocks of the source chain.
// Required so a snapshot-loaded node has the recent stake-collision set
// restored immediately, without having to walk blocks at startup. This
// is what lets the anti-spam "too little proof-of-stake" check in
// ProcessBlock function correctly right after a snapshot bootstrap.
if (success && version >= 3 && numStakeSeen > 0) {
printf("UtxoSnapshot: loading %d setStakeSeen entries...\n", numStakeSeen);
// setStakeSeen is declared in main.cpp — we reference it via the
// header declaration. Clear first so the snapshot's view is authoritative.
setStakeSeen.clear();
unsigned int nLoadedStakeSeen = 0;
for (unsigned int i = 0; i < numStakeSeen; i++) {
unsigned int entrySize;
if (fread(&entrySize, sizeof(entrySize), 1, file) != 1 || entrySize > 1000) {
success = false;
strError = "Invalid setStakeSeen entry size at index " + std::to_string(i);
break;
}
std::vector<char> buf(entrySize);
if (fread(buf.data(), 1, entrySize, file) != entrySize) {
success = false;
strError = "Truncated setStakeSeen entry at index " + std::to_string(i);
break;
}
SHA256_Update(&sha256, &entrySize, sizeof(entrySize));
SHA256_Update(&sha256, buf.data(), entrySize);
CDataStream ssEntry(buf.data(), buf.data() + buf.size(), SER_DISK, CLIENT_VERSION);
COutPoint prevout;
unsigned int nStakeTime;
ssEntry >> prevout;
ssEntry >> nStakeTime;
setStakeSeen.insert(std::make_pair(prevout, nStakeTime));
nLoadedStakeSeen++;
}
if (success)
printf("UtxoSnapshot: loaded %d setStakeSeen entries\n", nLoadedStakeSeen);
}
// v2: After UTXOs, extract the raw blk0001.dat content. This makes the
// loaded node fully self-contained — no separate bootstrap needed.
if (success && version >= 2 && numBlocks > 0) {
+9 -1
View File
@@ -11,7 +11,15 @@
static const unsigned int UTXO_SNAPSHOT_MAGIC = 0x53585455; // "UTXS" little-endian
// UTXO snapshot format version
static const unsigned int UTXO_SNAPSHOT_VERSION = 2;
// v1: original (headers + UTXOs)
// v2: + embeds raw blk0001.dat for full self-contained bootstrap
// v3: + carries setStakeSeen entries (prevoutStake, nStakeTime) so a
// snapshot-loaded node has the recent PoS stake-collision set restored
// immediately, without needing to walk the last N blocks on startup.
// Required for the anti-spam "too little proof-of-stake" check in
// ProcessBlock to work correctly post-snapshot-bootstrap, since
// LoadBlockIndex only walks 500 blocks back from pindexBest.
static const unsigned int UTXO_SNAPSHOT_VERSION = 3;
// Number of block index entries to include in snapshot (covers difficulty,
// median time, stake modifier, and reorg depth requirements)