Repository navigation
graphsense-lib 2.16.4
·
251 commits
to master
since this release
[2.16.4] - 2026-09-22
Library
Added
- ZEC transactions now carry a per-pool breakdown of their shielded value.
sapling_value_balance,orchard_value_balanceandironwood_value_balanceare emitted on every transaction record in zatoshi, positive when value left that pool. They exist because the pool is not recoverable downstream: every shielded input and output is address-less and identical once emitted, and the bundle that produced it is not stored anywhere, so attributing a shielded row to Sapling, Orchard or Ironwood after the fact would need another node read. Two of three pools would be no use either — the remaining rows would stay ambiguous between Sprout and Sapling — so Sapling is reported alongside the two new ones. Nothing consumes them yet: the parquet sink builds its table withpa.Table.from_pylist(mapping=data, schema=...)and logs uncovered fields at debug, and the Cassandra sink prepares its insert from the table's own columns, so both ignore the three keys until the parquet schema declares them. The Cassandra raw schema does not change and the graph output is unaffected. spark/build.sbtis now the single source of truth for the Scala job's
dependencies.DEFAULT_SCALA_JOB_PACKAGESmirrors its compile-scope
entries so theslimartifact can pass them tospark-submit --packages,
and nothing enforced that mirroring — the two drifted silently whenever the
sbt build changed, and a slim run would only find out as a
NoClassDefFoundErroron a cluster.scripts/check_spark_packages.pyparses
the declaration block and either fails (make check-spark-packages) or
regenerates the lists (make sync-spark-packages), wired into pre-commit
exactly likeupdate-api-version. It caught a real drift on its first run:
bcprov-jdk18onhad been added to the sbt build and never mirrored. If the
declaration shape ever changes, the parser exits with an error rather than
silently matching nothing.--exclude-packageson the slim submit path.--packagesresolves
transitively, so an artifact the sbt build excludes came straight back:
without this the slim path re-resolvedbcprov-jdk15onthrough
org.web3j:utils, undoing the BouncyCastle swap. The new
DEFAULT_SCALA_JOB_EXCLUDESis generated from the build'sexclude(...)
clauses by the same script, and is only emitted alongside--packages.
Changed
DEFAULT_SCALA_JOB_PACKAGESdropsorg.web3j:core, tracking the same
removal inspark/build.sbt(seespark/CHANGELOG.md): nothing in the Scala
job imports it. This list only feeds theslimartifact's--packages;
production runsfat, where the assembly jar is authoritative.- The pre-commit gate is now a fast subset; CI runs everything. The hook
ran the full suite with coverage and no marker filter, so a commit cost ~236s
— and more than half of that was one test, which resolved a bogus hostname
(redis://invalid-host) and paid whatever the local resolver charged for a
dead name: 120s on a box behind a blackholing DNS server, instant on CI. With
that fixed,make test-fastdrops coverage (nothing reads the local report),
theslowmarks and the four testcontainer directories (tests/web,
tests/db,tests/tagstore,tests/integration— ~80s of a ~95s suite,
mostly container start/stop, since the fixtures are session-scoped and the
cost is all-or-nothing). A typical commit is now ~33s / 1307 tests.
make test-rustjoins the hook path-gated onrust/**(0.06s warm) and the
codegenhook is no longeralways_run: 7s of Docker plus the Java
openapi-generator now fires only forsrc/graphsenselib/web/, the client
templates and the compat patches, which are its only inputs. The hook prints
aspytest: fast subset only (no slow/container tests)rather than
pytest-check, which read exactly like a full-suite pass.semver-checkand
ensure-envdrop theirstages: [pre-commit, pre-push]:make devruns
pre-commit install, which installs thepre-commithook only, so the
pre-push half had never fired. README,CLAUDE.mdand
.github/copilot-instructions.mddocument what each target covers and what
it costs -- the latter two had claimedmake test-ciskips slow tests. - CI no longer skips
slowtests, and the Spark job no longer path-filters.
Every CI pytest invocation passed-m "not slow", so the threeslow-marked
tests ran only on a developer's machine — and the fast local gate would have
left them running nowhere at all.make test-cinow runs the suite in full
(2326 tests, ~104s), and so doesmake test-with-base-dependencies-ci(2046
passed / 26 skipped, ~78s) -- no CI target filtersslowany more, so nothing
can be skipped everywhere at once.spark_tests.ymllikewise ran on a path list covering
spark/, the delta updater andtransformed_*.sql; since sbt is not run
locally at all, that list was the only thing standing between a new file
feeding the delta/Spark contract and an unnoticed drift, so it now runs on
every push and PR. - Dependency bumps: the
uv-minor-patchgroup (22 packages), setuptools
84.0.0, thedocker-imagesgroup (uv 0.12.9, a new temurin 11 digest) and
arrow 59.3.0 inrust/gs_clustering. Notable inside the group: fastapi
0.136.3 -> 0.141.1, sqlmodel 0.0.38 -> 0.0.42, rapidyaml 0.13.0 -> 0.15.2,
redis 8.0.0 -> 8.1.0, cassandra-driver 3.30.0 -> 3.30.1, ty 0.0.73 -> 0.0.79.
ruffis deliberately held at 0.15.17: 0.16 widens the default rule set
and reports 3442 findings (2741 autofixable) on a tree that is clean today,
which is what failed every job on the group's PR -- the tests passed there.
Adopting it is a separate change, either the autofixes or an explicit
lint.select. - A second round of bumps taken from the open Dependabot PRs:
anyio4.13.0
-> 4.14.2 (#170), theuvbuild image 0.12.9 -> 0.12.16 (#165) andarrow
59.3.0 -> 60.0.0 inrust/gs_clustering(#164). All seven open Dependabot
PRs are based onmaster, which is 32 commits behind this release window, so
none could be merged as it stands:developalready carries most of the
uv-minor-patchgroup, and the "from" versions in those PR tables are
master's, not this branch's. The three above were applied by hand instead, as
in 2.16.3, and verified here rather than on the PR — the full Python suite
(2397 passed, 1 skipped, containers andslowincluded),ruff,tyand
cargo test(22 passed) against the updated locks.arrow60 is a major
bump, but confined to the clustering extension: it addsarrow-cmp, moves
the transitiveatoito 3.1.0, and needed no source change in the crate. - The github-actions group (#167) and the
clients/pythongroup (#166) were
applied as well. #167 pinsdocker/setup-buildx-actionto v4.4.0 and
docker/build-push-actionto v7.4.0 across the three image workflows and
movesactions/setup-javav5 -> v6 in the three Scala ones; both SHAs were
re-verified against their tags before pinning. Its redspark-testjob was
not the bump: the failure is a JVMOutOfMemoryError: Java heap spacein
org.graphsense.utxo.TransformationTest,spark_tests.ymlis green on every
recentdeveloprun, the sbt heap options are identical on both branches,
and re-running that exact job on that exact commit passed. setup-java v6
changes distribution handling, caching and the Zulu/Azul API — nothing that
touches heap. - #166 was not a lockfile-only bump, and hid a real bug.
click8.4.2 ->
8.5.0 movedget_text_streambehind a module-level__getattr__that raises
aDeprecationWarning(removal in Click 9.0), and the CLI resolved its
stderr through it on every write. The deprecation hook resolves that stream
inside atry/except Exception, so under the client's-W errorsuite the
warning became the exception the hook swallows and every "endpoint is
deprecated" message vanished —
test_deprecation_header_triggers_stderr_warningis what caught it, and no
amount of bumpingtyfixed the matchingcall-non-callablediagnostic,
because the annotation really is-> object.graphsense/cli/context.pynow
resolvessys.stderrdirectly, which is what the helper returned anyway and
what the hook already does forstream=None. The client is not
re-released in this window: on click 8.x the bug is invisible under Python's
default warning filters, so the fix rides the nextwebapi-v*release.
Verified with the client's own gate —lint,type-check,test-ci(122
passed,-W error) andtest-compat. - Two Dependabot PRs are deliberately left for their own change.
fastmcp
3.4.2 -> 4.0.3 (#169) is a major carryingmcp1.28.1 -> 2.2.0, and it fails
tests/mcp/test_server_integration.py::test_tool_surface_shape, where
Tool.inputSchemais now the deprecated spelling ofinput_schema; it also
crosses the DNS-rebinding host check that madeGS_MCP_ALLOWED_HOSTS
necessary in the first place, so it wants a deployment check rather than a
same-day bugfix slot.ruff0.16.7 stays held for the reason above, and that
keeps the rest of #168 with it:filelock3.32.6,grpcioand
grpcio-tools1.84.0,psycopg2-binary2.9.13,build1.6.1 andty
0.0.81.
Fixed
- Tagstore:
0xhex addresses are lowercased on every network, not just
ETH. A token tag without an explicitnetwork(e.g.currency: USDT)
gets the currency as its network, which skipped the ETH-only lowercasing, so
checksummed addresses were stored verbatim and never matched the exact,
lowercase lookup the API does. The insert path and the validation
duplicate-key check now share one normalizer (tagpack.utils. normalize_tag_address), and the tagstore lookups (single and batch
subject-id queries, user-reported tags) normalize incoming0xids the same
way; batch results stay keyed by the ids the caller passed. Validation now
warns when a tag's network is a token rather than a chain. Existing rows are
not rewritten; fix them with
UPDATE tag SET identifier = lower(identifier) WHERE identifier ~ '^0x[0-9a-fA-F]+$' AND identifier <> lower(identifier);
after removing rows whose lowercase twin already exists, or by reimporting
the affected tagpacks with--force(which drops their old rows). - UTXO ingest no longer aborts on transaction versions >= 2^31 from
Bitcoin Core >= 28.0. Core 28.0 changed the tx version touint32
(bitcoin/bitcoin#29325), so its RPC reports the two negative-version txs in
BTC block 256818 as 2591798512 and 2187681472. The rawtransaction.version
column isint, and the bind failed ('i' format requires -2147483648 <= number <= 2147483647), stopping any BTC ingest that reaches that block. The
parser now wraps such versions to the signed int32 older nodes returned, so
the stored value matches keyspaces ingested from pre-28 nodes and no schema
change is needed. transformation raw-to-transformed --localkeeps a configured
local[N]master. The flag used to forcespark.master=local[*]
unconditionally, so aspark.master: local[1]in thespark_config
profile was silently widened to all cores. A local master from the config
is now kept; a non-local master (e.g.spark://…) is still replaced by
local[*], now with a warning.- Every
ArrowInvalidfrom the delta sink took the shrink-the-row-group
retry, not just the two it was meant for. The guard read
if "large_binary" in ste or "named input expected length":-- the second
operand is a bare string literal, so the condition is always true and the
else: raise ebeneath it was dead code. An unrelated Arrow error therefore
spent up to 20 attempts halvingmax_row_group_sizebefore surfacing as
"Could not write delta-file after 20 attempts", which names neither the real
error nor the file. Found by thetybump below, which is the entire reason
that bump is worth taking. - Running the suite without a Docker daemon now skips the container-backed
tests instead of erroring ~1000 times. #160 made a daemon-less machine
collect, but every test requesting the Cassandra/Postgres/Redis fixtures
still failed at setup with a full urllib3 traceback each. The session
fixtures now ping the daemon once (5s timeout, so a deadDOCKER_HOSTis
cheap) and skip with one reason plus a summary banner when it does not answer.
On CI (CIset) an unreachable daemon stays a hard error -- skipping there
would turn a broken runner green while covering nothing. (#161) - ZEC Orchard and Ironwood value balances are accounted as shielded inputs and outputs. The parser recorded shielded flows from Sprout
vjoinsplitand SaplingvalueBalanceonly; the NU5orchardand NU6.3ironwoodbundles were blacklisted wholesale, so every transparent↔Orchard and transparent↔Ironwood flow was invisible andinput_value,output_valueandfeewere computed from the transparent, Sprout and Sapling sides alone. One code path now loops over both pools and reads the bundle'svalueBalanceZat, which is already in zatoshi and must not go through_btc_to_satoshi; the floatvalueBalancebeside it is deliberately not read, because Zebra renders it with a helper namedlossy_zecthat its own documentation says must not be used for consensus-critical calculations (zebra-rpc/src/methods/types/zec.rs). The sign convention is Sapling's — positive means value left the pool, so it becomes a shielded input; negative becomes a shielded output of the negated amount. A fully-shielded NU5 transaction makes the size of the old error concrete:bd84ece1a1f9930085768a880d99445ee1ee13686c2e5979b30d920e90866517at height 1,687,194 moves +89,000 zat out of Sapling and −88,000 zat into Orchard for a 1,000 zat fee, where the parser previously saw the Sapling leg alone and reported a fee 89× too large. This needs a re-ingest from NU5 activation (height 1,687,104) and a re-transform to take effect, since shielded I/O is synthesized at ingest time and blocks already in the lake keep their old values. - Two consequences of that re-transform are worth stating plainly, because neither is a simple value refresh. Existing relation values shift:
totalInputis the denominator ininValue / totalInput * outValue, and it is summed over all inputs with no address filter (Transformator.plainAddressRelations, mirrored byget_total_input_sumin the delta updater), so adding a shielded leg lowersestimatedValueon the transparent relations of a mixed transaction rather than only adding rows. And cluster membership can change:is_coinjoinreadsinput_count,output_countand the output-value histogram, and shielded I/O is appended before those counts are taken, so a new leg can flip thecoinjoinflag — whichcomputeAddressClusterconsumes. Treat the re-transform as a full cluster rebuild with possible attribution shifts. This is the behaviour Sprout and Sapling legs have always had, not a new hazard, but it now applies to far more transactions. - A third point is about reading the result rather than producing it: a positive shielded balance is usually not an unshielding. Most often it is the fee of a fully-shielded transaction, paid out of the pool. Mapping it to a shielded input is right for value conservation and matches what Sapling has always done, but a
shieldedinput must not be read as a z→t transfer. - No
spark/change accompanies this.computeRegularInputsandcomputeRegularOutputsfiltersize(col("input.address")) === 1, so address-less shielded I/O never reaches the address graph, whileplainAddressRelationsexplodes every input without that filter and therefore picks the new legs up on its own; the delta updater mirrors both. There is no Scala path that reads shielded I/O by type — the Scala tree contains no occurrence ofshieldedat all. - Sapling now reads its integer
valueBalanceZattoo, so all three pools take the same field rather than leaving one on a float. On values this is a no-op —round(float * 1e8)is exact at ZEC supply scale, and 665,685 mainnet transactions sampled across the whole Sapling range agreed on every one — so it cannot disturb existing Sapling data. It was worth confirming rather than assuming, because both Sapling keys areOptionfields in Zebra's RPC types and a missing one would drop the shielded row silently rather than raise: the two are written from a single construction site in Zebra (bothSome, or bothNone) and from adjacentpushKVcalls inside one guard in zcashd, and a scan of 112,958 blocks — eleven contiguous 10,000-block windows from Sapling activation to the tip, plus a stride sweep of the whole range — found the pair present together on 665,685 of 665,685 transactions, across transaction versions 4, 5 and 6, with no case of the float appearing alone.valueBalanceandvalueBalanceZattherefore exchange places between_TX_KNOWN_KEYSand_TX_BLACKLIST. - The bundle is validated strictly rather than read permissively, so the next pool Zcash adds fails loudly instead of being silently dropped.
validate_rpc_fieldsdoes not recurse, so the bundle, each of its actions and itsflagsobject are enumerated at their own level.orchardandironwoodmove into the transaction's known keys as optional entries — validation works by set difference, so an absent known key never raises — deliberately without a transaction-version gate: Zebra emitsorchardon every transaction including pre-NU5 v1–v4 ones, where it is present but empty — confirmed on 328,134 NU6.3 transactions and on pre-NU5 transactions of all four versions down to block 1 — while zcashd gates it onnVersion >= ZIP225(v5) and has no Ironwood field at all, so any version gate would be wrong against one node or the other. The transaction-levelvalueBalanceZatis read as Sapling's own balance and is separate from the identically-named key inside each bundle, which is read per pool; the two share a name but not a meaning, and the transaction-level floatvalueBalancebeside them is blacklisted.flagsalso listsenableCrossAddressin advance: ZIP-229 defines that third bit and Zebra already carries it in its consensus types (zebra-chain/src/orchard/shielded_data.rs), but its RPCOrchardFlagsstruct exposes onlyenableSpendsandenableOutputs, so no node emits it today — listing it now keeps the alert for a genuinely unexpected fourth flag while removing a break that would otherwise hit every Ironwood transaction at once the moment Zebra adds the serde field.
Removed
graphsenselib.utils.pipelineand its tests. Nothing has imported the
multiprocessingPipelinesince the account ingester was rewritten, and its
tests failed on everyspawnplatform (macOS, Windows): the stages carried
mp.Queue/mp.Eventobjects into child processes, which cannot be
re-pickled. Linux forks, so only local macOS runs saw it. (#161)- The
exchange-rates coindeskprovider (fetch/ingest) and
graphsenselib.rates.coindesk. It called CoinDesk's old Bitcoin Price
Index API (api.coindesk.com/v1/bpi), which CoinDesk has shut down: the
hostname no longer resolves, so every run died with a bare
NameResolutionError, and the API had stopped returning recent rates as
far back as 2022. CoinDesk's surviving data API is the former CryptoCompare
API (CoinDesk acquired CCData/CryptoCompare in 2024), so use
exchange-rates cryptocomparewith acryptocompare_api_keyfrom
https://developers.coindesk.com/ instead.scripts/dev-ingest.shno longer
calls it.
What's Changed
- Detect 1inch swaps from transaction input by @frederik-raphael in #156
- test: build the testcontainers on first use by @mkzung in #160
- fix(mcp): default list-tool pagesize and advertise it in schemas by @frederik-raphael in #149
- fix(ingest): account ZEC Orchard and Ironwood value balances as shielded I/O by @steven3002 in #163
New Contributors
- @mkzung made their first contribution in #160
- @steven3002 made their first contribution in #163
Full Changelog: v2.16.3...v2.16.4