Skip to content

[Feat] Migrate shield-swap-sdk to the shield_swap.aleo stack - #110

Merged
iamalwaysuncomfortable merged 20 commits into
mainfrom
feat/shield-swap-stack
Jul 28, 2026
Merged

[Feat] Migrate shield-swap-sdk to the shield_swap.aleo stack#110
iamalwaysuncomfortable merged 20 commits into
mainfrom
feat/shield-swap-stack

Conversation

@iamalwaysuncomfortable

Copy link
Copy Markdown
Member

Migrates @provablehq/shield-swap-sdk from shield_swap_v3.aleo to the deployed shield_swap.aleo stack (core AMM + swap router + LP router + freezelist + multisig + token wrappers). Breaking, hard cutover — v3 support is removed.

What changes for callers

  • Wrappers are invisible. Callers name only tokens, amounts, and pools. The SDK resolves each token's wrapped-ness on chain (from_wrapper_token_id), selects the underlying records, and dispatches to shield_swap.aleo or the correct router transition internally — no wrapper params anywhere in the public API. swap/swapMultiHop no longer take tokenInProgram.
  • Q128.128 prices. Tick/price math moves from Q64 to Q128.128 (getSqrtPriceAtTickX128, getTickEstimateX128, U256 sqrt-price literals). getSqrtPriceAtTick/MIN_SQRT_PRICE and the pool scale0/scale1 fields are gone.
  • Immutable withdrawal address, required on mint and fixed for the position's life; collect supports an owner distinct from the withdrawal address.
  • Unified claim. claimSwapOutput serves both single- and multi-hop swaps and routes the payout (wrapped vs plain) internally; claimMultiHopOutput is removed.
  • Token/balance reads follow the migrated API shape (amm_token_program + underlying_program + underlying_token_id, replacing wrapper_program); private-balance scans key on underlying_program and read credits.aleo microcredits.

Under the hood

  • veil-core / codegen: array and struct plaintext values now encode and decode end to end, so the generated bindings for the new stack round-trip.
  • Routing layer: on-chain wrapped-ness resolution (cached), the router dispatch matrix, blinded-address derivation, and the empty-freezelist / wrapper-sentinel merkle witnesses.
  • Output parsing: write actions read the returned id (swap id, position token id, pool key) by anchoring on the transition's sole field-literal output rather than a positional offset. This is correct across direct and routed dispatch — a router prepends forwarded change records and extractTransitions drops the valueless ones, so the id's index shifts. (requireFieldOutput, kept in shield-swap; no core interface change.)

Testing

  • pnpm vitest run green from the repo root (unit + example suites).
  • Full devnode lifecycle matrix green (leo 4.3.4, aleo-devnode 0.2.0), compiled from amm-v3 Leo source with the genesis admin. Covers plain/plain mint / increase / swap (both directions) + claim / decrease / collect / owner≠withdrawal collect / burn, and the wrapped-side router matrix — wrapped/plain and wrapped/wrapped mint/increase/collect, plus a wrapped-input swap + routed claim through the swap router. Zero skips.
  • Staging shape-parity CI job (staging-shapes, continue-on-error): the unkeyed read + auth subset runs against the live migrated API on every push/PR for drift detection.

Notes for review

  • Version level: the changeset is minor. The changeset fixed group versions the whole @provablehq/veil-* suite in lockstep and it's pre-1.0 (0.5.0); a major entry would escalate the entire suite to 1.0.0. minor folds into the planned 0.6.0 release (pre-1.0 convention for a breaking change). Say if you want the 1.0.0 jump instead.
  • DEFAULT_API_URL intentionally stays the dev host as a forward placeholder; the migrated shapes are served by staging (amm-api-staging.dev.provable.com) until prod migrates. This PR does not claim prod validation.

