Skip to content

fix: audit follow-up — DB resilience, read-path perf, ingest/clustering safety, ETH failed-tx fee correction#117

Open
soad003 wants to merge 115 commits into
developfrom
fix/audit-deeper-fixes
Open

fix: audit follow-up — DB resilience, read-path perf, ingest/clustering safety, ETH failed-tx fee correction#117
soad003 wants to merge 115 commits into
developfrom
fix/audit-deeper-fixes

Conversation

@soad003

@soad003 soad003 commented Jul 8, 2026

Copy link
Copy Markdown
Member

Overview

Follow-up fixes from the 2026-07-07 codebase audit — the higher-review items that
were held back from develop and done together on this branch. Covers Cassandra
connection/retry resilience, read-path performance, ingest/clustering safety, and
one data-correctness fix (ETH failed-transaction gas-fee accounting).

Each item is a separate commit. All changes pass the full pre-commit suite (ruff,
ty, full pytest, codegen check).

Changes

DB resilience (async + sync layers)

  • fix(db): stop blocking the event loop on Cassandra reconnect (90664f37) —
    connect() was recursive and re-created the whole Cluster on the read path on
    NoHostAvailable, blocking the asyncio reactor. It is now a bounded, non-recursive
    connect loop (connect_max_attempts, default 60), and the read path lets a
    transient NoHostAvailable propagate to the driver's own reconnection policy
    instead of tearing the cluster down.
  • fix(db): ride out transient stalls in the batch read helpers (3c07bf3c) —
    execute_statements_async and execute_batch did not route through the
    application-side _execute_with_backoff, so a cluster-wide stall failed them
    immediately. They now ride out TRANSIENT_DB_ERRORS on the calling thread
    (per the retry architecture in CLAUDE.md — backoff never runs on the driver
    reactor). Reads only; write paths already retry at the apply layer.

Read-path performance (web)

  • perf(web): normalize_address_transactions bounded-concurrent (cd77544b) —
    per-row tx lookups were serial (up to pagesize sequential Cassandra round-trips
    per request); now processed with a bounded asyncio.gather (cap 100). Same
    results, far less tail latency.
  • perf(web): list_links filters the fresh page + concurrent cluster ids
    (fad0192c) — filtering now operates on the freshly fetched page instead of the
    growing accumulator, and cluster-membership checks resolve concurrently. Also
    tightens links?token_currency=… so only the requested token's rows are fetched
    (no native-asset leak into token-filtered results).

REST serialization safety (web + regenerated python client)

  • fix(web): make first_tx/last_tx Optional, guard first_tx_id == -1
    (f869ea3f) — addresses/entities with the -1 "no originating tx" sentinel
    (miner-reward addresses, and now ETH fee-only senders — see below) no longer 500.
    Regenerates the OpenAPI python client (Address model).

Ingest / clustering safety

  • fix(ingest): remove orphaned side-table rows on append resume (592e2f1f) —
    Delta append-mode writes side tables before the block table, so a crash between
    them left rows that a resume from MAX(block_id)+1 would re-append as duplicates.
    On resume we now delete block_id > <block-table max> from the side tables once,
    under the ingest lock, before catching up.
  • fix(transformation): lock + clamp for one-off clustering (580b56c4) —
    one-off clustering now holds the transformed-keyspace lock (so it cannot overlap a
    running delta-updater or Spark transform) and clamps any address id beyond the
    union-find size out of the feed (defensive against a Rust out-of-bounds panic on a
    stale snapshot), warning on drops.

Data correctness — ETH failed-tx gas fee (the one with data implications)

  • fix(deltaupdate): debit failed-tx gas fee to the sender (b4849d28) — on
    ETH the miner is credited the gas fee for every transaction including reverted
    ones, but the payer was debited only if already tracked in the batch. A sender that
    appears only in failed transactions was untracked, so its debit was dropped and
    the fee was minted from nowhere — total supply (Σ balances) drifts upward,
    monotonically, forever.
    Such fee-only senders are now added to the address set
    and get a zero-stat address row (first_tx_id = -1, mirroring miner rewards) so the
    debit lands and the fee balances. TRX is unaffected (no miner fee credit; failed
    txs are pre-filtered).

Deployment considerations

Group A — safe to roll out as-is, no data implications

90664f37, 3c07bf3c, cd77544b, fad0192c, f869ea3f.

