The Dockerfile in packaging/docker/ downloads the daemon .deb from
the release URL during the build. On tag-push, the release record is
created immediately but the .deb asset gets uploaded a few seconds
to minutes later by the build job.
Race condition seen on v5.9.24 distribute run #24 (2026-06-24 01:10 UTC):
- Workflow fired on tag push
- Docker Hub job started step 5 'Build and push' immediately
- Dockerfile's curl returned 404 for the .deb
- Job failed in 18 seconds; release .deb was uploaded ~8 min later
AUR and WinGet jobs already had this wait step; Docker Hub was the
only one missing it. Added the same pattern (poll for URL reachability
up to 30 * 20s = 10 min).
Two errors from PR #391813 manifest validation (build 349844):
1. 'The schema header URL does not match the expected pattern.'
I used raw.githubusercontent.com URLs, but the validator wants
the aka.ms short URLs that the official winget-bot uses.
Updated all 3 files to https://aka.ms/winget-manifest.*.1.12.0.schema.json
2. 'Silent and SilentWithProgress switches are not specified for
InstallerType exe.'
TrianglesQt installer is built with NSIS (see build-all.yml
'Install NSIS via MSYS2' step + mingw-w64-x86_64-nsis package).
NSIS silent flag is /S. Added both Silent and SilentWithProgress.
Closes superseded PR microsoft/winget-pkgs#391813 (same Manifest-Validation-Error).
Two pre-existing latent bugs in the WinGet job template:
1. The line '# yaml-language-server: $schema=...' was inside a
<<EOF heredoc, so bash treated $schema as an undefined variable
and stripped it down to '=https://...'. The resulting YAML still
parsed (since the $schema line is just an editor comment), but
IDE auto-complete and editor-side validation were broken.
Fix: escape the $ as \$ in the heredoc so bash leaves it alone.
2. INSTALLER_URL was set in the workflow env: block with literal
${VERSION} placeholders. GitHub Actions only substitutes \${{ }}
expressions in env values, not ${}. So the bash $VERSION got
expanded but the URL kept ${VERSION} literal in the output —
meaning the published manifest had a broken InstallerUrl that
the Microsoft validator would 404 on (and a literal ${VERSION}
string in SHA-source comparison).
Fix: use ${{ env.VERSION }} in the workflow YAML so GitHub Actions
substitutes it at runtime. Then bash gets the real version string
and the heredoc just expands the resulting env var.
The winget-pkgs repository has tightened its accepted schema. Per
doc/ValidationFailureGuide.md:
- 'Manifest-Version-Deprecated: Update your manifest to use a supported
schema version. The recommended schema version is 1.12.0
(1.10.0 is also accepted).'
- 'Manifest-Validation-Error: Address all reported errors and resubmit.'
What changed in the template heredocs:
1. ManifestVersion: 1.6.0 → 1.12.0 in all 3 files
2. Version file: dropped Publisher/PublisherUrl/PackageName/License/
ShortDescription (those belong in defaultLocale only).
Replaced PackageLocale: en-US with DefaultLocale: en-US — that
field was renamed in schema 1.12.
3. Installer file: replaced InstallerMode: interactive with
InstallModes: [interactive, silent] (the singular 'InstallerMode'
was removed; InstallModes is now an array per-installer or root).
Dropped PackageLocale (not part of installer schema) and
InstallerScope: user (no longer supported at root, only per-installer).
4. Added # yaml-language-server: $schema=... comment to all 3 files
pointing at the official 1.12.0 JSON schemas — helps editor/IDE
auto-complete AND validates against the same schema the winget
validators use.
Supersedes PR microsoft/winget-pkgs#391801 (closed in same batch —
manifests there used the 1.6.0 schema and got Manifest-Validation-Error).
Sami's winget-pkgs submission bot has been firing one PR per release.
Three of them (#391151/391368/391388) were generated with a buggy path
format and accumulated PullRequest-Error / Needs-Author-Feedback labels
before Sami noticed. That pattern reads as spam to winget-pkgs moderators
and risks the maintainer goodwill we've built with stephengillie.
Two new safeguards:
1. Pre-flight check (distribute.yml, winget job):
- Before opening a PR, scan existing SamiAhmed7777 PRs on
microsoft/winget-pkgs for PullRequest-Error or
Needs-Author-Feedback labels
- If any are found, abort this submission with a clear error
- Also skip if a PR for this exact version is already open
2. New winget-watchdog.yml workflow (cron */30 * * * *):
- Every 30 min, scan open SamiAhmed7777 PRs
- For each one, inspect wingetbot comments for validation result
- If a PR has automatic-validation failure comments, post a
summary comment + close the PR automatically
- This prevents 'broken PR opened, forgotten for 24h' pattern
that creates the spam appearance
Both changes keep the existing tag-triggered release flow intact.
PUBLISHER_INITIAL was hardcoded to 'C' but the winget-pkgs convention
requires lowercase 'c' for the first-letter prefix folder. Additionally,
the manifest was being placed at manifests/c/CryptographicTriangles/<full
PackageIdentifier with dot>/<version>/, but the correct convention is
manifests/c/CryptographicTriangles/<short package name>/<version>/ — the
file *names* still use the full PackageIdentifier (e.g.
CryptographicTriangles.TrianglesQt.installer.yaml).
Without these fixes, microsoft/winget-pkgs Automatic Validation rejects
the PR with: "the casing of the file in disk or identical file is not
merged" because the path written to the (Windows, case-insensitive)
validator filesystem differs from what's in the git tree.
Closes superseded PRs microsoft/winget-pkgs#391151, #391368, #391388.
Three pure helper functions extracted from ThreadHTTPSeedFetch2 into
netbase.{h,cpp} so the HTTPS seed-list code path can be unit-tested
without the SSL/Tor network stack:
int DechunkTransferEncoding(const std::string& body, std::string& out)
std::vector<std::string> ParseSeedListBody(const std::string& body)
bool IsValidSocksNegotiationTimeout(int nMs)
DechunkTransferEncoding is now strict (was lenient):
- Hex validation: every byte of the chunk-size line is checked with
isxdigit() before strtoull. Old code passed a raw strtoul() result
which silently accepted leading '+', '-', and whitespace.
- strtoull + errno + size_t bounds check replaces the silent
'if (pos+chunkSize > body.size()) chunkSize = body.size()-pos'
clamp. The old behavior would mask truncated network reads.
- Empty size lines, '+5' / '-5' / ' 5', and unsigned overflow all
return DECHUNK_INVALID_HEX (or DECHUNK_OVERSIZE_CHUNK for the
bounds case) instead of being treated as 0/last-chunk.
- Missing CRLF after chunk data returns DECHUNK_MISSING_DATA_CRLF
rather than being read as the next chunk-size line.
- Body without a '0\r\n' last-chunk terminator returns
DECHUNK_NO_CHUNK_TERMINATOR instead of silently being accepted.
- Chunk extensions ('5;foo=bar') are still preserved — the ';'
delimiter is stripped from the size line, not from the framing.
ParseSeedListBody is a 1:1 extraction of the old loop. Same behavior
on every input. Trims inline '#' comments, splits on whitespace /
comma / semicolon, normalizes CR-only line endings.
IsValidSocksNegotiationTimeout is the central policy: 5000..180000 ms
inclusive. Replaces the inline 'nTorTimeout >= 5000 && nTorTimeout <=
180000' check in init.cpp's AppInit2. Out-of-range values now emit an
InitWarning so the operator sees why their setting was ignored.
Six distinct failure-mode log messages in ThreadHTTPSeedFetch2:
1. 'cannot connect to %s through Tor proxy' — connect failure
2. 'malformed response (no header terminator)' — no \r\n\r\n
3. 'malformed chunked transfer encoding (%s)' — DechunkResult enum
reason string
4. 'empty response from %s' — 0 bytes read
5. 'parsed response contained zero valid addresses' — body parsed
but CService
validation
dropped all
6. '%d addresses found from HTTPS seed list' — success path
Help text for -torconnecttimeout now precisely describes what the
value bounds (the SOCKS5 handshake — send/recv of init/auth/connect),
not 'time to reach the onion' which was misleading. The onion-resolution
time is bounded by Tor's own SocksTimeout (~120s) and is not directly
controllable from the daemon.
src/test/http_seed_tests.cpp adds 43 new Boost.Test cases covering
every scenario in the hardening brief:
DechunkTransferEncoding: 16 cases
- single chunk, multiple chunks, chunk extensions (one and
multiple), uppercase hex, payload containing CRLF, awkward
boundary that looks like a chunk-size line, last-chunk with
extension
- empty body, no CRLF after size, invalid hex, empty size line,
oversize chunk, truncated last-chunk marker, missing data CRLF,
strtoul overflow, sign in size, whitespace in size, no last
chunk
ParseSeedListBody: 14 cases
- empty, single-per-line, CRLF endings, multiple-per-line
(space, comma, semicolon, mixed), inline comments, blank lines,
all-comments, portless onion, invalid entry preserved, trailing
whitespace, mixed CRLF/LF
IsValidSocksNegotiationTimeout: 9 cases
- 4999 (out), 5000 (in, exact lower), 60000 (in, default), 180000
(in, exact upper), 180001 (out), 0 (out), -1 (out), INT_MAX
(out, guard against wraparound), 3 midrange values
Integration: 1 round-trip case
- Encode a seed body as chunked, dechunk it, then parse the
result. Verifies the two helpers compose correctly.
Test results: 183 test cases total, *** No errors detected. Existing
onion_v3_tests (8) and netbase_tests (10) still pass.
1. -torconnecttimeout config option (init.cpp, netbase.h, netbase.cpp)
SOCKS5/Tor negotiation bound. Default 60s. Range 5-180s. Without this, a
dead/slow .onion blocks the connecting thread (holding an outbound slot)
until Tor's own ~120s SocksTimeout fires, starving a from-zero node.
Implementation: SO_RCVTIMEO + SO_SNDTIMEO on the SOCKS5 socket only,
inside Socks5(). Both Linux/BSD and Win32 paths. Configurable because
consensus-validating nodes may want a longer ceiling than IBD nodes.
2. HTTP seed fetch: chunked-encoding support (net.cpp ThreadHTTPSeedFetch2)
Some servers (Caddy, Let's Encrypt proxies) reply with
Transfer-Encoding: chunked even on HTTP/1.1 + Connection: close. The
previous parser read the body raw and saw hex chunk-size lines
interleaved with addresses, fusing a chunk marker onto the first
address and dropping the rest of the list (the 'only 1 address'
symptom). De-chunk first when header advertises chunked, then parse.
3. Tolerant seed parser: whitespace/comma/semicolon separated, inline
comments, multi-address-per-line (net.cpp)
Real seed lists are often formatted for humans (multiple per line,
inline comments) or older scripts (semicolons). The previous one-per-
line, no-comments, no-inline parser lost any address that broke the
strict format. Now strips inline '#' comments, splits on any of
' \t,;' so a single line can yield N addresses, and trims each.
Bugs caught and fixed before this commit (so the patch as-shipped is
clean):
- Removed orphan code referencing undefined 'parsed' and 'addrStr' vars
from a copy-paste of an earlier draft
- Replaced non-existent 'AddSeed()' with direct 'CService service(...)'
construction followed by 'addrman.Add(CAddress, CService)' (correct
addrman.Add signature, not CNetAddr)
- Tightened 'addrman.Add' call to the actual signature: address + source
A canonical starting point for new operators. Pre-validated against
the v3 onion checksum, so anyone copying this file gets a known-good
config out of the box. Documents:
* The 7 hardcoded seeds from src/onionseed.h (with port 24112)
* How to add the 7 dynamic seeds from seeds.cryptographic-triangles.org
(commented out, since the daemon fetches them automatically)
* The pre-commit hook installation instructions
* The Tor-only requirement (notor=0 must stay)
* Standard index flags (txindex, addressindex, spentindex, timestampindex)
* dbcache sizing guidance
The 7 hardcoded seeds were taken verbatim from src/onionseed.h and
verified by scripts/validate_onion_seeds.py. The C++ test suite
src/test/onion_v3_tests.cpp also re-validates them at every build.
Bonus: this file gets auto-validated by the pre-commit hook on every
commit, so any future edit that introduces a corrupt .onion will be
caught before it can reach a deployment.
Adds Finding 8 (corrupted v3 .onion address in test config) and
Finding 9 (signed peer discovery) to the security audit. Documents
the full chain:
4,842 Tor 'No more HSDir' errors
→ identified as bad .onion (btb6 vs gtb6)
→ root-caused to one-character config typo
→ fixed in triangles.conf
→ built validator tool (scripts/validate_onion_seeds.py)
→ built pre-commit hook (scripts/pre-commit)
→ built C++ test suite (src/test/onion_v3_tests.cpp)
→ shipped signed peer discovery (commit 9e9d17e)
Includes a defense-in-depth table showing the 4 layers of protection
now in place (Tor checksum, Python validator, C++ tests, signed peers).
Also documents 3 remaining gaps for future work:
1. No signing on seeds.cryptographic-triangles.org seed list
2. No audit log of when the btb6 typo was introduced
3. getwalletaddr creates a new key per call (should use stable node identity)
Adds src/test/onion_v3_tests.cpp with 8 Boost.Test cases that validate
every hardcoded seed in src/onionseed.h against the v3 hidden service
checksum algorithm (SHA3-256 of ".onion checksum" || pubkey || version).
Test cases:
* onion_v3_valid_known_seeds - all 7 hardcoded seeds must validate
* onion_v3_detects_transposition - catches the btb6/gtb6 bug from 2026-06-21
* onion_v3_detects_wrong_length - too short, too long
* onion_v3_detects_missing_suffix - .com instead of .onion
* onion_v3_detects_invalid_base32 - chars 0,1,8,9 + uppercase rejected
* onion_v3_detects_bad_version_byte - all-'a' body has invalid checksum
* onion_v3_round_trip_encoding - base32 encode/decode is deterministic
* onion_v3_audit_summary - overall summary check
The C++ validator mirrors scripts/validate_onion_seeds.py exactly so the
two implementations stay in sync. Catches corruption at CI/build time
instead of daemon runtime.
Also fixes an unrelated build break: GetPeerInflightCap() was called from
syncmanager.cpp:533 but never declared in syncmanager.h. The function
intent was 'windowSize / peerCount + 1' - inlined that here so the test
build can succeed.
The hook scans every staged file for:
1. Filename matches: triangles.conf, *.onion
2. Content matches: lines starting with 'addnode=' followed by a
base32-encoded .onion address
If any address fails v3 onion checksum validation, the commit is blocked
with a clear diagnostic showing the bad address, the reason, and (when
possible) a suggestion of the correct address.
Run with --ci mode on the validator so it exits 1 on any failure.
Install:
cp scripts/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
Bypass (NEVER do this for normal commits):
git commit --no-verify
Tested:
✓ Clean config: commit allowed, validator says PASSED
✓ Corrupted config (btb6 vs gtb6): commit blocked with full
diagnostic + 'did you mean: gtb6?' suggestion
Detects corrupted .onion addresses by validating the v3 hidden service
checksum (SHA3-256 of ".onion checksum" || pubkey || version).
Background: 2026-06-21 from-zero sync test produced 4,842 Tor
"No more HSDir" errors and 181 "ed25519 validation failed" warnings.
Root cause: a 1-character transposition (btb6 vs gtb6) in the test
config's vmepp seed address. This tool would have caught it in 0.1s.
Usage:
./scripts/validate_onion_seeds.py /root/.triangles/triangles.conf
./scripts/validate_onion_seeds.py /path/to/triangles.conf --ci
./scripts/validate_onion_seeds.py /path/to/triangles.conf \
--against /root/triangles_v5/src/onionseed.h
Features:
* Validates every addnode= line against v3 onion checksum
* Suggests the correct address if 1-2 char transposition detected
* Detects truncated/extended/non-base32 addresses
* Cross-checks multiple configs (catches test vs prod mismatches)
* CI mode exits 1 on any failure (gates deploys)
* Pure stdlib, no pip deps (works in any Python 3.8+ env)
Triangles already has a node-identity signing system (getwalletaddr/walletaddr
in onion_v3.cpp:4793-4848) that lets peers cryptographically prove they own
their .onion address. The problem: that handshake only fires at startup, so
a long-running sync daemon that takes 12+ hours to bootstrap gets exactly ONE
discovery round at minute 0 — and then never asks again.
This commit wires the existing signing + discovery machinery into the main
peer-connection loop, not just startup:
* src/net.h: add nLastGetaddrTrigger + nSignedPeerBonus fields to CNode
* src/net.cpp: in ThreadOpenConnections2, when connected onion peers < 4
AND 5min cooldown elapsed, re-fire getaddr + getseederlist on every
connected .onion peer. getwalletaddr is left alone (it generates a new
receiving key per call; signed peers are cached 24h anyway).
* src/tor/onion_v3.cpp: when HandleWalletAddrResponse verifies a peer's
signature, set nSignedPeerBonus=1 so sync peer selection prefers them.
* src/syncmanager.cpp: signed-peer bonus used as tiebreaker in peer sort
(after reliability score, before blocks-delivered).
Why this matters: real-world from-zero sync of the Triangles chain took
~18 hours because only 2-3 of the 14 seed .onion nodes were reliably
reachable from any given Tor instance. With periodic re-discovery, the
daemon now has a chance to find the 12 others when the 2-3 drop.
Verified: built clean (15:59), test daemon climbed from 70,828 → 73,997+
at ~1.9 blk/s with new binary, SYNC-SIGN message confirmed firing.
Avoid 'fetch first' errors when the same version gets re-distributed
(multiple tags or workflow re-runs). Each run uses its own branch in
the winget-pkgs fork.
- Chocolatey 'Check' step: add shell: bash so the [ -z ] syntax parses
- WinGet fork: remove --fork flag (renamed), use --remote=false instead
which omits the clone in the same step
distribute.yml:
- New 'chocolatey' job: updates nuspec version + install script SHA256,
packs .nupkg, pushes to chocolatey.org. Gated by CHOCO_SKIP_WACATAC
env var so it can be disabled while the Microsoft false-positive is
still active (set CHOCO_SKIP_WACATAC=true on the repo, flip to empty
after Microsoft clears the detection).
- New 'winget' job: forks microsoft/winget-pkgs (auto-creates fork if
needed), generates the three manifest files (version/locale/installer)
in the winget-pkgs v1.6.0 format, opens a PR.
Both jobs use the Windows setup.exe as the installer source.
Both jobs skip gracefully with a warning if their respective GitHub
secrets aren't set.
packaging/chocolatey/tools/chocolateyInstall.ps1:
- Rewritten to use the NSIS installer (.exe) instead of the old .zip
format (the v5.9.x release ships an NSIS .exe setup)
- Uses $env:ChocolateyPackageVersion so the workflow can substitute the
version at pack time
- checksum64 is '__CHECKSUM_PLACEHOLDER__' which the workflow replaces
with the computed SHA256
Required GitHub secrets (all added):
CHOCO_API_KEY - Chocolatey API key
WINGET_TOKEN - GitHub PAT with public_repo scope
The previous commit had a literal '***' placeholder where the GitHub
Actions expression ${{ secrets.HOMEBREW_GITHUB_TOKEN }} should have
been. The workflow couldn't parse, so runs showed as 'failure' with
zero jobs and the display name fell back to the file path.
Fixed by writing the correct expression directly.
Observed the workflow firing on regular push-to-master events, not just
tag pushes. GitHub is sometimes over-eager about workflow re-runs on
commits that touch the workflow file. Add an explicit job-level guard
if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v')
to all four jobs so the distribute jobs only run on tag pushes or
manual workflow_dispatch events.
New 'homebrew' job in distribute.yml:
- Waits for the macOS .dmg to be available on the GitHub release
- Computes the new SHA256
- Clones SamiAhmed7777/homebrew-triangles
- Updates version + sha256 in both Formula/triangles.rb and
Casks/cryptographic-triangles.rb
- Commits and pushes to main
- Skips gracefully with a warning if HOMEBREW_GITHUB_TOKEN is not set
Required GitHub secret: HOMEBREW_GITHUB_TOKEN (added)
GitHub Actions doesn't allow 'secrets' context in 'if:' conditionals,
only in 'env:'. Reworked the workflow to:
- Capture DOCKERHUB_TOKEN and AUR_SSH_KEY into env vars at job level
- Each step that needs a secret checks env.* and exits 0 with a
::warning:: annotation if not set
- Skipped steps display a final summary in the job log
Same behavior, just no parser errors.
New workflow .github/workflows/distribute.yml:
- Triggers on v* tag push (and workflow_dispatch for manual runs)
- Docker job: builds + pushes to samiahmed7777/trianglesd with both
:VERSION and :latest tags, plus a post-push smoke test
- AUR job: runs in archlinux container, downloads the release .debs,
updates PKGBUILD with new version + SHA256s, regenerates .SRCINFO
via makepkg, commits and pushes to AUR via SSH
- Both jobs skip gracefully (with a clear warning) if their respective
GitHub secrets aren't set, so the workflow can be merged and tested
before secrets are configured
- Waits up to 10 minutes for the build-all release artifacts to be
available (build-all and distribute run in parallel on the same tag)
Required GitHub secrets:
DOCKERHUB_TOKEN — Docker Hub access token (have in vault)
AUR_SSH_KEY — Private key of the AUR packager (~/.ssh/aur_key)
Docker:
- Dockerfile now extracts from cryptographic-triangles-daemon_5.9.20_amd64.deb
(release no longer ships raw linux-x64 binaries)
- Multi-stage build with .deb extraction
- Includes triangles-cli alongside trianglesd
- LD_LIBRARY_PATH wrapper for the bundled lib/ dir
AUR:
- Bump triangles-qt-bin to 5.9.20
- Switch from raw linux-x64 binary download (no longer published) to
extracting the official .deb packages
- Bundle version-pinned libs in /opt/triangles/lib
- Add triangles-cli to provides
From-zero sync test confirmed: chain advances past 15k freeze zone
to 17k+ with no stall. Build clean (149/149 Ninja targets).
132/132 unit tests pass.
Sync-freeze patch (original):
- Backpressure ceiling HEADER_FRONT_MAX_AHEAD=8000
- PruneHeaders protects live sync window (nProtectFloor)
- Hard-cap eviction from highest-height first
- Bridge-repair getheaders from connected tip via PathReachesChain
Additional fix:
- Skip PoW check on PoS headers (nonce=0) in AddHeaderNode
Block 1026 is PoS but within the 0-9000 PoW range — old code
rejected valid PoS headers and severed the chain at height 1025
Verified: from-zero no-snapshot sync reached block 17k+ past the
old 15k freeze zone. 132/132 unit tests pass.
FastImport removal in commit bdb7253 made the v2 UTXO snapshot the
canonical sync start. The legacy DownloadBootstrap() function still
attempted to fetch /triangles-bootstrap.tar.gz first, then fell back to
filelist.txt — which still contained tri-bootstrap.tar.gz. Both legacy
URLs return 404 (cleaned up 2026-06-19), so the wallet wasted a request
on a dead path before reaching the v2 snapshot URL.
Changes:
- DownloadBootstrap() no longer tries /triangles-bootstrap.tar.gz.
- Goes straight to filelist.txt → downloads the URL listed there (now
utxo-snapshot.bin only, after the bootstrap server fix).
- Removed unused ExtractTarGz() helper function (~110 lines).
- Kept DEFAULT_HOST in bootstrap.h — init.cpp still references it
for the SnapshotNet P2P fetch.
No version bump. v5.9.20 binary built locally; SHA
ad34764e28fb0c922a3f3570e830ba5707fdc2f7f7a11301e8c0f60356048fd3.
Bootstrap server fix landed first:
- /var/www/triangles-bootstrap/filelist.txt now contains only
'utxo-snapshot.bin' (was tri-bootstrap.tar.gz + triangles-bootstrap.tar.gz).
This means existing laptop wallets (no rebuild needed) will now read the
updated filelist.txt on next bootstrap attempt and go straight to the
v2 snapshot URL.
FastImport was the legacy path for rebuilding the block index from a
local blk0001.dat. With v2 UTXO snapshots now containing embedded
blocks, FastImport is redundant and dangerous (could silently index
a forked chain from a stale blk0001.dat).
Changes:
- src/main.cpp: delete FastImportBlockFile() function (~270 lines)
- src/main.h: delete FastImportBlockFile() declaration
- src/init.cpp: delete -allowfastimport flag handler block
remove from help text
clean up stale comments referencing FastImportBlockFile
- src/bootstrap.cpp: update stale comments
v2 snapshot loading (auto-download from bootstrap or local placement
of utxo-snapshot.bin + manifest) is now the only supported sync start.
Tested: daemon builds, runs, chain state preserved across restart.
Binary SHA: 3f26f6202947a8dc0f7933314829702aafa1e42c968368ab7ec043d57baa9519
DNS2 + DNS3 running this build, both on correct chain.
Not bumped to v5.9.21 per Sami's preference. Next formal release
will inherit this change.
A bash command interface to trianglesd RPC designed for Hermes, Krystie,
and Sami to manage TRI wallets and communicate via the built-in secure
messaging system (smessage).
Features:
- Info: status, balance, peers, staking info
- Wallet: addresses, send, transactions
- Secure messaging: inbox, outbox, send (encrypted via ECDH over Tor P2P)
- Raw RPC passthrough for any daemon command
- Bash + zsh completion
- SSH-tunneled RPC for remote node access
- Config at /etc/tri/nodes.conf (shared between agents)
Files:
- scripts/tri/tri Main script
- scripts/tri/nodes.conf.example Config template
- scripts/tri/tri-completion.bash Bash completion
- scripts/tri/_tri_zsh_completion Zsh completion
- scripts/tri/README.md Documentation
Tested against live DNS3 node (block 2,207,455, 4 peers).
Secure messaging verified: send → inbox → outbox all working.
Three bugs prevented the wallet from automatically downloading the UTXO
snapshot when starting with stale blk0001.dat but no chain database:
1. NeedsBootstrap() only checked for blk0001.dat existence, not the chain
DB. If blk0001.dat was present (leftover from old version) but
txleveldb/chainstate was missing, it reported "no bootstrap needed"
and the snapshot download never triggered.
Fix: check for txleveldb/ or blocks/chainstate/ instead.
2. Bootstrap HTTP download was skipped when snapshotMode was true (the
default). The code deferred to P2P snapshot fetch (Step 11.6), but
that runs AFTER Step 7 which errored out on the FastImport gate.
Fix: always attempt HTTP bootstrap when NeedsBootstrap is true,
regardless of snapshotMode. The UTXO snapshot HTTP download IS the
fast path — no reason to defer to P2P when HTTP is available.
3. FastImport gate (Step 7) was a hard InitError that killed the daemon
before it ever reached the snapshot fetch path. blk0001.dat present
+ no chain index + FastImport disabled = immediate crash.
Fix: instead of erroring, remove the stale blk0001.dat and continue.
The daemon syncs from the snapshot that was already loaded in Step 6b,
or from P2P if that somehow failed.
Per Sami's vision: 'I want to carry over the whole block inside the
UTXO.' The snapshot is now self-contained: a fresh node loading it
has everything needed (headers + UTXOs + all block bodies) without
needing a separate bootstrap tarball.
Format change (UTXO_SNAPSHOT_VERSION 1 → 2):
v1 HEADER (88 bytes):
magic, version, network, height, blockHash, moneySupply,
numHeaders, numUtxos, contentHash
v2 HEADER (92 bytes):
same + numBlocks (between numUtxos and contentHash)
v2 CONTENT (after v1's headers + utxos sections):
blocks[numBlocks] ← raw blk0001.dat bytes, SHA256 included
DumpSnapshot changes:
- Walks ALL blocks from pindexBest to pindexGenesisBlock (was: last
N=2000). The nHeaders arg is honored only when caller passes a
count smaller than the full chain for v1-compat diagnostic snapshots.
- After headers + utxos sections, streams GetDataDir()/blk0001.dat
bytes into the snapshot, chunked (64 KB), content-hashed.
- Header now writes numBlocks between numUtxos and contentHash.
LoadSnapshot changes:
- Reads numBlocks after numUtxos when version >= 2.
- After UTXOs section, streams numBlocks bytes from the snapshot
into dataDir/blk0001.dat (uses GetDataDir() since the param dataDir
is intentionally unnamed in this function).
- v1 snapshots still load via the partial-load path (no numBlocks in
header, no blk0001.dat written).
- Empty snapshot check loosened to (numHeaders && numUtxos && numBlocks)
— all three must be zero to be considered empty.
Total v2 snapshot size: ~1.9 GB (headers + blocks + UTXOs).
Generation on the operator machine: a few minutes. Download on
reasonable connection: a few minutes.
This supersedes the earlier v2 attempt (commit 69529ea) which had
compile bugs from using an unnamed dataDir parameter and had wrong
snapshot file layout.
Adds the foundation for the snapshot-based IBD:
- sign-snapshot.sh: operator-side script to sign canonical snapshots
- utxosnapshot gate requireCheckpoint on trust source
- utxosnapshot build address index when loading (wallet balance support)
- main build address index during FastImport
- build: ignore build-*/ directories
Per Sami's vision: 'I want to carry over the whole block inside the
UTXO.' The snapshot should be self-contained so a fresh node is fully
usable — can serve blocks to peers, fully verify the chain, validate
txs, and resume syncing forward. Replaces the legacy tri-bootstrap.tar.gz.
Format change (UTXO_SNAPSHOT_VERSION 1 → 2):
v1 HEADER:
magic, version, network, height, blockHash, moneySupply,
numHeaders, numUtxos, contentHash (88 bytes)
v2 HEADER:
same + numBlocks (92 bytes) ← new field
v2 CONTENT (after v1's headers + utxos sections):
blocks[numBlocks] ← raw blk0001.dat bytes, SHA256 included
DumpSnapshot changes:
- Walks ALL blocks from pindexBest to pindexGenesisBlock (was: last
N=2000). The nHeaders arg is honored only when 0 < nHeaders < chain
height for v1-compat diagnostic snapshots.
- After writing headers + utxos sections, streams GetDataDir()/blk0001.dat
bytes into the snapshot, chunked (64 KB), content-hashed.
- Header now writes numBlocks between numUtxos and contentHash.
LoadSnapshot changes:
- Reads numBlocks after numUtxos when version >= 2.
- After the UTXOs section, streams numBlocks bytes from the snapshot
into dataDir/blk0001.dat.
- v1 snapshots (no numBlocks in header) still load via the partial
path: headers + UTXOs only, no blk0001.dat written. The 'block
verification skipped for snapshot-sourced chains' hack stays
for v1, becomes unnecessary for v2.
Total v2 snapshot size: ~1.9 GB (550 MB headers + 1.3 GB blocks + 50 MB UTXOs).
Generation on the operator machine: a few minutes. Download on
reasonable connection: a few minutes.
This commit is format-only — signature verification, auto-rebuild,
and the LoadBlockIndex crash fix from PR #8 still apply unchanged.
When LoadBlockIndex tries to reset the sync-checkpoint, it looks for
one of the known checkpoint blocks in mapBlockIndex and writes it to
the DB. For a freshly snapshot-loaded chain, mapBlockIndex only has
~1166 headers near the tip — none of the known sync checkpoints
(2205000, 2206004) are in that subset.
The reset returns false (no checkpoint found in main chain), and the
caller currently treats this as fatal: 'failed to reset sync-checkpoint'.
But for snapshot-sourced chains this is expected — the sync checkpoint
will be set when the node syncs past the next known checkpoint height.
Soften the failure: if fLoadedFromSnapshot is true, log a warning and
continue instead of erroring out.
After LoadSnapshot, the daemon has headers + UTXOs but the raw block
bodies haven't been downloaded yet — they'll arrive via P2P as the
node syncs past the snapshot tip. LoadBlockIndex's verification
loop tries to read the last 50 block bodies from disk and fails
with 'OpenBlockFile failed' because the data isn't on disk yet.
Add fLoadedFromSnapshot global, set true at the end of successful
LoadSnapshot. In both txdb-leveldb.cpp and txdb-rocksdb.cpp LoadBlockIndex
verification loops, when ReadFromDisk fails AND fLoadedFromSnapshot is
true, log a warning and continue (the UTXO set itself was already
content-hash verified during LoadSnapshot, so we have strong evidence
the chain state is correct).
For non-snapshot chains (full blk0001.dat downloaded, normal IBD), the
ReadFromDisk failure remains a fatal error as before.
Combined with the prior fix in utxosnapshot.cpp that sets
fSerializeChainTrust=true before writes, the full snapshot path now
works end-to-end on a fresh datadir.
THE BUG: CDiskBlockIndex serialization is gated by a static flag
fSerializeChainTrust. LoadBlockIndex later sets this flag to true
based on dbformat >= 2 and tries to read nChainTrust as part of every
CDiskBlockIndex record.
But LoadSnapshot runs FIRST and writes CDiskBlockIndex records while
the static is still at its default value (false). The records are
written WITHOUT nChainTrust. Then LoadBlockIndex reads with flag=true,
expects nChainTrust, runs off the end of the buffer → 'CDataStream::read():
end of data: iostream error' → AppInit() exception.
This bug affected every fresh snapshot load: the snapshot's headers
and UTXOs loaded correctly (the per-record writes work), then the
post-load LoadBlockIndex crashed. Sami identified this as the
'format mismatch' blocker; the signature verification work went in
first but the underlying serialization bug remained.
Fix: explicitly set fSerializeChainTrust=true at the top of LoadSnapshot
before any CDiskBlockIndex writes. Then writes include nChainTrust.
Then LoadBlockIndex reads with the same flag set → matches.
The snapshot FILE format itself is unchanged — old snapshots produced
by daemons that wrote with flag=false will still fail to load (their
records don't have nChainTrust). New snapshots produced by daemons
that always write with flag=true (i.e. always include nChainTrust)
will load cleanly.
Two operational changes that together fulfill the 'snapshot as
universal sync start' vision:
1. -autorerebuild=<n> CLI flag (default 0=disabled)
After Step 7 loads the chain DB, MaybeAutoRebuild() compares our
local nBestHeight to the median peer-reported height (collected via
CNode::nStartingHeight from the version handshake). If lag >= n,
wipe the chain DB (preserve wallet.dat, onion, smsg state) and
request shutdown. On restart, the daemon sees no chain DB and the
snapshot path takes over.
WaitForPeerHeights() polls up to 60s for at least 3 peers.
2. -allowfastimport CLI flag (default OFF)
The FastImportBlockFile() rebuild path is now gated behind this
flag. If the chain DB is empty and blk0001.dat exists, the daemon
fails with a clear error message that tells the operator how to
recover (place utxo-snapshot.bin, delete blk0001.dat, or set
-allowfastimport). FastImport is now operator opt-in only — the
snapshot path is the canonical sync start.
This matches Sami's vision: 'Everything should be transferred over
to the UTXO jump and then they should be able to put the blockchain
together exactly how it's supposed to be from all the peers
filling in all the blank spots.'
When I added the 2207680 checkpoint, I was treating checkpoints as the
authentication gate for snapshot loading. Sami corrected: 'It shouldn't
require a checkpoint, all it should require is a signature.'
Commit 2866a94 already replaced requireCheckpoint=true with signature
verification in DownloadUtxoSnapshot. This commit removes the now-
unnecessary checkpoint entry so the source stays clean — the signature
is the only gate for snapshots, period.
(2205000/2206004 checkpoints remain — they're separate concerns for
chain finality validation, not snapshot acceptance.)
DownloadUtxoSnapshot now authenticates snapshots via Triangles signed
messages instead of relying on hardcoded checkpoints.
New flow:
1. Fetch big manifest.json, find canonical snapshot entry
2. Fetch the per-snapshot manifest (utxo-snapshot-{h}.manifest.json)
3. Verify the signer address is in the trusted signers list (currently
Sami's TG8f76yktTxDrT7JJymY3wVAusXiD3fVvX)
4. Verify the signature cryptographically (Triangles compact-message
protocol with strMessageMagic prefix, same construction as
signmessage/verifymessage RPC)
5. Download snapshot file, verify SHA256 against manifest
6. Load with requireCheckpoint=false — signature is the gate
Per Sami: 'It shouldn't require a checkpoint all it should require
is a signature.' This removes the checkpoint coupling that was
breaking fresh-node sync (the 2207680 checkpoint gate rejected the
canonical snapshot even though it was validly signed).
Trusted signer list is currently a hardcoded constant. Future work:
-snapshotsigner=<addr> CLI arg (repeatable).
DownloadUtxoSnapshot now:
1. Fetches manifest.json from the bootstrap server
2. Locates the utxo_snapshot entry (filename + expected sha256)
3. Downloads THAT file
4. Verifies file SHA256 matches manifest
5. Falls back to legacy 'utxo-snapshot.bin' if manifest unavailable
Also add 2207680 checkpoint to mapCheckpoints so the canonical signed
snapshot (per 2026-06-18 manifest) passes the requireCheckpoint gate.
Defense in depth: server symlinks + daemon verifies the file matches.
* Add triangles-cli: JSON-RPC client (port bitcoin-cli pattern)
Triangles never had a CLI client (bitcoin-cli analog). This adds
triangles-cli as a third build target alongside trianglesd and
triangles-qt.
- src/triangles-cli.cpp: self-contained JSON-RPC 1.0 client.
Reads triangles.conf for credentials, supports -rpcuser/-rpcpassword
/-rpcconnect/-rpcport/-testnet/-datadir/-conf flags. Implements
-getinfo (synthesized summary from getnetworkinfo/getblockchaininfo
/getwalletinfo) and raw method dispatch. JSON via json_spirit compat
shim (json_compat.h), HTTP via boost::asio, base64 auth inline.
No util.cpp / wallet.cpp / net.cpp / triangles_common link dep —
keeps the binary small (~600 KB Linux, ~1.5 MB Windows).
- CMake: new option(BUILD_CLI ON) + add_executable(triangles-cli)
in src/CMakeLists.txt. Status line added.
- CI: BUILD_CLI=ON added to build-windows-daemon and build-linux-daemon
jobs. triangles-cli.exe bundled into windows-daemon artifact
alongside trianglesd.exe. triangles-cli added to linux-daemon .deb
package (with launcher in /usr/bin).
- Default ON; set BUILD_CLI=OFF to skip.
Closes the open 'triangles-cli.exe missing from Windows build'
follow-up (the binary wasn't missing — it never existed).
Patterned after Bitcoin Core bitcoin-cli and Dash Core dash-cli.
* Fix macOS build: drop Boost::system/find_package component, use std::filesystem
Homebrew's boost formula doesn't ship the boost_system CMake config file,
so find_package(Boost REQUIRED COMPONENTS system) failed on macOS.
- Replace boost::filesystem with std::filesystem (C++17, no Boost dep)
- Drop 'filesystem' from find_package — only headers needed (asio + system)
- Link libboost_system explicitly per-platform by library name, resolved
via the platform's default search path (Homebrew toolchain on macOS,
system libs on Linux, MSYS2 on Windows)
CI will rerun automatically on PR push.
* Fix macOS build: add Boost::boost target for headers, link boost_system
The previous fix dropped the find_package component but also killed the
boost include path. Now use the modern Boost::boost header-only target
(available in Boost 1.83+) which sets up include directories without
requiring a per-component config file.
Link libboost_system explicitly by name on all platforms — the linker
finds it via the platform's default search path:
- Linux: /usr/lib (libboost_system.so)
- macOS Homebrew: /opt/homebrew/lib (libboost_system.dylib)
- Windows MSYS2: mingw64/bin (libboost_system-mt-X-XX.dll)
* Drop Boost entirely from triangles-cli: use raw sockets for HTTP
Third time's the charm. After two CI failures chasing boost::asio / libboost_system
linking issues across platforms (Homebrew missing config on macOS, MSYS2 versioned
names on Windows, CMake targets that don't quite work everywhere), rip the whole
Boost dependency out of the CLI and use raw POSIX/Winsock sockets.
- triangles-cli.cpp: replaced boost::asio with raw socket() / connect() / send()
/ recv() / getaddrinfo(). Cross-platform: #ifdef _WIN32 for Winsock + WSAStartup
/ WSACleanup, else POSIX. ~100 lines of clean portable socket code.
- src/CMakeLists.txt: dropped find_package(Boost) entirely. Only links
json_compat (header-only) + ws2_32 on Windows. No boost libs to find.
Should be the last fix needed for this PR.
* Fix Windows packaging step: simplify bash { } | sort -u | while pattern
The previous step used a bash group command piped through sort -u and a
while loop. Under MSYS2 bash + 'set -e -o pipefail' (GitHub Actions
default), this triggered a non-zero exit even when the loop body
succeeded, causing the Windows daemon job to fail at the packaging step
(the actual link of both trianglesd.exe and triangles-cli.exe succeeded).
Replaced the { } | sort -u | while pattern with a temp-file-based dedup:
- ldd both binaries, append to /tmp/cli-dlls.txt (or cli-libs.txt on Linux)
- sort -u the temp file
- pipe the result into the while loop (simpler pipeline, no group)
Also applied the same simplification to the Linux .deb packaging for
consistency, even though the Linux build was passing.
* Simplify DLL packaging: plain for loop, no pipe-into-while
The previous attempts used 'ldd | sort -u | while read; do ... done' patterns
that exit 1 under MSYS2 bash + 'set -e -o pipefail' even when the script
ran successfully. Replaced with a plain 'for bin in ...; do ldd > list.txt;
while read; do cp; done < list.txt; done' pattern that has no pipelines
other than the standard redirection, and uses IFS= read -r for safe line
iteration.
Also moved temp files from /tmp to the working directory (./dll-list.txt)
to avoid any MSYS2 /tmp path-translation edge cases.
* diagnostic: add tracing to Windows packaging step
* Add package-windows-daemon.sh + package-linux-daemon.sh scripts
Move the Windows daemon packaging step and the Linux .deb build into
committed shell scripts under scripts/ci/. This bypasses GitHub Actions'
inline-run-block quirks (silent exit 1 under msys2 + set -e -o pipefail
with multi-line scripts) and makes the packaging logic debuggable locally.
* Switch to script-file packaging for Windows + Linux daemon jobs
Replace inline multi-line run: blocks with invocations of the
scripts/ci/package-*.sh scripts. This sidesteps the GitHub Actions
msys2 + 'set -e -o pipefail' issue that caused silent exit 1 on the
Windows daemon packaging step. The scripts are also debuggable locally.
---------
Co-authored-by: Krystie <krystie@sami>