The new shield_swap stack passes [MerkleProof; 2] proof arrays on
nearly every entrypoint. encodePlaintextValue encodes structs/arrays
from plain JS values against ABI descriptors, encodeInputs and the
record parsers handle composites bracket-aware, and codegen's
plaintextFieldExpr emits element-wise decoders where it previously
generated non-compiling output.
regen-abi.sh fetches transitive imports and stages them for leo abi;
pins bytecode + ABI for shield_swap, shield_swap_router, and
shield_swap_lp_router alongside the v3 pins. Generated bindings for
all three land next to the v3 module (renamed shield_swap_v3.ts;
existing imports rewired). Codegen learns external struct references
(leo emits foreign record outputs as program-qualified struct refs and
prunes their definitions) — they decode as opaque StructValue. New
stack constants (program ids, hardcoded router addresses, wrapper
table) are additive; DEFAULT_PROGRAM still points at v3 until the
actions flip.
…tors

utils/q128.ts mirrors the deployed contract bit-exactly: the 20-entry
magic cascade with positive-tick inversion, tick estimation via the
14-round log2 fraction, mulDiv, amount0/amount1 deltas, and U256
hi/lo split/join/format helpers. Vectors are generated from amm-v3's
scripts/q128 Python oracles; a pinning test recombines every cascade
constant from the pinned bytecode's u64 halves.
resolveTokenRoute reads from_wrapper_token_id (the on-chain definition
of wrapped) with an immutable-relationship cache and offline override;
token ids decode to and from program names; freezelist proof assembly
ships the canonical empty-tree witness with a provider hook for
populated lists; detectTokenStandard prefers the wasm SDK's
Program.isArc20()/.isArc22() and falls back to veil-core's pure
checker; recipient-bound wrapper records parse their binding fields
and are never selected as spendable inventory.
Swap, multi-hop, unified claim, mint, increase, collect, decrease,
burn, create_pool, collect_protocol, and allow_token now dispatch
internally between shield_swap.aleo and its swap/LP routers based on
each token's on-chain wrapped-ness — callers still name only tokens,
amounts, and pools. mint requires an explicit immutable withdrawal
address; claim is unified across single and multi-hop; collect pays
nft.withdrawal. Reads parse Q128.128 U256 state to bigint at the
boundary; getTokenDecimals and claimMultiHopOutput are removed. DEFAULT_PROGRAM,
derivations, tick alignment, agent tools, and decorators follow.
Integration tests against the live stack are rewritten in the next
commit.
Hard cutover to the shield_swap.aleo stack: delete the v3 generated
bindings and pinned ABI/bytecode, drop the v3 codegen entry, and remove
SHIELD_SWAP_V3 / createShieldSwapV3Contract from the public surface
(new-stack constants exported in their place). The two v3-pinned unit
tests move to the new bindings, and the blinding golden vectors are
regenerated against shield_swap.aleo (both factor and address are
program-scoped). session.ts, the READMEs, and SKILL.md drop v3 and the
obsolete raw-unit dust helpers.
Regenerate the pinned OpenAPI from staging (amm-api-staging), which
renamed TokenDoc's wrapper_program to amm_token_program +
underlying_program + underlying_token_id. getBalances now scans private
records in underlying_program (where users hold spendable inventory),
and parseTokenRecordInfo reads credits.aleo's microcredits field as
well as ARC-20 amount so wrapped ALEO's balance isn't silently zero.
regen-openapi.sh takes a VEIL_DEX_API_URL override.
Default each suite's DEX API base to staging (still VEIL_DEX_API_URL-
overridable), drop the removed tokenInProgram param, swap
getSqrtPriceAtTick for the Q128 getSqrtPriceAtTickX128, and clear v3
program/scale/token_decimals assumptions. reads.integration drops the
deleted getTokenDecimals (now a wrapper-registry resolution check),
asserts PoolState has no scales, and updates the mapping list for
from/to_wrapper_token_id. Verified green against staging's live pools
(reads + auth: 25 passed).
Runs the unkeyed shield-swap read + auth integration subset against the
migrated staging DEX API on every push/PR, catching drift between the
client and the live API. Soft-fails (continue-on-error) so a staging
outage doesn't red CI; keyed/write assertions self-skip without a funded key.
Drop the removed dust rule (raw atomic amounts now), the tokenInProgram
argument (routing is internal), and floorToDust; collapse the
claimMultiHopOutput branch into the unified claimSwapOutput; rename the
holding/token program field from wrapper_program to underlying_program
(getHoldings now scans the underlying, matching getBalances); refresh
the startup wrapped-balance examples to the new asset framing.
…ap.aleo stack

