Skip to content

Commit 628b45e

Browse files
fix(syncguard): spot-check proof by bnum offset, not tfile tail
Two bugs uncovered during end-to-end WSL smoke testing on mainnet: 1. sg_proof_match_tfile_tail() required the proof segment to sit byte-exactly at the TAIL of the downloaded tfile. If the peer's chain advanced between scan_quorum() (proof fetch) and resync() (full tfile download) — even by a single block — the proof would no longer be at the tail and the check would fire false positives. Since tfile trailers are stored in bnum-continuous order starting from genesis (trailer at byte offset N*sizeof(BTRAILER) has bnum==N), we can instead seek directly to proof[0].bnum's offset and compare the proof's historical trailers in place. The tfile may have advanced past the proof's tip, but the proof's own range is historical and cannot change without a reorg. Renamed to sg_proof_match_tfile() to reflect the new semantics. 2. resync() called sg_session_reset() at entry, which also cleared the proof cache that scan_quorum() had just populated. The proofs must persist across the scan→resync boundary. Session reset is no longer automatic on resync(); the session caches now persist for the lifetime of the process (zero-initialized at startup via static storage, overwritten naturally on re-scan). sg_session_reset() remains available as an exported helper. Also downgraded diagnostic logs in sg_proof_match_tfile() from plog() to pdebug() so they only appear at debug log level. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 44d3549 commit 628b45e

3 files changed

Lines changed: 61 additions & 23 deletions

File tree

src/sync.c

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -216,10 +216,13 @@ int resync(word32 quorum[], word32 *qidx, void *highhash, void *highweight,
216216
return VERROR;
217217
}
218218

219-
/* Phase 3: reset session-local caches at the start of each resync
220-
* attempt. Bad-tfile and bad-chain caches are only meaningful within
221-
* the scope of a single sync attempt. */
222-
sg_session_reset();
219+
/* NOTE: session-local caches (bad-chain, bad-tfile, proofs) are not
220+
* reset here. The proof cache was populated by scan_quorum() and must
221+
* persist through the gettfile loop for the spot-check below to work.
222+
* bad-chain and bad-tfile caches must persist across resync retries
223+
* within the same process so we don't fall into repeated failures
224+
* on the same malicious peers. All caches are zero-initialized at
225+
* process start via static storage duration. */
223226

224227
show("gettfile"); /* get tfile */
225228
pdebug("fetching tfile.dat from %s", ntoa(&quorum[0], ipaddr));
@@ -273,12 +276,15 @@ int resync(word32 quorum[], word32 *qidx, void *highhash, void *highweight,
273276
continue;
274277
}
275278

