fix(shield-swap): DEX correctness, owned-position views, 0.4.0 - #67
Open
iamalwaysuncomfortable wants to merge 13 commits into
Open
fix(shield-swap): DEX correctness, owned-position views, 0.4.0#67iamalwaysuncomfortable wants to merge 13 commits into
iamalwaysuncomfortable wants to merge 13 commits into
Conversation
The hard-coded staging host (amm-api-staging.dev.provable.com) now 404s — the deployment moved and shield_swap.aleo is live on mainnet as well as testnet. There are two API hosts now, one per network, so a single module-level constant cannot be right for both. Replaces DEFAULT_API_URL's baked value with SHIELD_SWAP_API_URLS keyed by network and api_url_for(network) to resolve it. ShieldSwap and AsyncShieldSwap now default api_url from their bound client's network_name, so the off-chain indexer always matches the chain being read — a testnet pool key means nothing to the mainnet indexer, and the old default silently guaranteed one of the two was wrong. SHIELD_SWAP_API_URL still overrides every network. An unknown network raises rather than falling back, and the standalone-ApiClient default points at testnet deliberately: an accidental default must not reach mainnet. Verified both hosts serve /tokens and /pools unauthenticated and gate /access/status with 401. 179 passed (shield-swap; +5 new).
…es unix seconds Two correctness bugs, both confirmed against the live testnet. increase_liquidity derived its insert hints from pick_insert_hint, which reads slot.next_init_below/above — those bracket the pool's CURRENT tick, not the target. Any bound further out than one initialized tick therefore got a hint above itself, which finalize rejects after the fee is spent. mint already walked the on-chain list (aa71c33); increase_liquidity now does the same via find_tick_predecessor. pick_insert_hint is deleted rather than left in place. It was unused after this change, unexported, untested, and its own docstring conceded it returns hints the contract rejects — a known-wrong helper is a trap. get_ohlcv typed from_ts/to_ts as str, but the API declares from/to as int64 unix seconds (inclusive start, exclusive end). test_api_get_ohlcv passed ISO-8601 strings and failed with 400 "query parameters do not match the expected schema" — it had never run in CI, being live-marked. Both are now int, and the test passes real unix seconds. Verified: 14/14 live reads pass against api.testnet.swap.shield.fi (the OHLCV test was the only red one), 179 shield-swap, 873 sdk, 11 devnode.
get_owned_positions(pool_key=?) and get_owned_position(token_id) answer "what do I hold and what is it worth right now" without a transaction. A position spans two sources that neither side can answer alone: the private PositionNFT record carries identity (pool, range, withdrawal) and no amounts; the public positions/slots/ticks mappings carry amounts and no identity. Callers previously had to persist token ids externally and reimplement two pieces of contract math to display a position. position_math.py mirrors the amm-v3 view helpers bit-exactly — amounts_for_liquidity (view_amounts_for_liquidity), fee_growth_inside (get_fee_growth_inside), fee_owed, and u256_wrapping_sub (u256::u256_sub). Fee growth is 256-bit and modular by design: an outside counter may exceed the global one and the difference wraps at 2^256, so every subtraction goes through the wrapping helper — a plain - would raise where the contract wraps. The 17 math vectors are transcribed from the contract's own tests/test_amm_helpers.leo, including the wrap-negative fee_growth_inside cases, so a divergence in either implementation fails the suite rather than silently producing wrong balances. state is None while a mint finalizes (record spendable, mapping not written) and when a boundary tick is uninitialized — the identity stays usable in both cases. Burned positions cannot appear, since burn consumes the record. 196 passed (+27: 17 math vectors, 10 join/filter/lag paths).
…e handle swap() derived its blinded identity through next_blinded_identity, which scans for the first counter the chain does not carry. Correct in sequence, unsafe in parallel: two swaps starting together read identical chain state, reach the same counter, and the second reverts at finalize once the first consumes it. Nothing surfaces locally — at proving time the address genuinely was unused, because the check and the use are not atomic. swap_many already avoided this by reserving from the journal; single swap() did not. It now takes the same path: reserve one counter under the journal's file lock, derive the identity at it, and record the resulting handle once the broadcast is accepted. Two concurrent swaps can no longer collide. Journaling the handle is the other half. The blinding factor is the only thing that can claim a swap — lose it and the output is unclaimable by anyone, which is the point of blinding it. Recording at accept time (not confirmation) means a crash mid-flight leaves a claimable handle for collect_all(). A journal write that fails after the swap lands raises rather than being swallowed: the swap is already spent, so silently dropping its claim secret is the worse outcome. track=False opts out, an explicit identity= still wins, and without a journal the on-chain probe remains the only option — documented as racing. 206 passed (+5 covering reservation, distinct counters, opt-out, explicit identity, and the journal-less path).
The mainnet API publishes neither /airdrop nor /airdrop/{job_id} — verified
against both OpenAPI specs — so requesting one there returned 404 and blew
up onboard() with a DexApiError. The remedy is the caller funding the
account, not a retry, so the stage now raises NotFundedError naming the
network and the address. Mainnet onboarding is authenticate -> redeem ->
credentials, then the caller funds, then the funded stage passes.
A profile-bound client could only ever be testnet: from_profile called Profile.load_or_create with no arguments, and that defaults network to testnet. With shield_swap.aleo now deployed on mainnet, mainnet was unreachable through the documented entry point. Both apply only when the profile is created — an existing one keeps what it was created with, since its derived pool keys and blinded identities are network-scoped and would not transfer. Give each network its own home. Verified against both live deployments: 5 pools/8 tokens on testnet and 4 pools/8 tokens on mainnet, with locally derived pool keys matching each indexer on both.
conftest gated the write tier on ALEO_E2E_API_KEY / ALEO_E2E_CONSUMER_ID but built its provider without them, so the hosted record scanner answered Unauthorized and every write test failed on the first record read — before reaching the chain. It also never registered the account, which scanning requires. Confirmed by contrast: the same account reads private balances fine when the credentials are wired in (57 credits + test ETH + USDCx on testnet, 0.4 credits + USDCx on mainnet), and fails with exactly this Unauthorized when they are not. The provider now receives both, and the account is registered with the scanner once a key exists.
…ed reads get_route is auth-gated; the bespoke clients in test_swap_lifecycle never established a session, so it answered 401 before anything was proved.
…zero
Four fixes, each a way a wrong value used to reach the chain or the disk.
_quote_expected_out swallowed NotAuthenticatedError/NotRedeemedError into a
None return, which resolve_swap_params then replaced with a spot estimate.
Spot ignores the pool fee, so amount_out_min came out above what the pool
can pay: the caller paid for a proof the finalize rejected. "Could not ask"
is not "no route" — auth failures now propagate, and swap_many refuses
outright when it has no quote and slippage_bps < 10000 rather than proving
and broadcasting N swaps engineered to revert. slippage_bps=10000 ("accept
any output") still proceeds without one.
Profile never called expanduser, so load_or_create("~/x") and
SHIELD_SWAP_HOME=~/x each created a literal ~ directory in the cwd and
wrote the private key there — where no later run would look for it. Both
paths now expand.
pyright: 15 errors -> 0. Twelve were real. _lp_programs was annotated str
while its own docstring said "None when an explicit record made resolution
unnecessary"; the annotation was simply wrong. select_token_record's callers
relied on a short-circuit pyright cannot see, so mint and increase now share
_fund_side, which resolves record and program together and removes the
duplication. authenticate returned self._csrf (str | None) as str. Two
dict comprehensions produced list[str | None] despite filtering.
The last three were unresolved mcp imports — a declared optional extra, now
installed so pyright checks the MCP server rather than skipping it. That
surfaced a latent bug in sdk-abi's own stub: _aleo_abi.pyi declared
generate_abi with three parameters while the pyo3 signature and the runtime
both take four (imports=None). aleo.abi passes four and was correct; the
stub was wrong and is now generated from the Rust signature.
Bumps all three packages 0.3.1 -> 0.4.0: this branch removes
generate_access_codes, replaces DEFAULT_API_URL, and retypes get_ohlcv's
timestamps, none of which are patch-compatible.
220 shield-swap, 884 sdk, pyright clean in both packages.
test_private_swap_roundtrip proved, broadcast, and had the network reject at1xze86e… — fee consumed. Diagnosed from the rejected transition's inputs: amount_out_min was 1_844_890_080 while sqrt_price_limit sat at exactly MIN_SQRT_RATIO_X128, the default extreme, so the price bound was not the constraint. The minimum was simply unpayable. Cause, measured against the live API on the ETH/ALEO pool: get_route(amount_in=10000000000000000) -> 1863.544605 (raw base units) get_route(amount_in=0.01) -> 1058.294112 (canonical) /route takes a CANONICAL decimal amount. The test passed raw base units, so the API quoted a trade of 10_000_000 ETH rather than 0.01, returned a price from deep in the book, and the test scaled that into a minimum 76% above what the pool would actually pay. _quote_expected_out was correct throughout — it divides by 10**dec_in before asking and returns 1058294112, matching the canonical quote exactly. The test reimplemented the conversion and got it wrong, so it now calls the helper instead. test_reads_live had the same confusion, passing 10**decimals as amount_in. It never failed because it asserts only shape, but it documented the wrong convention; it now passes "1" with the units spelled out. The write tier now passes for the first time: 2 passed, both roundtrips proved, broadcast, confirmed and claimed against real testnet.
…, dead code Six findings from reviewing the branch. swap() reserves its counter at BUILD time, not at the terminal method: the blinded address is a transition input, so the identity must exist before anything can be assembled. That means discarding a prepared call — or only calling simulate() — still spends a counter, which contradicts the "nothing happens until a terminal method" contract the README states. The reservation cannot be deferred, so it is documented instead of hidden, with track=False offered as the side-effect-free build. Three tests pin the behaviour rather than leaving it as prose. Async parity: get_owned_positions/get_owned_position now exist on AsyncShieldSwap. sdk/AGENTS.md requires sync+async pairs with shared pure logic, so the PositionNFT record shape moved to _core as POSITION_RECORD_FIELDS + decode_position_record rather than being copied into the second client. test_async_parity fails on any future read method added to one client and not the other, and also fails when its own SYNC_ONLY allowlist goes stale. The async swap docstring pointed at "journal-reserved counters" the async client cannot reserve; it now states that it probes, that the probe races, and that concurrent callers must pass identity explicitly. get_owned_positions read the slot once per position; ten positions in one pool cost ten identical reads. A per-call cache keyed on pool brings that to one, asserted by counting the calls. Record detection keyed on "tick_lower" alone, so any future record type carrying that field would be misread as a position. It now requires the whole field set, with a test using an impostor record that shares one field. amounts_for_liquidity ended in an unreachable `return 0, 0` — its three arms are exhaustive by construction. A silent (0, 0) would read as "holds nothing", so it raises AssertionError instead. amount0_delta divides by both bounds and now documents ZeroDivisionError. The swap docstring renders into AGENTS.md and pushed the page past its compactness budget; it is written tighter rather than raising the cap a third time (23866, 134 spare). 229 shield-swap (+9), 884 sdk, 14 live reads, pyright clean in both.
There was a problem hiding this comment.
Pull request overview
This PR updates the shield-swap SDK for the 0.4.0 release, addressing several live correctness issues (DEX host selection, swap identity races, quote handling, OHLCV timestamp typing, airdrop behavior on mainnet, profile path expansion), and adding new read-side features for valuing owned LP positions by joining private records with on-chain mappings.
Changes:
- Fix DEX/API correctness issues (per-network API host resolution, stricter quote handling, OHLCV timestamp typing, mainnet airdrop behavior) and harden swap identity handling via journal-backed counter reservation.
- Add owned-position read views (
get_owned_positions,get_owned_position) backed by new contract-mirroring position math helpers plus test vectors. - Bump versions across packages to 0.4.0 and refresh generated OpenAPI models/codegen workflow.
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| shield-swap-sdk/tests/test_swap.py | Adds tests for swap counter reservation + journal handle tracking behavior. |
| shield-swap-sdk/tests/test_swap_many.py | Updates swap_many quote behavior tests; adds refusal-on-missing-quote coverage. |
| shield-swap-sdk/tests/test_profile.py | Adds regression tests for ~ expansion in profile home paths. |
| shield-swap-sdk/tests/test_position_math.py | Adds KAT-style vectors for position view math (mirroring contract behavior). |
| shield-swap-sdk/tests/test_package.py | Updates expected package version to 0.4.0. |
| shield-swap-sdk/tests/test_owned_positions.py | Adds tests covering record↔mapping join for owned-position views. |
| shield-swap-sdk/tests/test_lifecycle.py | Adds mainnet airdrop-stage refusal test (but contains a docstring placement issue). |
| shield-swap-sdk/tests/test_async_parity.py | Adds sync/async surface parity tests, including owned-position view presence. |
| shield-swap-sdk/tests/test_api_client.py | Adds tests for per-network API host resolution + env override behavior. |
| shield-swap-sdk/tests/integration/test_swap_lifecycle.py | Fixes write-tier harness (credentials wiring + authenticated routing + quote conversion). |
| shield-swap-sdk/tests/integration/test_reads_live.py | Fixes live read tests for canonical routing amounts + OHLCV timestamp types. |
| shield-swap-sdk/tests/integration/conftest.py | Fixes integration fixture credential propagation + optional record registration. |
| shield-swap-sdk/python/aleo_shield_swap/types.py | Introduces OwnedPosition / OwnedPositionState dataclasses. |
| shield-swap-sdk/python/aleo_shield_swap/tick_hints.py | Removes unused/incorrect insert-hint logic. |
| shield-swap-sdk/python/aleo_shield_swap/profile.py | Expands ~ in default/explicit profile paths; updates documentation/comments. |
| shield-swap-sdk/python/aleo_shield_swap/position_math.py | Adds bit-exact position view math helpers (wrapping fee growth, liquidity amounts). |
| shield-swap-sdk/python/aleo_shield_swap/lifecycle.py | Raises NotFundedError for mainnet airdrop stage (no faucet endpoints). |
| shield-swap-sdk/python/aleo_shield_swap/client.py | Core fixes/features: per-network API selection, journal-backed swap counter reservation + handle journaling, owned-position views, quote error propagation, LP funding refactor. |
| shield-swap-sdk/python/aleo_shield_swap/async_client.py | Adds owned-position views and per-network API selection (but currently has an async bug). |
| shield-swap-sdk/python/aleo_shield_swap/api.py | Implements SHIELD_SWAP_API_URLS + api_url_for; fixes OHLCV timestamp typing; tightens CSRF typing. |
| shield-swap-sdk/python/aleo_shield_swap/AGENTS.md | Updates documented signatures/behavior (from_profile args, swap tracking semantics). |
| shield-swap-sdk/python/aleo_shield_swap/_core.py | Adds shared PositionNFT record decoding helper (decode_position_record). |
| shield-swap-sdk/python/aleo_shield_swap/_api_models.py | Regenerated models (adds UsdcUsdQuote, pool list valuation field). |
| shield-swap-sdk/python/aleo_shield_swap/init.py | Exports owned-position types; bumps version to 0.4.0. |
| shield-swap-sdk/pyproject.toml | Bumps shield-swap-sdk version to 0.4.0. |
| shield-swap-sdk/codegen/regen-openapi.sh | Updates OpenAPI regeneration to target per-network hosts (or explicit URL). |
| shield-swap-sdk/codegen/amm_api.openapi.json | Updates vendored OpenAPI spec with latest schema changes. |
| shield-swap-sdk/AGENTS.md | Mirrors AGENTS documentation changes at repo/package root. |
| sdk/pyproject.toml | Bumps aleo-sdk version to 0.4.0. |
| sdk/Cargo.toml | Bumps Rust crate version to 0.4.0. |
| sdk-abi/python/aleo_abi/_aleo_abi.pyi | Fixes stub signature for generate_abi to include optional imports. |
| sdk-abi/pyproject.toml | Bumps aleo-contract-abi-generator version to 0.4.0. |
| sdk-abi/Cargo.toml | Bumps aleo-abi crate version to 0.4.0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+373
to
+376
| acct = self._account(account) | ||
| records = self._aleo.record_provider.find( | ||
| acct, program=self.program, unspent=True) | ||
| out: list[OwnedPosition] = [] |
Comment on lines
+176
to
+178
| def test_airdrop_stage_refuses_on_mainnet(tmp_path): | ||
| tmp_journal = tmp_path / "j.jsonl" | ||
| """The faucet endpoints are testnet-only; on mainnet say so, don't 404.""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Six live correctness bugs, two features, and the test harness that was hiding several of them. Base is
feat/shield-swap-update.Bumps all three packages 0.3.1 → 0.4.0 — this branch removes
generate_access_codes, replacesDEFAULT_API_URL, and retypesget_ohlcv's timestamps, none of which are patch-compatible.Live correctness bugs
The DEX API host was decommissioned.
amm-api-staging.dev.provable.comreturns 404 on every path. The API is deployed per network on separate hosts, so one module-level constant cannot be right for both.SHIELD_SWAP_API_URLS+api_url_for(network)replace it, resolved from the bound client'snetwork_nameso the indexer always matches the chain being read. An unknown network raises rather than falling back, and the standalone default points at testnet deliberately: an accidental default must not reach mainnet.Do not use
amm-api.dev.provable.comas a fallback — it still answers 200 but indexes the pre-migrationshield_swap_v3.aleo.increase_liquidityproduced tick hints the contract rejects. It readslot.next_init_below/above, which bracket the pool's current tick rather than the target, so any bound further out than one initialized tick got a hint above itself — mined, reverted, fee consumed.mintalready walked the on-chain list;increase_liquiditynow does too.pick_insert_hintis deleted rather than left in place: unused after this change, unexported, untested, and its own docstring conceded it returned rejected hints.get_ohlcvhad the wrong timestamp type. Declaredstr; the API'sfrom/toare int64 unix seconds (inclusive start, exclusive end). The live test passed ISO-8601 and got400 query parameters do not match the expected schema— it had never run in CI, beinglive-marked.swap()raced on blinded identities. It derived one vianext_blinded_identity, which scans for the first counter the chain does not carry. Correct in sequence, unsafe in parallel: two swaps starting together read identical state, reach the same counter, and the second reverts at finalize. Nothing surfaces locally, because at proving time the address genuinely was unused — the check and the use are not atomic.swap_manyalready reserved from the journal;swap()now takes the same path and records the resulting handle once the broadcast is accepted.track=Falseopts out.Journaling the handle is the other half: the blinding factor is the only thing that can claim a swap, so a crash mid-flight would otherwise lose it permanently. A journal write that fails after the swap lands raises rather than being swallowed — the swap is already spent, so dropping its claim secret silently is the worse outcome.
Quote failures turned into unpayable trades.
_quote_expected_outswallowedNotAuthenticatedError/NotRedeemedErrorinto aNonereturn, whichresolve_swap_paramsreplaced with a spot estimate. Spot ignores the pool fee, soamount_out_mincame out above what the pool can pay and the caller paid for a proof the finalize rejected. "Could not ask" is not "no route": auth failures now propagate, andswap_manyrefuses outright when it has no quote andslippage_bps < 10000.Profilewrote private keys to a literal~directory. Noexpanduser, soload_or_create("~/x")andSHIELD_SWAP_HOME=~/xeach created a directory named~in the cwd — where no later run would look for the key. Both paths now expand.Features
Owned-position views —
get_owned_positions(pool_key=?)andget_owned_position(token_id)answer "what do I hold and what is it worth" without a transaction.A position spans two sources neither of which can answer alone: the private
PositionNFTrecord carries identity (pool, range, withdrawal) and no amounts; the publicpositions/slots/ticksmappings carry amounts and no identity. Callers previously had to persist token ids externally and reimplement two pieces of contract math.position_math.pymirrors the amm-v3 view helpers bit-exactly —amounts_for_liquidity,fee_growth_inside,fee_owed,u256_wrapping_sub. Fee growth is 256-bit and modular by design: anoutsidecounter may exceed the global one and the difference wraps at 2^256, so every subtraction goes through the wrapping helper — a plain-would raise where the contract wraps. The 17 vectors are transcribed from the contract's owntests/test_amm_helpers.leo, including the wrap-negative cases, so a divergence in either implementation fails the suite rather than silently producing wrong balances.stateisNonewhile a mint finalizes and when a boundary tick is uninitialized; the identity stays usable in both cases. Burned positions cannot appear, since burn consumes the record.from_profile(network=, endpoint=)— a profile-bound client could only ever be testnet, becauseProfile.load_or_createdefaults there. Withshield_swap.aleonow live on mainnet, mainnet was unreachable through the documented entry point. Both apply only at creation: an existing profile keeps what it was created with, since its derived keys are network-scoped.Mainnet
Verified against both deployments — testnet 5 pools / 8 tokens, mainnet 4 pools / 8 tokens, with locally derived pool keys matching each indexer on both.
Mainnet publishes neither
/airdropnor/airdrop/{job_id}(confirmed by diffing both OpenAPI specs), so the airdrop stage 404'd there. It now raisesNotFundedErrornaming the network and address, because the remedy is funding the account rather than a retry. Mainnet onboarding is authenticate → redeem → credentials, then the caller funds.OpenAPI regenerated per network; picked up
UsdcUsdQuoteandPoolListResponseDoc.valuation.regen-openapi.shno longer defaults to the dead host.pyright: 15 → 0, in both packages
Twelve were real.
_lp_programswas annotatedstrwhile its own docstring said "None when an explicit record made resolution unnecessary" — the annotation was simply wrong.select_token_record's callers relied on a short-circuit pyright cannot see, somintandincrease_liquiditynow share_fund_side, which resolves record and program together and removes the duplication.authenticatereturnedself._csrf(str | None) asstr. Two dict comprehensions producedlist[str | None]despite filtering.The last three were unresolved
mcpimports — a declared optional extra, now installed so pyright checks the MCP server instead of skipping it. That surfaced a latent bug insdk-abi's own stub:_aleo_abi.pyideclaredgenerate_abiwith three parameters while the pyo3 signature and the runtime both take four (imports=None).aleo.abipasses four and was correct; the stub was wrong and is now generated from the Rust signature.The write tier had never passed
Four defects, each masking the next:
conftest gated on credentials it never passed — required
ALEO_E2E_API_KEY/ALEO_E2E_CONSUMER_IDbut built its provider without them, so the record scanner answeredUnauthorizedbefore any test reached the chain.Split credential —
api_keyon the provider,consumer_idonaleo.network_client. The scanner is built lazily from provider config, so it had a key with no consumer and could not mint a JWT.No DEX authentication before the auth-gated
get_route, so it 401'd before anything was proved.Raw base units to
/route. The last failure got all the way to chain:at1xze86e…proved, broadcast, and was rejected at finalize. Diagnosed from the rejected transition's inputs —amount_out_minwas1_844_890_080whilesqrt_price_limitsat at exactlyMIN_SQRT_RATIO_X128, the default extreme, ruling out the price bound.Measured against the live API on the ETH/ALEO pool:
/routetakes a canonical decimal amount. Passing raw1e16quoted a trade of 10,000,000 ETH rather than 0.01, returned a price from deep in the book, and produced a minimum 76% above what the pool would pay._quote_expected_outwas correct throughout — it returns1058294112, matching the canonical quote exactly. The test had reimplemented the conversion; it now calls the helper.test_reads_livehad the same confusion and only passed because it asserts shape rather than amounts.Verification
Reviewer notes
CI does not run any of this. The workflow uses
-m "not slow and not live and not devnode", so the integration tiers are written, maintained, and never executed — which is exactly why all four harness defects survived, and the same gap that let the ABI drift tests sit silently erroring on a missing import. Fixing the tests without fixing that means they rot again. Worth a scheduled job even if it cannot gate PRs.aleo-contract-abi-generatormust be installed for the drift tests to run at all. Without it they error on import rather than checking anything.Not in this PR: a shared Provable credential session. Defect 2 above is an instance of a bug class — a credential must reach every consumer of it, and nothing enforces that; two call sites got it wrong in one file. A shared session would make the split unrepresentable rather than a convention. Also absent: rebuilding a lost journal from chain history, and the merkle-proof parameter work.