Replace the vendored-v3-bytecode devnode fixture with a compile-from-source
harness that builds the amm-v3 Leo stack (multisig, freezelist, shield_swap,
the fake token wrappers, both routers) from the sibling checkout and deploys
it under the devnode genesis admin — which equals the DEPLOYER the AMM
constructor asserts against, so no address patching is needed. Bootstrap runs
the freezelist init/role, fee tier + tick spacing + binding, wrapper->underlying
allow_token registration, three pools (plain/plain, wrapped/plain,
wrapped/wrapped), and seeds the plain pool via the SDK mint. set_token_decimals
is gone.

The two lifecycle suites drive the migrated ABI:
- actions suite: full plain/plain matrix through the SDK write actions — mint,
  increase, both swap directions with unified claimSwapOutput, decrease,
  collect (including owner != withdrawal), and burn — asserting on-chain
  positions/slots/swap_outputs after each step.
- generated suite: createShieldSwapContract with the create_pool sqrt price and
  swap sqrt_price_limit as { hi, lo } U256 literals and the mint/claim
  freezelist witnesses as [MerkleProof; 2] literals.

The wrapped-side router matrix is written against the SDK's internal dispatch
but gated (WRAPPED_SDK_BLOCKED): the SDK's routed mint/swap local paths read
the public id at a fixed output offset that assumes the router's leading
change record is present, but veil-core's devnode extractTransitions drops
router-forwarded external/dynamic records with no plaintext value, shifting the
id to index 0 (mint.ts:250, swap.ts:302). The stack itself deploys, registers,
and creates the wrapped pools; only the SDK local-path return parsing is
affected. Flip the gate once the SDK anchors on the public field output.

Verified green end to end on a fresh devnode (leo 4.3.4, aleo-devnode 0.2.0):
14 passed | 2 skipped.
The routed swap and liquidity actions read the public id (swap id, position
token id, pool key) at a fixed output offset that assumed the router's
forwarded underlying-change record occupied output slot 0. veil-core's
extractTransitions drops router-forwarded records that carry no plaintext
value, so the id lands at a lower index and the actions threw an
"Unexpected output shape" error after the on-chain effect had already
succeeded.

Extract requireFieldOutput, which returns the transition's field-literal
output regardless of record positions, and use it at all seven id-reading
sites. Drop the now-unneeded tokenIdIndex from the liquidity dispatch. This
un-gates the wrapped-side devnode matrix (wrapped/plain, wrapped/wrapped
mint/increase/collect and a wrapped-input swap + routed claim), now green.

Add the shield_swap.aleo migration changeset.
The devnode lifecycle suites now compile the AMM stack from amm-v3 Leo
source, so the vendored shield_swap_v3.aleo bytecode, the old multisig, and
the hand-vendored test tokens under test/fixtures/programs/ are unused, as
is refresh-devnode-fixtures.sh that populated them. Nothing reads them.
Remove a stray paren that broke the snippet and the stale dust-flooring
note; the new stack collects owed balances in raw base units directly.
Address code review on the field-output read: requireFieldOutput now throws
unless exactly one field output is present, so a future transition that
emitted a second public field fails loudly instead of silently returning the
wrong id. The generated devnode suite's firstField delegates to the shared
helper rather than duplicating the regex, and the harness comment that still
described the fixed positional-offset bug is corrected.
@vercel

vercel Bot commented Jul 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
veil-loyalty-dapp Ready Ready Preview, Comment Jul 28, 2026 7:33pm

Request Review

* [Feat] Add parsePlaintextValue for struct and literal plaintext parsing

* [Feat] Emit struct decoders over StructValue instead of RecordValue

* [Refactor] Decode struct mapping values as plaintext, not loose records