These are connection/retry resilience, read-path performance, and API serialization
safety. They do not change stored data and do not require any re-transform or
backfill. cd77544b/fad0192c increase concurrent Cassandra reads per request
(bounded), so the only thing to watch is coordinator load under peak fan-out.
f869ea3f is a Web API model change → deploy the regenerated client alongside
gs-rest.

Group B — ingest / ops behavior changes (safe, but be aware)

  • 592e2f1f (append resume dedup): only affects append-mode ingest, and only
    on a resume after a crash. It issues a DELETE on the Delta side tables for
    block_id above the last fully committed block — i.e. exactly the rows that would
    otherwise become duplicates. It never touches data at or below the last committed
    block, so already-correct keyspaces are unaffected. No migration; effective on next
    resume.
  • 580b56c4 (clustering lock): one-off clustering will now block while a
    transform/delta-update holds the transformed-keyspace lock (intended — prevents a
    torn read). No change to existing data.

Group C — ETH failed-tx fee (b4849d28): forward-only correction, history needs a re-transform

This is the one item where "just roll it out" needs a caveat.

  • Forward-only and self-contained: the delta-updater applies the corrected
    behavior to newly processed blocks immediately. It does not depend on the
    keyspace already being re-transformed, and it introduces no dependency on the Spark
    side. Safe to deploy on its own — no new inflation accrues after it lands.
  • Historical data is NOT corrected by this PR. Balances inflated by past
    failed-tx fees remain wrong until the canonical Spark fix + full re-transform
    lands (tracked separately, scheduled by ops "eventually"). This PR is the lib half
    only; it deliberately does not attempt to rewrite history.
  • New model state introduced: "an address with a (negative) balance but
    no_transactions = 0 / first_tx_id = -1". Consumers must tolerate this:
    • REST API — handled in this PR (f869ea3f). Deploy the gs-rest change together
      with / before the delta-updater change
      so these addresses serialize without a
      500 the first time one is queried.
    • Clustering — N/A (account model, no address clustering).
    • Dashboard / any downstream that assumes "nonzero balance ⇒ ≥1 transaction" —
      worth a quick check before promoting to a user-facing keyspace.
  • Id divergence is expected and harmless: the fee-only senders get ids appended
    after the current id space (normal delta-updater behavior). A later full
    re-transform reassigns every id from scratch anyway, so there is no id-scheme
    coordination to get right now.

Suggested rollout order: Group A + B anytime → deploy gs-rest (f869ea3f) →
deploy the delta-updater (b4849d28). Schedule the Spark re-transform separately to
clear the historical inflation.

Testing

  • New unit tests: bounded connect() retry, batch-helper ride-out, append-resume
    orphan cleanup, ETH fee-only entity delta + a supply-conservation property test
    (nets to zero with the fix, inflates by exactly gas_used × gas_price without it).
  • Full pytest, ruff, and ty green (enforced by pre-commit on every commit).

Not in this PR / follow-ups

  • The canonical Spark failed-tx-fee fix + historical re-transform / backfill
    (the other half of the ETH correction).
  • The MCP 421 fix and CHANGELOG/release consolidation already landed on develop
    separately.

Tommel71 added 30 commits June 3, 2026 17:41
… clustering

One-off path (run_clustering_spark): bulk token-range reads of raw.transaction and address_ids_by_address_prefix, zero-copy Arrow feed into the Rust union-find (process_transactions_arrow), connector bulk-write of fresh_address_cluster / fresh_cluster_addresses, then a fresh_cluster_stats (size + min_address_id) aggregate. Incremental delta clustering reads only address->cluster and stats for touched addresses, classifies each component new/join/merge, and reads full membership only for the absorbed side of a merge; per-partition fetch_size-paged reads (read_partitions_concurrent) replace the multi-partition IN that could ReadTimeout on large clusters. Adds fresh_cluster_stats table + transformed_utxo v1->v2 migration.
…read

Harvest each batch's multi-input address-id sets from the txs the delta loop already holds and cluster per batch before persist, instead of re-reading the whole block range and re-resolving addresses after every run. last_synced_block then also marks clustering progress: a crash redoes the batch and re-clusters idempotently. run_fresh_clustering stays as the stand-alone range re-cluster for backfill/recovery and the regression tests.

Adds Rust unit tests (arrow null/empty/accumulate, Clustering struct path), Python harvest-helper unit tests, and a one-off vs incremental partition-equivalence test across batch sizes.
… parity

