Skip to content

fix(shield-swap): DEX correctness, owned-position views, 0.4.0 - #67

Open
iamalwaysuncomfortable wants to merge 13 commits into
feat/shield-swap-updatefrom
fix/dex-correctness-owned-positions-0.4.0
Open

fix(shield-swap): DEX correctness, owned-position views, 0.4.0#67
iamalwaysuncomfortable wants to merge 13 commits into
feat/shield-swap-updatefrom
fix/dex-correctness-owned-positions-0.4.0

Conversation

@iamalwaysuncomfortable

Copy link
Copy Markdown
Member

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, replaces DEFAULT_API_URL, and retypes get_ohlcv's timestamps, none of which are patch-compatible.

Live correctness bugs

The DEX API host was decommissioned. amm-api-staging.dev.provable.com returns 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's network_name so 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.com as a fallback — it still answers 200 but indexes the pre-migration shield_swap_v3.aleo.

increase_liquidity produced tick hints the contract rejects. It read slot.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. mint already walked the on-chain list; increase_liquidity now does too. pick_insert_hint is deleted rather than left in place: unused after this change, unexported, untested, and its own docstring conceded it returned rejected hints.

get_ohlcv had the wrong timestamp type. Declared str; the API's from/to are int64 unix seconds (inclusive start, exclusive end). The live test passed ISO-8601 and got 400 query parameters do not match the expected schema — it had never run in CI, being live-marked.

swap() raced on blinded identities. It derived one via 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 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_many already reserved from the journal; swap() now takes the same path and records the resulting handle once the broadcast is accepted. track=False opts 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_out swallowed NotAuthenticatedError/NotRedeemedError into a None return, which resolve_swap_params replaced with a spot estimate. Spot ignores the pool fee, so amount_out_min came 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, and swap_many refuses outright when it has no quote and slippage_bps < 10000.

Profile wrote private keys to a literal ~ directory. No expanduser, so load_or_create("~/x") and SHIELD_SWAP_HOME=~/x each created a directory named ~ in the cwd — where no later run would look for the key. Both paths now expand.

Features

Owned-position viewsget_owned_positions(pool_key=?) and get_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 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.

position_math.py mirrors 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: 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 vectors are transcribed from the contract's own tests/test_amm_helpers.leo, including the wrap-negative cases, so a divergence in either implementation fails the suite rather than silently producing wrong balances.

state is None while 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, because Profile.load_or_create defaults there. With shield_swap.aleo now 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 /airdrop nor /airdrop/{job_id} (confirmed by diffing both OpenAPI specs), so the airdrop stage 404'd there. It now raises NotFundedError naming 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 UsdcUsdQuote and PoolListResponseDoc.valuation. regen-openapi.sh no longer defaults to the dead host.

pyright: 15 → 0, in both packages

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_liquidity 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 instead of 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.

The write tier had never passed

Four defects, each masking the next:

  1. conftest gated on credentials it never passed — required ALEO_E2E_API_KEY/ALEO_E2E_CONSUMER_ID but built its provider without them, so the record scanner answered Unauthorized before any test reached the chain.

  2. Split credentialapi_key on the provider, consumer_id on aleo.network_client. The scanner is built lazily from provider config, so it had a key with no consumer and could not mint a JWT.

  3. No DEX authentication before the auth-gated get_route, so it 401'd before anything was proved.

  4. 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_min was 1_844_890_080 while sqrt_price_limit sat at exactly MIN_SQRT_RATIO_X128, the default extreme, ruling out the price bound.

    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. Passing raw 1e16 quoted 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_out was correct throughout — it returns 1058294112, matching the canonical quote exactly. The test had reimplemented the conversion; it now calls the helper. test_reads_live had the same confusion and only passed because it asserts shape rather than amounts.

Verification

Gate Result
shield-swap hermetic 220 passed
sdk hermetic 884 passed
shield-swap live reads + ABI drift 17 passed
shield-swap write tier 2 passed — proved, broadcast, confirmed, claimed on real testnet
devnode 11 passed
pyright (both packages) 0 errors

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-generator must 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.

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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."""
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