* [Fix] Replace record parsers with parseRecord mirroring snarkVM grammar

* [Docs] Update codegen README for StructValue decoders

* [Refactor] Apply simplify-pass findings to the parser split

* [Docs] Correct output-routing comments: future text parses as struct plaintext

* [Feat] Parse future outputs into typed FutureValue

* [Fix] Parse dynamic futures as their own DynamicFutureValue type

* [Refactor] Rename RecordValue.ownerMode to ownerVisibility

* [Refactor] Rename record/struct fields to entries/members mirroring snarkVM

* Revert "[Refactor] Rename record/struct fields to entries/members mirroring snarkVM"

This reverts commit 7b8be95.

* [Refactor] Rename codegen emitter internals to verb-led decoder names
* [Feat] Decode mapping reads with ABI-derived codegen decoders

Mapping reads previously returned raw literals typed as string, and
shield-swap hand-rolled three partial parsers on top (a u8/u16/u32-only
regex, a raw === 'true' check, and a struct guard). Absent keys and
malformed requests were also conflated: the Provable API answers 200
null for an absent key (and for an unknown mapping or program), while
404 means the request itself was malformed — its body misleadingly
blames the program.

Core: readContract/readMapping return string | null, folding the
JSON-null and "null" answers into null and rethrowing the 404 with the
actionable cause; TransportError gains structured status/body fields so
layers above stop regexing messages.

Codegen: every mapping now emits a to<Name>MappingValue decoder —
struct values guard the shape and delegate to the struct decoder,
literal values decode through core's strict parseValue with a declared-
width check — and the generated factory's read methods encode typed
keys via core's encodeValue, null-guard absence, and resolve to
Promise<Value | null> instead of Promise<unknown>.

Shield-swap: readStructMapping/readBoolMapping/readUintMapping collapse
into a generic readDecodedMapping plus a readFlagMapping that owns the
absence-is-false rule for flag mappings; all sixteen read actions ride
the generated decoders.

Docs, the e2e demo, and the reference dApp are updated to the new
absent-key semantics.

* [Feat] Parse signature literals in parseValue

Signatures are prefix-recognizable (sign1...) exactly like addresses
(aleo1...), so parseValue now classifies them as { value, type:
'signature' } instead of throwing. Signature-valued mappings ride the
strict generated decoder path as a result. Identifier literals stay
outside the grammar deliberately — they are bare tokens with no
recognizable shape, so the lenient string passthrough is the only
honest treatment.

* [Fix] Report mapping-read 404s without diagnosing the cause