Seed a small hand-crafted raw UTXO dataset into Cassandra and assert the PySpark one-off (run_clustering_spark) and the incremental run_fresh_clustering produce the same address partition across block batches, reusing compare_cluster_partitions. Lives in the regression suite (needs live Cassandra + Spark); production code runs via current_venv subprocesses.
Cap the one-off clustering to transactions with block_id <= end_block so
the chain can be clustered as of a given height. No start bound: clustering
is transitive over the full history, so a sub-range start is meaningless.
Threads end_block through multi_input_address_id_sets and run_clustering_spark
and exposes it as the transformation cluster --end-block CLI option. Adds a
manufactured-dataset regression asserting a capped one-off equals incremental
over the same [0, end_block] range.
Add the legacy cluster table's stat columns (no_incoming/outgoing_txs,
in/out_degree, first/last_tx_id, total_received/spent and their _adj
variants) to fresh_cluster_stats via migration 2_to_3, so the table can
carry the per-cluster stats the weekly recompute will populate. Columns are
nullable; nothing writes them yet. size + min_address_id stay the live tier.
Aggregate the address-level tables (address + address_incoming/outgoing_
relations) through the fresh address->cluster membership into the full per-
cluster stats: size, min_address_id, raw total_received/spent (element-wise
currency sums), first/last_tx_id, and relation-derived in/out_degree and
no_incoming/outgoing_txs (distinct neighbour clusters / summed tx-counts over
external edges, self-edges dropped). Pure DataFrame transforms with synthetic-
frame unit tests; recompute_fresh_cluster_stats is the Cassandra I/O wrapper.

New CLI 'transformation recompute-cluster-stats' runs it under the transformed-
keyspace lock (same lock the delta updater holds) and truncate-rebuilds for a
self-healing weekly job. Membership is untouched; total_*_adj left unset (no
delta-updater reference definition exists).
Cassandra has no ADD IF NOT EXISTS for columns, so re-applying an ALTER TABLE ... ADD raises a column-conflict rather than 'already exists'. Treat both as already-applied so a partially-applied migration can be re-run.
address_cluster_mapping_v2 plus best_cluster_tag_v2 / tag_count_by_cluster_v2 alongside the existing objects for the clustering cutover. Additive and idempotent (IF NOT EXISTS, unique indexes for REFRESH CONCURRENTLY); legacy views untouched.
…luster totals

- one-off relabels every cluster to min(address_id); incremental elects the smallest-root survivor, so cluster_id == root == min(address_id) end-to-end

- total_received_adj/total_spent_adj = summed external inter-cluster estimated_value (cluster totals minus intra-cluster flow)

- fresh_cluster_stats.size counts every member via a left join

- input-id resolution drops unresolvable (multisig) addresses instead of raising
- qualify CREATE TABLE/TYPE incl. IF NOT EXISTS; provision pyspark in the venv; strip JDWP/SPARK_HOME and pin worker python for the Spark subprocess; pass the setuptools_scm version to the docker build

- split batches so a real two-cluster merge runs, and assert cluster_id == min(members) on both the one-off and incremental paths
The recompute is just the Spark job that rebuilds fresh_cluster_stats;
the cadence is an infra/scheduling concern, not a property of the code.
GRAPHSENSE_TAGSTORE_FRESH_CLUSTERS routes the cluster-tag read path to the
fresh-clustering address_cluster_mapping_v2 / *_v2 materialized views via a
per-call resolver in queries.py. Independent of the write-side
GRAPHSENSE_FRESH_CLUSTERING_ENABLED so v2 can be populated and validated while
reads stay on the legacy relations; defaults off, instant rollback by unsetting.
…o_addresses

Give the fresh tables the same id_group partitioning as the legacy address /
cluster tables: fresh_address_cluster keyed (address_id_group, address_id),
fresh_cluster_addresses (cluster_id_group, cluster_id, address_id),
fresh_cluster_stats (cluster_id_group, cluster_id). Keeps partition count and
Spark-scan chunks bounded at billion-row scale instead of one partition per id.
Also renames fresh_cluster_stats.size -> no_addresses to match the legacy
cluster column the tagstore mapping feeder reads.

bucket_size (= configuration.bucket_size) is threaded into the one-off Spark
writers, the delta-loop writes, and the fresh point-read helper, which gained
optional composite-key (group_column) support. Manufactured parity regression
uses a tiny bucket so the dense ids span several distinct buckets.
run_clustering_spark PHASE 4 now runs recompute_fresh_cluster_stats
instead of the size+root-only backfill, so a one-off bootstrap leaves
fresh_cluster_stats complete (totals, first/last tx, degrees, tx-counts,
adjusted totals), not just no_addresses + min_address_id. Drop the now
dead backfill_fresh_cluster_stats.
# Conflicts:
#	CHANGELOG.md
#	src/graphsenselib/deltaupdate/deltaupdater.py
#	tests/regressions/tests/clustering/ingest_runner.py
…GSTORE_FRESH_CLUSTERS