276-
/* Phase 3 tail spot-check: the peer's validated proof segment
277-
* from scan_quorum() must appear byte-exactly at the tail of
278-
* the tfile they just served. Mismatch = they served a
279-
* different chain than they advertised. */
280-
if (sg_proof_match_tfile_tail(*quorum, "tfile.dat", NTFTX) != VEOK) {
281-
pdebug("gettfile: %s tfile tail does NOT match its advertised "
279+
/* Phase 3 spot-check: the peer's validated proof segment from
280+
* scan_quorum() must appear byte-exactly at the corresponding
281+
* bnum offset in the tfile they just served. The tfile may have
282+
* advanced past the proof's tip if the peer received new blocks
283+
* between scan_quorum() and resync(); we only require that the
284+
* historical trailers at the proof's bnum range match exactly.
285+
* Mismatch = they served a different chain than they advertised. */
286+
if (sg_proof_match_tfile(*quorum, "tfile.dat") != VEOK) {
287+
pdebug("gettfile: %s tfile does NOT match its advertised "
282288
"proof -- marking tfile bad", ntoa(quorum, ipaddr));
283289
sg_bad_tfile_add(tfile_hash);
284290
remove("tfile.dat");

src/syncguard.c

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
/* external support */
1919
#include "extmath.h" /* cmp64 */
20+
#include "extlib.h" /* get32 */
2021
#include "sha256.h"
2122
#include "extio.h" /* plog/perr */
2223

@@ -186,39 +187,63 @@ void sg_proof_clear_all(void)
186187
memset(Proofs, 0, sizeof(Proofs));
187188
}
188189

189-
/* Compare the tail of tfile at fname byte-exactly against the cached
190-
* proof segment for ip. Returns VEOK on match, VERROR on any mismatch
191-
* or I/O failure.
192-
* Caller should sg_bad_tfile_add() on mismatch. */
193-
int sg_proof_match_tfile_tail(word32 ip, const char *tfname, word32 count)
190+
/* Compare the cached proof segment for ip against the corresponding
191+
* range of trailers in the tfile at fname. The proof's first trailer
192+
* should appear at offset proof[0].bnum * sizeof(BTRAILER) in the
193+
* tfile (trailers are stored in bnum order starting from the genesis
194+
* block). The tfile may have advanced past the proof's tip if the
195+
* peer mined or received new blocks between scan_quorum() and
196+
* resync(); we only care that the proof's historical trailers match
197+
* the corresponding positions in the tfile.
198+
*
199+
* Returns VEOK on byte-exact match, VERROR on any mismatch, I/O
200+
* failure, or if the tfile does not contain the proof's range. */
201+
int sg_proof_match_tfile(word32 ip, const char *tfname)
194202
{
195203
sg_proof_t *p;
196204
FILE *fp;
197205
long long len, off;
198206
BTRAILER bt;
199207
word32 i;
208+
word64 first_bnum;
200209

201210
p = sg_proof_find(ip);
202-
if (p == NULL) return VERROR;
203-
if (count == 0 || count > p->count) count = p->count;
211+
if (p == NULL) {
212+
pdebug("sg_proof_match_tfile: no cached proof for peer");
213+
return VERROR;
214+
}
215+
if (p->count == 0) return VERROR;
216+
217+
/* compute byte offset for proof[0].bnum */
218+
memcpy(&first_bnum, p->proof[0].bnum, 8);
204219

205220
fp = fopen(tfname, "rb");
206221
if (fp == NULL) return VERROR;
222+
223+
/* check tfile length covers the proof range */
207224
if (fseek64(fp, 0LL, SEEK_END) != 0) { fclose(fp); return VERROR; }
208225
len = ftell64(fp);
209-
if (len < (long long)(count * sizeof(BTRAILER))) {
226+
off = (long long)(first_bnum * sizeof(BTRAILER));
227+
if (len < off + (long long)(p->count * sizeof(BTRAILER))) {
228+
pdebug("sg_proof_match_tfile: tfile too short (len=%lld, need=%lld)",
229+
len, off + (long long)(p->count * sizeof(BTRAILER)));
210230
fclose(fp);
211231
return VERROR;
212232
}
213-
off = len - (long long)(count * sizeof(BTRAILER));
214233
if (fseek64(fp, off, SEEK_SET) != 0) { fclose(fp); return VERROR; }
215234

216-
for (i = 0; i < count; i++) {
235+
for (i = 0; i < p->count; i++) {
217236
if (fread(&bt, sizeof(BTRAILER), 1, fp) != 1) {
237+
pdebug("sg_proof_match_tfile: fread failed at proof[%u]", i);
218238
fclose(fp);
219239
return VERROR;
220240
}
221241
if (memcmp(&bt, &p->proof[i], sizeof(BTRAILER)) != 0) {
242+
word32 tfile_bnum = (word32) get32(bt.bnum);
243+
word32 proof_bnum = (word32) get32(p->proof[i].bnum);
244+
pdebug("sg_proof_match_tfile: mismatch at proof[%u] "
245+
"(tfile bnum=0x%x, proof bnum=0x%x)",
246+
i, tfile_bnum, proof_bnum);
222247
fclose(fp);
223248
return VERROR;
224249
}

src/syncguard.h

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,15 @@
1212
* - Session-local cache of known-bad (weight, hash) chain pairs used
1313
* as an exclusion list when re-scanning after a quorum fails
1414
*
15-
* All caches are in-memory and cleared at the start of each resync()
16-
* attempt. No on-disk persistence.
15+
* All caches are in-memory and live for the lifetime of the process.
16+
* They are zero-initialized at startup via static storage duration.
17+
* Bad-chain and bad-tfile caches intentionally persist across resync()
18+
* retries so the node does not repeatedly fall into the same bad
19+
* actors. Proofs are populated by scan_quorum() and consumed by
20+
* resync(); stale proof entries are harmless (only used in lookups
21+
* keyed by current quorum-member IP) and are naturally overwritten on
22+
* subsequent scans of the same peer. sg_session_reset() is provided
23+
* for explicit clearing if ever required.
1724
*/
1825

1926
/* include guard */
@@ -52,7 +59,7 @@ int sg_hash_file(const char *fname, word8 out[HASHLEN]);
5259
/* Per-peer proof segment cache (session-local) */
5360
void sg_proof_store(word32 ip, const BTRAILER *proof, word32 count);
5461
int sg_proof_get(word32 ip, BTRAILER *out, word32 count);
55-
int sg_proof_match_tfile_tail(word32 ip, const char *tfname, word32 count);
62+
int sg_proof_match_tfile(word32 ip, const char *tfname);
5663
void sg_proof_clear_all(void);
5764

5865
/* Proof validation: structural chain climb + tip hash match against advertised.

0 commit comments

Comments
 (0)