* [Fix] Trim the mapping-read 404 message to the bare fact
…drift (#113)

Live-verified all four trading journeys (startup, swap+claim, concurrent
swaps, liquidity mint/increase/decrease, collect) against the staging DEX
API on testnet, and fixed what the runs surfaced:

- setup.ts accepts --api-url to pin a DEX API deployment. The URL persists
  in the state file; switching deployments resets the deployment-scoped
  state (access grant, API token, pending airdrop job) while key material
  and Provable credentials carry over. Comparison treats an unset state as
  the SDK default so re-pinning the default is a no-op, and the
  SHIELD_SWAP_API_URL env var stays a purely ephemeral per-run override in
  loadSession — only the flag pins.
- setup.ts's funded report read h.wrapperProgram, a field getHoldings has
  not returned since the stack migration; it now prints underlyingProgram.
- liquidity.md's mint snippet passed the AMM-side token programs as
  token0Program/token1Program, which breaks wrapped sides — those
  parameters name the programs holding the caller's records, and the SDK
  resolves them on chain. The snippet also gained the now-required
  recipient/withdrawal parameters, and the increaseLiquidity section no
  longer claims the action lacks positionTokenId — it accepts one, and the
  snippet pins it.
* [Feat] Mirror the contract position-view math in q128 utils

* [Feat] Decode full PositionNFTs and list them via listPositionNFTs

* [Feat] Add getOwnedPositions and getOwnedPosition with derived amounts and fees

* [Feat] Expose owned-position reads on the shield-swap client

* [Feat] Add owned-position agent tools

* [Test] Add keyed e2e coverage for owned-position views

* [Docs] Document the owned-position views and add the changeset

* [Refactor] Simplify the owned-position read path

Delegate PositionNFT decoding to the generated toPositionNFT, push the
token-id filter into listPositionNFTs, collapse the per-position reads
into a single concurrent wave with the pool slot shared as a promise,
dedupe the per-token fee settlement, export the new q128 mirrors, and
default amountsForLiquidity to withdrawal-side rounding.

* [Refactor] Rename enrichOwnedPosition to resolveOwnedPosition

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.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

@iamalwaysuncomfortable
iamalwaysuncomfortable merged commit bc51d70 into main Jul 28, 2026
6 checks passed
iamalwaysuncomfortable added a commit that referenced this pull request Aug 4, 2026
…e swap and liquidity journeys (#121)

* [Fix] Derive the DEX API host from the client's network

DEFAULT_API_URL shipped as amm-api.dev.provable.com, which indexes the
pre-migration shield_swap_v3.aleo. Since #110 moved this SDK to shield_swap.aleo,
that host serves pools which do not exist on the program the SDK reads — so
discovery returned keys whose every chain read came back null, surfacing as "pool
does not exist" rather than as a misconfigured host. Verified: a pool served by
that API exists under testnet shield_swap_v3.aleo and under neither mainnet
program, while the pools on api.testnet.swap.shield.fi exist under
shield_swap.aleo.

The API is deployed per-network on separate domains, so one constant cannot be
right for both. shieldSwapActions derives it from the client's network, resolved
per request so switchChain re-targets the API rather than leaving it on the network
the client started from — the same defect class as the prover URL. baseUrl
accordingly accepts a resolver and ApiClient.baseUrl becomes a getter.
SHIELD_SWAP_API_URLS and defaultApiUrl() are exported for direct construction, and
DEFAULT_API_URL is deprecated.

Three integration suites defaulted to amm-api-staging.dev.provable.com, which now
404s on every path. That, not an outage, is why the DEX suites have been red: with
the correct host and no env override, 35 live tests pass — including the
route-quote test recorded as known-red.

* [Fix] Walk the tick list for insert hints, and export the list sentinels

pickInsertHint consulted slot.next_init_below/above, which bracket the pool's
current tick rather than the target. Any bound further out than one initialized
tick got a hint above itself, which the contract rejects on finalize — mined,
reverted, fee consumed. Measured on the live ETH/USDCx pool at tick -200996: a
lower bound of -203230 returned -200996, where the true predecessor is -273894.
The docblock already carried this as a known limitation with the exact walk as a
follow-up; this is that follow-up.

The walk visits one entry per initialized tick, of which live pools hold 3 to 18,
so the extra reads are few and bounded rather than proportional to the tick range.

Also exports MIN_TICK_SENTINEL / MAX_TICK_SENTINEL. The list is anchored one step
outside the usable range — ∓400_001 against MIN_TICK/MAX_TICK of ∓400_000 — and
with no constant for it callers hardcoded -400001, as devnodeLifecycle does; that
only holds while a pool's tick list is empty. Getting this wrong is how the first
version of this patch silently returned an uninitialized tick.

Verified against the live deployment: 30 hints across all 5 testnet pools, each
initialized, strictly below its target, and with its successor at or beyond it.

* [Test] Add live swap and liquidity suites against testnet

Two gated suites covering the journeys that had no live coverage: swaps
(single-hop, forced multi-hop, concurrent) and the liquidity lifecycle (mint,
increase, decrease, collect, burn, plus the owned-position reads).

Everything is discovered — pools from the API, balances via getBalances, tick
spacing from the fee tier on chain, ranges from the active tick, insert hints from
pickInsertHint. Nothing is hardcoded, so they follow the deployment. No createPool:
pool_creation_open reads false on this deployment, so a suite that created its own
pool would only ever skip.

Verified live: single-hop swap and claim, the two-hop swap and claim, pool
discovery against both API and chain gates, hint predecessor checks, and the mint
with its on-chain position. Deposit amounts come from amountsForLiquidity at the
current price rather than fixed figures, which balance for one pool's price and
revert elsewhere.

Three timing behaviours needed handling, each confirmed by a real failure first:
finalize writes lag the confirmed transaction, so swap_outputs reads and claims
poll; the record scanner indexes a minted position NFT after the mint confirms, so
dependent steps wait for it; and multi-hop needs more than the default 5-minute
confirmation window.

Also exposes confirmationTimeout on createAleoClient, which createProvingConfig
accepted but the factory did not forward.

Two findings worth carrying forward. Concurrent swaps from one account revert on
finalize even with disjoint input tokens. And minting into the native-credits pool
reverts where the wrapped-token pool succeeds, so the suite prefers the deepest
pool; the LP path there likely needs an explicit token route.

* [Chore] Point the DEX shape-parity job at the live deployment

The job pinned VEIL_DEX_API_URL to amm-api-staging.dev.provable.com, which now
returns 404 on every path. Because it is continue-on-error, it has been failing
silently — and the pin is what let it keep checking a decommissioned deployment
long after the migration moved on, which is the same misconfiguration this branch
fixes in the client.

Dropping the pin lets the suites derive the host from the network, so the job
follows the deployment instead of a snapshot of it. Renamed staging-shapes to
dex-shapes since it no longer targets staging; no ruleset requires a status check
by name, so nothing depends on the old one.

Verified with CI's exact command and no env override: 26 passed, where it had been
failing every path with 404.

continue-on-error is kept — a live-API outage should not red the whole run — but
the comment now says plainly that this hides real drift failures, which is how the
staging rot went unnoticed.

* [Feat] Shorten the confirmation window and report what the polls saw

`waitForConfirmation` defaulted to 300s. Measured against the live testnet
deployment, healthy confirmations land far inside that — a mint took 49.7s and an
increase 39s, both including proving — so a transaction still absent at the limit
is more often one the node never included than one about to arrive. The default
is now 60s.

This is a behaviour change: a write that previously confirmed between one and
five minutes now throws instead of returning. Multi-hop swaps are the known slow
path, one measured at 322s, so a client submitting them sets
`confirmationTimeout` explicitly — documented on the multi-hop section of the
shield-swap README and in the `createProvingConfig` reference, and applied to the
live swap suite.

Every polling failure was previously swallowed, so a node that answered cleanly
and consistently did not have the transaction was indistinguishable from one that
could not be reached — while the message asserted the transaction "may still be
pending", which is backwards for one that was dropped before inclusion.
`TransactionTimeoutError` now carries `polls` and `absentPolls` and says which
case it saw. It does not diagnose why: the confirmed-transaction endpoint cannot
tell a pending transaction from a dropped one on its own.

* [Feat] Add liquidityForAmounts, the deposit-side inverse of amountsForLiquidity

The package could turn a liquidity figure into token amounts but not the reverse,
which is the direction a depositor starts from: a caller holds two balances and
wants to know what position they support. Without it every caller had to invent a
liquidity number and work forwards, and a figure that balances at one pool's
price falls short at another — one side runs out and the mint reverts.

Mirrors the contract's derivation, with the same branch boundaries as
`amountsForLiquidity`: token0 binds at or below the range, token1 at or above it,
and inside the range the shorter side governs. Every step floors, so the result
is a lower bound — feeding it back through `amountsForLiquidity` with
deposit-side rounding returns amounts that fit inside the originals, which keeps
a mint from reverting for want of a base unit. That property is asserted across a
sweep of ticks, range widths and magnitudes rather than on one case.

* [Fix] Stop the liquidity suite building writes on spent records

The live liquidity lifecycle failed at `decrease`, then at `collect`, with a
confirmation timeout against a transaction the chain had never heard of — 404 on
both the confirmed and unconfirmed endpoints, permanently. Each write spends the
position record and creates a new one, and the scanner indexes that
asynchronously, so a write built moments after the previous one carries a serial
number the chain has already consumed. The node drops it at verification, where
it never becomes a rejected transaction and never reaches a block.

The suite only waited for the scanner after `mint`, and that wait checked mere
presence — which the spent record also satisfies. It is now a freshness wait
keyed on the record tag, run after every write that respends the position.

`burn` needed the other lag: the mapping delete propagates to reads
asynchronously, so the position read back with liquidity 0 immediately after the
burn confirmed and null shortly after. Both views are now polled.

Also: spacing comes from `slot.tick_spacing`, which is what `mint` aligns
against, with the fee registry asserted to agree rather than used as the source.
Insert hints are left to `mint`, which applies a correction a caller cannot —
passing an explicit `tickUpperHint` disables it and reverts whenever no
initialized tick sits between the bounds. The deposit is a thousandth of what the
account holds, so repeated runs cannot drain it, and the minted liquidity is
checked against `liquidityForAmounts` with the chain as the authority. A failure
aborts the remaining steps instead of paying a fee each to revert.

Verified live on testnet: everything through `collect` passes, and the mint
parity assertion holds. The `burn` poll is the one change still unverified.

* [Fix] Poll every post-write position read in the liquidity suite

The mint confirmed and its `positions` entry still read back null, failing the
step against a position the chain had created — the same asynchronous propagation
that made `burn` read back with liquidity 0 after the entry was already removed.

Every step in the lifecycle reads exactly what its own write just changed, so all
five had this race and were passing on timing luck. They now share one helper
that polls the position read until the entry shows what the write did: present
after mint, increased after increase, zero after decrease, cleared after collect,
absent after burn. The scanner's separate lag on the burned record keeps its own
poll, since it is a different view catching up rather than the same one.

* [Fix] Tighten the liquidity suite's waits and stop gating on scanner latency

The polling bounds were 200s, which is far past useful: mapping propagation is
quick, and a read that has not caught up long after its write is a signal, not a
slow network. Mapping reads now poll every second for ten seconds. The
scanner-freshness waits keep a longer 30s bound, because record indexing is
measurably slower — the mint's record took 8–15s to appear across runs — and that
wait failing means the next write builds on a spent record and is dropped.

The scanner polls also tolerate a failed read inside their window rather than
failing the step on one error, and attach the last failure if none succeed. The
SDK already force-refreshes the JWT and retries a 401 four times, which handled
the intermittent scanner errors seen on these runs; this survives an outage
longer than that.

The burn test no longer asserts the scanner has dropped the record. It marks
records spent on its own schedule — still serving one 30s after the burn
confirmed — so gating on it makes the suite hostage to third-party indexing
latency rather than to anything this SDK does. The chain-side assertion, that the
`positions` entry is gone, is what proves the burn worked and passes inside 10s.

Verified live: mint with its parity check, the owned-position reads, increase,
decrease and collect all pass. Two runs were cut short by Provable API auth
flakiness — a scanner 'iss' 401 and a prover 401 on /prove/testnet/pubkey —
neither related to these changes.

* [Docs] Say what a null state means on an owned position

The docs described it as a mint that has not finalized, which is one of two
causes. A live run measured the record scanner still serving a burned position
more than four minutes after the burn confirmed, so the same `null` equally means
the position no longer exists — and a caller rendering a portfolio that reads it
as "still loading" shows a phantom position for minutes.

Both directions of the record/mapping lag are now stated, in the action's docblock
and the README, with the public mapping named as what settles which case it is.

* [Fix] Keep the derived DEX API host when baseUrl is passed as undefined

`buildApi` set the derived host and then spread the caller's `api` options over
it, so `baseUrl: process.env.VEIL_DEX_API_URL` with the variable unset passed the
key with an undefined value and won the spread. `ApiClient` then fell back to its
deprecated testnet constant, pointing a mainnet client at testnet — reading pools
that do not exist on the program it proves against, with nothing in the config to
suggest it. The coalesce now runs after the spread, so only a set `baseUrl` wins.

Also corrects the `pickInsertHint` docblock, which still described deriving hints
from the slot's active-range neighbours after the implementation moved to walking
the initialized-tick list. It now says what the code does and why the neighbours
are unusable: they bracket the pool's current tick, not the target.

Both reported by Copilot on #121. The regression test fails against the previous
spread order.

* [Fix] Keep pickInsertHint working without the optional WASM peer

Walking the tick list reads `ticks`, which is keyed by a hash of pool and tick, so
it derives keys through `@provablehq/sdk`. `mint` calls `pickInsertHint` whenever
hints are omitted — and `mint` uses the soft loader while `increaseLiquidity` never
loads WASM — so the walk made both require the peer and broke wallet-backed browser
installs that previously minted fine. That contradicts the design in `utils/sdk.ts`,
which reserves WASM for local-account derivations.

An absent peer now degrades to the slot's neighbours: one mapping read keyed by the
pool, deriving nothing, and exactly what this function returned before the walk
existed. Callers with the peer keep the true predecessor for any target; callers
without it are no worse off than before the change. Documented as best-effort, with
explicit hints named as the remedy for a distant range.

Reported by Copilot on #121. The fallback tests fail against the unguarded walk.

* [Feat] Derive insert hints from the DEX API without the WASM peer

`pickInsertHint` needs `@provablehq/sdk` to hash tick keys for the on-chain walk,
and fell back to the slot's two neighbours without it — correct only for a target
within one initialized tick of the current price, and rejected by finalize for
anything further out.

The API answers this exactly and was already in the vendored spec, unused:
`GET /pools/{key}/initialized-ticks` returns the pool's full sorted tick list, and
its description names this as the purpose — computing the hints the AMM's
hint-walk asserts on, which is what the frontend mint flow does. Exposed as
`client.api.getInitializedTicks`, and supplied by the decorator to pickInsertHint,
mint, and increaseLiquidity, so a wallet-backed client without WASM gets the exact
predecessor rather than a guess.

Three tiers by authority: the contract's list when the peer is present, the API
list when it is not, the slot's neighbours when neither is. The chain stays
preferred because the API indexes positions rather than reading the contract, so
it can lag a fresh mint, and a stale hint costs a fee. A failing or
unauthenticated API drops to the slot rather than failing the write.

Verified live against three testnet pools: the API-derived predecessor matched the
chain walk on every one, including a 17-tick pool where the walk takes 17 round
trips and this takes one.

* [Test] Pin that the chain-walk path makes no API request for ticks

The tick list is attached to hint-deriving actions as a supplier rather than a
fetched array, so a client that can derive tick keys never pays for the fallback
being wired up. Asserted rather than assumed: the fetch spy records no
`initialized-ticks` request while `pickInsertHint` walks the chain.

Also dedupes the request across an action's hints. `mint` derives two and passed
the same supplier to both, which fetched the identical list twice on the fallback
path. The promise is now shared, rejection included, so both hints fall back to
the slot together rather than disagreeing about their source.

* [Fix] Do not trust an empty tick list, and read the poll status null-safely

Review of this branch's own changes turned up two defects.

`predecessorOf([])` returns the sentinel, so an API answering `{data: []}` because
the pool is not indexed yet produced a hint below every initialized tick, which
finalize rejects. An empty list now falls through to the slot instead: a pool that
genuinely has no initialized ticks anchors at the sentinel, which the slot reports
as `next_init_below` anyway, so the safe case loses nothing and the stale case is
covered.

The 404 check read `.status` off the caught value directly, which throws on a null
rejection — inside the loop whose purpose is to survive transient failures. Now
optional-chained.

Also keeps `tickLowerHint`/`tickUpperHint` adjacent in the increaseLiquidity
docblock, which the new property split apart.

* [Test] Cover the null-rejection path in waitForConfirmation

The empty-tick-list fix shipped with a test; the null-safety fix did not. A
rejection carrying no status now asserts that the timeout is still what surfaces
and that `absentPolls` stays 0 — a rejection with no status is not evidence the
node reported the transaction absent.

Fails against the direct property read it replaced.
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