The env var that already flips tagstore reads now also flips the feeder:
Cassandra read source (fresh_address_cluster/fresh_cluster_stats), Postgres
write target (address_cluster_mapping_v2), and the *_v2 MV refresh + staleness
sample. Exclusive switch; first v2 fill needs a full --rerun.
…RESH_CLUSTERING_ENABLED

Entity stats now read fresh_cluster_stats and cluster membership reads
fresh_cluster_addresses when the flag is on (UTXO only), matching the
address-level fresh_cluster_id path. Also fixes get_fresh_cluster_id to
include the address_id_group partition key (was an unservable query).
Fresh clustering persists only multi-member clusters, so an address absent
from fresh_address_cluster is its own singleton (cluster_id==address_id, 1
member). The UTXO feeder left-joined fresh_address_cluster and inserted the
resulting NULL gs_cluster_id, violating NOT NULL. Now coalesces missing
cluster_id to address_id (mirrors the account-model branch); no-op for the
legacy address.cluster_id path. Verified end-to-end: acm_v2 maps both
multi-member (cluster 33) and singleton (cluster 1) LTC addresses.
Fresh clustering persists only multi-member clusters, so singleton ids
have no fresh_cluster_stats row. Under GRAPHSENSE_FRESH_CLUSTERING_ENABLED:
- get_address_entity_id resolves the fresh cluster id (falls back to
  address_id) instead of the stale legacy address.cluster_id
- get_entity synthesizes a one-address entity for singletons
- list_entities restores singletons dropped from the bulk lookup,
  preserving input order and dropping only genuinely unknown ids
…0_to_1 edit

transformed_utxo_0_to_1 shipped in v2.13.0 creating fresh_address_cluster /
fresh_cluster_addresses with a single-column partition key. The partition-bucket
reshape had edited that released migration in place, which is a silent no-op on
any keyspace already past schema_version 1 (version-gated + CREATE IF NOT
EXISTS) - all prod UTXO keyspaces are at v1 with the old shape, so the new
bucketed reads would fail there.

Revert 0_to_1 to its released bytes and deliver the reshape as a new
transformed_utxo_3_to_4 (DROP + CREATE, since Cassandra cannot ALTER a primary
key). Safe: the tables hold only regenerable derived data and are empty on every
keyspace that has not run fresh clustering. schema.sql keeps the final bucketed
shape for greenfield; both v1 and v0 paths converge to v4.
Under GRAPHSENSE_TAGSTORE_FRESH_CLUSTERS the feeder reads membership from
fresh_address_cluster, which stores only multi-member clusters. A batch of only
singleton addresses resolved to an empty get_cluster_ids and hit the early
return DataFrame(), dropping the whole batch - and singletons are the majority
under fresh clustering. Empty membership now maps every address to its own
singleton cluster (cluster_id == address_id) via _as_singleton_clusters; legacy
behavior (empty means not-found) is unchanged when the switch is off.
- Remove is_fresh_clustering_empty (analytics): no callers after the delta
  updater stopped gating on a first-run emptiness check.
- read_partitions_concurrent: drop the unreachable non-bucketed branch (all
  callers pass group_column) and turn the bucket_size=None foot-gun (TypeError
  at k // bucket_size) into an explicit ValueError.
…st crate

The shipped incremental path reads only touched addresses and classifies
new/join/merge in Python; the rebuild-full-mapping-then-diff API is unused.
Removes both pymethods, the snapshot field, and their tests; rebuilt crate.
fresh_cluster_stats no_incoming_txs/no_outgoing_txs summed per-edge
no_transactions over address relations, over-counting a multi-input tx once
per member address. Count distinct tx_id per cluster from address_transactions
instead (new cluster_tx_counts); degrees and adjusted totals stay edge-based.
Counting per-address is_outgoing split double-counted the change pattern
(spend from member A, change to member B of the same cluster) in BOTH
directions. Legacy computeClusterTransactions sums signed value per
(cluster, tx) and assigns one direction from the net sign. Sum the signed
address_transactions.value per (cluster, tx) and count by net sign; net==0
counts in neither. Subsumes the earlier distinct-count fix.
parallel_find_all collected a Vec<(u32,u32)> then split it with a
single-threaded into_iter().unzip() — an ~8 GB transient plus a serial O(n)
pass on the driver at BTC scale (~1e9 ids). Fill cluster_ids in place in
parallel by index and generate address_ids from the dense range.
…s recompute

cluster_relation_stats inner-joins the anchor endpoint (result-identical: the
base left-join drops singleton-anchored rows downstream) to shrink the degree/
value aggregation shuffles. recompute persists members once — it feeds three
joins under different partitionings that ReuseExchange cannot dedupe.
soad003 added 3 commits July 8, 2026 15:22
…ustering + clamp union-find ids to avoid Rust panic
…upply inflation (forward-only fee-only sender addresses)
@soad003 soad003 requested a review from Tommel71 July 8, 2026 14:29
Tommel71 and others added 26 commits July 8, 2026 16:33
A resolved address.cluster_id whose stats row is absent (dangling
reference, e.g. a tip cluster whose stats write lagged, or a not-yet
written fresh_cluster_stats row) left no_addresses NaN after the merge;
the unconditional .astype(int) then raised IntCastingNaNError, aborting
the whole multiprocess recalc on a single bad row.

Keep the real cluster_id and store the unknown size as NULL, not a
fabricated 1 (gs_cluster_no_addr = 1 is the singleton sentinel in the
cluster-tag views, so 1 would misroute a real cluster's tags).
When a resolved cluster_id yields no no_addresses, log each row and
classify the cause: no row in the cluster table (no_cluster_row) vs a
row whose no_addresses column is NULL (row_present_null_no_addresses),
plus a per-batch summary. Lets a full recalc reveal the mechanism and
prevalence without crashing.
The structural fold seeds a cluster from its members' pre-batch address
rows (the delta clusters before the batch's address writes commit), so it
misses every touched member's current-batch activity — not just existing
members growing between mutations, but also newly-clustered addresses'
own-batch activity. That made the money/tx columns only weekly-fresh,
leaning on recompute-cluster-stats.

Mirror the legacy cluster table: fold every touched address's per-batch
EntityDelta onto its fresh cluster (from_activity_delta, membership-neutral)
so pre-batch fold + this-batch propagation == the from-scratch member sum.
Columns are now always-fresh; the recompute is only an optional backstop.
Singletons are skipped (their pre-join history folds structurally on join),
and a merge's absorbed side is guarded against resurrection.

Cost: one fresh_address_cluster + fresh_cluster_stats read per batch over
all touched addresses (not just co-spends). Verified by a new test that
grows address rows across batches and asserts delta == one-off on final
sums (new cluster, activity-only batch, merge, join).
The test job ran the full 4-way Python matrix (~6.5 min) and the build
job depended on it, serializing it in front of every publish and roughly
doubling wall-clock (~6 -> ~11-13 min). Tests still run via run_tests.yaml
on pushes and PRs; publishing no longer waits on them.
Build the gs_clustering wheel before COPYing ./src so Python-only
changes no longer invalidate the (expensive) Rust compile, and add
BuildKit cache mounts for the cargo registry/git and crate target dir
so the arrow/pyo3 dependency tree is compiled once and reused. The
wheel is copied out to /wheels since cache mounts aren't part of the
layer. The publish workflow persists those mounts across CI runs via
actions/cache + buildkit-cache-dance.

Measured locally: Rust compile 53.9s cold -> 3.7s when only crate code
changes; a Python-only change skips the Rust layer entirely.
fresh_cluster_stats is an absolute row folded from its own stored value
(stored + this batch's activity), so committing it ahead of the batch's
commit point let a crash-and-replay fold the same activity onto itself
twice. The structural writes alone were replay-safe -- on a redo the
members are already assigned and the join no-ops -- so this only became
reachable once activity propagation landed.

Append the clustering changes to self.changes instead of applying them
inline, so they ride the same WAL record and the same commit as the
address rows and the bookkeeping that advances last_synced_block. This is
the ordering TX mode already used. _apply_clustering_inputs stays for the
standalone backfill, which re-reads committed rows and may commit per chunk.

Guard tests cover the staging, the activity-only batch (no co-spend, which
no test previously exercised), the untouched batch, and the fresh rows and
their deletes through the WAL codec -- the fresh tables enter the WAL here
for the first time.
Conflicts were in the one-off clustering path, which both branches hardened
independently.

transformation/cli.py, transformation/clustering.py: resolved to
feature/clustering2. It replaces the Cassandra-fed one-off
(run_clustering_one_off_from_cassandra) with the Spark/Arrow one
(run_clustering_spark), already holds the transformed-keyspace lock this
branch was adding, and brings the recompute-cluster-stats command.

Ported this branch's out-of-bounds id guard onto the new Arrow feed: the
union-find is sized from summary_statistics.no_addresses, which can lag the
real highest address id, and Rust indexes its node array directly (verified:
feeding id 999 into a size-11 union-find panics at unionfind.rs:39). The
bound check is a vectorized max over the chunk, so the zero-copy fast path is
unaffected; only a chunk that trips it pays a Python fallback. flatten() is
used rather than .values so a sliced ListArray does not read the parent
buffer and mis-fire.
Add the adversarial folds the suite had no coverage for: chain merge inside
one batch, a member absorbed while it transacts, a singleton's pre-join
history, a clustered member with no address row -- plus seeded fuzz asserting
after every batch that each cluster equals the from-scratch member sum.

Verified non-vacuous: double-folding activity, dropping it, or leaking
no_addresses from the activity contribution each fail all nine.
The suite assumed both servers share a key namespace, which holds only for
two throwaway containers. Pointing BASELINE_SERVER at a real deployment needs
a key that its gateway accepts, so add CURRENT_AUTH / BASELINE_AUTH overrides;
unset, behaviour is unchanged. The manual suite sent no Authorization header
at all, which a gateway-fronted baseline rejects.
A deployment behind an API gateway sees injected headers the bare local server
never gets. X-Consumer-Groups in particular drives tag obfuscation, so without
it every private-tag field reads as a difference. Add CURRENT_HEADERS /
BASELINE_HEADERS (JSON); unset, behaviour is unchanged.
…istutils shim

pyspark 3.5 imports distutils.version at runtime (hit by every
createDataFrame(pandas_df)); on Python >= 3.12 distutils only exists via
setuptools' shim and the python:3.13-slim image ships no setuptools.
Dropping the runtime setuptools dep (0dcece3) therefore broke Spark
jobs in the Docker image with ModuleNotFoundError: No module named
'distutils'. Dev/testing venvs were unaffected (dependency groups still
pull setuptools), which is why tests never caught it. Remove again once
pyspark >= 4.
UTXO relations pair net senders with net receivers, but list_links
filtered on raw io membership, so an address netting to an outflow while
appearing in the outputs produced a link with no relation edge. The
relations pre-check correctly found no edge and returned an empty
result, disagreeing with the full walk and with the neighbor list.

Filter on the netted flows instead (and fetch only the netted direction
of the second address), making links, neighbors and the pre-check
consistent.
An address on both sides of a tx was counted in reg_in and reg_out,
creating relations and stats the spark transform never writes: relations
pair net senders with net receivers, and stats count each tx on the net
side only. Net the flows first, matching computeAddressTransactions +
splitTransactions in graphsense-spark.
The fresh multi-input harvest (delta updater, driver backfill, and Spark
one-off) merged coinjoin co-spends that the legacy Scala clustering
filters out. All three paths now respect the transformed keyspace's
configuration.coinjoin_filtering (default true); a NULL coinjoin flag
counts as not-coinjoin. The Scala-parity regression's from-scratch
harvest skips flagged txs the same way.
The net-sender-in-outputs address must not be linked (no relation edge,
consistent with the neighbor list); the pure-output receiver of the same
tx stays a baseline-compared link. The invariant test intentionally
fails against pre-netting servers.
make rest REF=<ref|url|local> CUR=<ref|url|local> SIZE=small|medium|large
resolves each side (git ref/working tree -> docker build+serve, hosted
URL -> direct), wires auth and gateway headers, and runs the manual,
fuzz, and loki suites cumulatively by size. No args prints full help
with runtime estimates. Refresh stale README.
DEPTH=quick|standard|full (old small|medium|large kept as aliases)
communicates the runtime tradeoff directly. Loki runs with pytest-xdist
(LOKI_WORKERS, default 8), cutting the full depth from hours to
~15-30 min; estimates in the help corrected accordingly.
BTC is at ~0.7 * 2**32 unique addresses and cluster ids are derived
from address ids (root == min address id), so the 2**32 offset would
collide within its remaining headroom. Raw ids in the fresh_* tables
and tagstore v2 relations are unshifted, so no data migration is
needed; shifted ids stay far below the 2**53 JS-safe-integer limit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants