Skip to content

Swaps with swapkit - #349

Open
juanky201271 wants to merge 48 commits into
devfrom
feat/swaps-with-swapkit
Open

Swaps with swapkit#349
juanky201271 wants to merge 48 commits into
devfrom
feat/swaps-with-swapkit

Conversation

@juanky201271

@juanky201271 juanky201271 commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Adds cross-chain swaps: ZEC out to another asset, or another asset in to ZEC, routed through SwapKit across Mayachain Streaming, NEAR Intents, and Flashnet.

An outbound swap is paid by this wallet. An inbound one is paid by the user from another wallet, and the payout lands on a refund-scope address this wallet derives. Both are tracked to completion and appear in History beside zingolib's own transfers.

Blocked on zingolib

native/Cargo.toml pins opreturn_on_proposal. Swaps need two things that branch carries and dev cannot express: OpReturnData, because the Maya and THORChain memo rides in an OP_RETURN, and route_via_ephemeral, because those two read a swap's refund destination from the deposit's origin, which a shielded spend does not expose.

It also carries 6b00f4cc5, which makes derive_refund_addresses public. Reserving an address out of band defeats ADR 0010: it spends an index on a transaction that may never exist, and the proposal then derives the next one, so the address named to SwapKit was never the one the vault would observe.

Nothing here merges before that does. Then the three pins become rev = <sha>, per ADR 0024 rule 7.

How it is put together

src/swap/ is the logic and holds no React. SwapKitClient speaks REST and types every failure. SwapService orchestrates quote, commit, and the broadcast bookkeeping. providers/ is a strategy per provider, since the /v3/swap shape is provider-specific and has drifted across revisions; each executor produces one uniform DepositInstructions. SwapStore persists records per wallet, encrypted through safeStorage. SwapPoller drives /track on two cadences and stops when nothing is left to ask.

src/components/swap/ renders it. DepositSlip is shared between the post-commit view and the detail view, because an inbound deposit is rarely paid in one sitting.

public/electron.js performs the swap layer's HTTP and storage in main, which the renderer's CSP and file:// origin both require. native/src/lib.rs adds derive_refund_address and threads op_return and route_via_ephemeral through send.

Suggested reading order

  1. src/swap/index.ts — the surface, and a map of the rest.
  2. src/swap/SwapService.ts — quote, commit, and the fee arithmetic behind the balance guard.
  3. src/swap/providers/ — where the deposit address and the memo come from. Highest consequence.
  4. src/swap/SwapStore.ts, SwapPoller.ts — persistence and the tracking loop.
  5. src/components/swap/Swap.tsxSwapExecute.tsxDepositSlip.tsx.
  6. public/electron.js, native/src/lib.rs — the two boundaries.

35 of the 168 changed files are chain logos.

Testing

63 suites, 811 tests, up from 596. Chosen by consequence rather than line count: the executors, /track handling, error classification, SwapStore, TokenCatalog, and the deposit slip's warnings. Several pin guards that sit behind past bugs.

Beyond the suite: mainnet swaps through Maya and NEAR Intents, whose captured response shapes are quoted in the executors. cargo check and yarn neon pass against the pinned rev.

Not verified end to end in the app: the deposit slip only appears after a real commit against SwapKit, which needs mainnet funds. Flashnet has no mainnet trace either, so its executor is written from the documented schema and refuses rather than half-building a record if the shape differs.

Known limitations

Swap traffic does not use the mixnet. Quoting and tracking reach the provider directly. Routing it through the mixnet is deferred rather than rejected, with the options written up in docs/swap-privacy.md. The Swap screen states the position, so the mixnet indicator does not imply a coverage it lacks there.

Out of scope

Swap rows merge into History rather than deduplicating against the send that funded them, so both stay visible. Deliberate.

Tests for addressValidators, explorerUrls, feeConversion, and the UI components.


Parts of this branch were written with AI assistance (Claude Code); commits carry Co-Authored-By trailers where that applies.

@juanky201271 juanky201271 self-assigned this Aug 17, 2026
juanky201271 and others added 26 commits August 17, 2026 19:46
The lock had drifted from the manifest: a local `cargo build` moved the
opreturn_on_proposal pin forward to c6d6ce1c9 and left the change
uncommitted, so CI compiled 71d54586 while every developer machine
compiled something else. The rev this commit records is the one
src/native.node was built from.

The dropped cookie / publicsuffix / time-macros entries come with it —
the newer rev no longer reaches them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A swap deposit paid from another wallet has three ways to be lost, and the
slip addressed none of them. All three have already happened on mainnet.

The memo hint said nothing about where the memo goes. On 2026-06-27 a
deposit was stranded because the payer used an EVM wallet, whose `data`
field defaults to empty; the copy has to name the data field there and
OP_RETURN on UTXO, and say neither on a chain we have not mapped.

The exact-amount rule was stated as a mild aside for every inbound swap.
It is not mild for NEAR Intents and Flashnet, which bind a deposit
address to one expected amount and refund anything short of it — a real
refund on 2026-06-29 came from a wallet subtracting its network fee from
the typed amount. Maya and THORChain route whatever arrives, so warning
there only teaches the user to dismiss the banner.

And there was no QR at all: `buildEip681Uri` and `buildMemolessPaymentUri`
had been sitting unused since the port, leaving the user to retype an
address, an amount and a memo by hand. `buildDepositQr` picks between
them and returns null when a memo exists that no URI can carry — a BIP-21
URI drops the OP_RETURN silently, and a code that looks complete while
omitting the field the provider routes on is worse than no code.

The fourth loss has no chain in it: an outbound broadcast that failed
after the commit left a reserved swap with no hash, which the poller
skips forever and no screen could repair. `SwapDetailModal` now offers
the slip plus an Attach deposit transaction field, routed to
markBroadcasted or setObservedDepositTxHash by direction. Both methods
already existed; nothing called either.

The slip is one component across the commit modal and the detail view
because an inbound deposit is rarely paid in one sitting, and the two
surfaces disagreeing about the memo is the disagreement that costs money.

`memoToHexCalldata` now encodes through TextEncoder, so the hex a user
pastes into an EVM wallet is the same UTF-8 MayaExecutor puts in the
OP_RETURN rather than a second encoding that agrees only over ASCII.
jsdom has no TextEncoder, so setupTests borrows Node's — the environment
matching the app, not the code bending to the environment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting a wallet was the one flow that destroys its swap records, and it
neither asked nor cleaned up. Two separate faults.

It never cleared the bucket. `SwapStore.clearForWallet` has existed since
the port with no caller, so the encrypted records of a deleted wallet
stayed in userData/swap-storage indefinitely — keyed by a fingerprint the
same seed would derive again, so restoring that seed on the machine
resurrected a history the user had asked to be rid of. The clear now runs
before `deinitialize`, which is what puts the UFVK, and with it the only
way to name the bucket, out of reach. An unreadable UFVK skips the clear
rather than guessing: a wrong key would wipe some other wallet's records.

And it never asked. `hasInflightDeposits` was equally uncalled, so a
wallet could be deleted with a deposit mid-flight and nothing said so.
The guard now runs first and routes through the confirm modal. That
notice also had to move: "stopping all the activity" was announced from
submitAction, and both modals are react-modal over one overlay with the
error modal mounting last, so it would have covered the question it was
racing.

The predicate is wider than mobile's, which counts outbound broadcasts
only. Mobile guards a wallet-replacement flow that keeps the seed; here
the payout of an inbound swap is addressed to an ephemeral address of the
wallet being deleted, so losing it without the seed written down loses
the funds, not just the tracking. Swaps that are merely reserved stay
uncounted — nothing has moved, and an abandoned quote must not stand
between a user and deleting a wallet.

`SwapStore.unbind` closes the neighbouring gap: the binding is module
state, so closing a wallet left it naming the departing wallet's bucket
until the next bind resolved, and a History mounted in that window listed
another wallet's swaps.

`backupCurrentToSlot` stays uncalled on purpose. It mirrors mobile's
single-wallet-file model, where changing wallets destroys the outgoing
one; zingo-pc keeps wallets side by side and destroys nothing on a
switch, so there is no moment that slot would be written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An outbound Maya swap that was reserved and never paid has no
source-chain hash, and Maya's /track only answers to a hash — the deposit
address is a rotating vault. So there is nothing to ask about it, ever.
runTick counted it as pollable anyway, which meant the auto-stop never
fired and the interval stayed armed for the life of the process,
decrypting and parsing the whole store every 20 seconds over a record
that could not move.

The fix is a distinction the old predicate did not draw: "not due yet"
must keep the interval alive, "nothing to ask" must not. shouldPollNow
became isPollable and isDue, and only the first decides whether the
poller stays armed.

Auto-stop was unreachable before, so re-arming was never exercised.
setObservedDepositTxHash relied on tickOnce, which fires one tick and
leaves a stopped poller stopped — the record it rescues is precisely one
of the records that now stops the poller. It starts first and ticks only
if it was already running.

Separately, the maximum swappable amount ignored the Zcash network fee.
It subtracted the route's fees from the balance and nothing else, so
swapping near the balance was offered and then refused at propose_send,
after the route was committed at the provider. The fee cannot be known
before then — a proposal needs a deposit address, which does not exist
until commit — so the screen reserves a ZIP 317 estimate instead, doubled
for the ephemeral route because that is two transactions each paying its
own fee. It errs high: reserving too much costs a swap amount a hair
below what was possible, reserving too little costs a committed route.

needsEphemeralRoute moved out of SwapExecute to sit beside that estimate.
They read the same fact about the same providers, and a copy that drifted
would have the fee reserve budget for one transaction while the deposit
sent two.

Also corrects what reserve_ephemeral_address claims. It reserves an
index; the proposal then picks its ephemeral address with
derive_refund_addresses, which returns the lowest index NOT reserved — so
the address declared to SwapKit is never the one the vault observes, and
each swap consumes two indices. Refunds still land somewhere this wallet
can spend, so nothing is at risk, but the comments said otherwise. What
closing it needs from zingolib is recorded where the next reader will
look.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
reserve_ephemeral_address called generate_refund_addresses, which claims
an index. The proposal that pays the deposit then derives its own with
derive_refund_addresses, which returns the lowest index NOT claimed, so
it took the one after. Every outbound swap told SwapKit about an address
its ZIP 320 hop would never spend through, and consumed two indices to do
it. Both are refund-scope addresses of this wallet, so a Mayachain or
THORChain refund read off the deposit's origin still landed somewhere
spendable; what was wrong was the correspondence.

Claiming out of band also defeated zingolib ADR 0010, which moved
reservation to apply time precisely so an abandoned plan leaves the index
free. Browsing swap quotes burned one index per asset the user looked at.

zingolib 6b00f4cc makes derive_refund_addresses public, so this now
derives. Repeated calls answer with the same address until something
applies, which is what lets the screen ask on every re-quote.

Inbound needs the claim that outbound gets for free. It is paid from
another wallet, so this one never builds a transaction bearing the
address and nothing would ever move the index on. Deriving alone would
hand every inbound swap the same address, and a provider watching two
deposits arrive at one t-address can tie the swaps together. SwapExecute
claims it once the route is committed, so browsing still costs nothing
and only a swap the user went through with spends an index. A failed
claim is logged rather than surfaced: the swap is live at the provider by
then and the address is still one this wallet watches.

Funds were never at risk either way. pepper-sync discovers transparent
addresses by scanning forward from the last claimed index up to the gap
limit, so a derived address receives and is found.

Renamed to derive_refund_address across the bridge, since "reserve" is
what it stopped doing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SwapStore carried four paths for migrating records written by older
builds. None of them can fire here. The branch history says why: the
first swap commit already wrote per-wallet namespaced keys and already
minted recordId in commitRoute, and sendSwapDeposit returned a string[]
from the commit that introduced it. There is no released zingo-pc with
swaps, so no user has a record from any other shape.

Gone with them:

- The legacy `swap:records` migration, reading a key no build here ever
  wrote.
- The single-slot backup, which mirrors mobile's one-wallet-file model
  where changing wallets destroys the outgoing one. zingo-pc keeps
  wallets side by side, so nothing would ever write that slot, and
  bindToWallet was paying a decrypt attempt per bind to look for it.
- migrateBroadcastTxIds and migrateRecordId, allocating a new object per
  record on every read to repair shapes this app cannot produce.

swapStatusLabel.ts goes too. It takes a `translate` callback for mobile's
i18n, was never exported from the barrel, and swapRowLabel has covered
the same ground since it was written.

What the store does is now what it says: one encrypted key per wallet, a
promise-chain mutex over it, and subscribers notified after each write.

The tests are new, since the store had none. They cover what the deletion
leaves standing rather than what it removed: the per-wallet boundary,
that overlapping upserts do not drop one another, that clearing takes the
records off disk and still empties the bucket when the file cannot be
removed, and that a failed read answers empty without touching what is
stored. A read failure looking like an empty wallet is how a swap in
flight would disappear from the history.

markKeyAsCleared kept its overwrite-then-remove, and lost the iOS
Keychain rationale it was carrying. On this platform the reason is a
handle held open by something like a backup agent, which is enough on
Windows to fail a delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The swap layer's HTTP runs over clearnet while the wallet's indexer
traffic rides the mixnet. Per quote, SwapKit sees the user's IP beside
the asset pair, the amount, and both addresses, one of which is the
refund-scope Zcash address this wallet just derived. That correlates an
address the wallet controls with an IP and with an identity on another
chain. Polling repeats the association every 20 to 90 seconds for as long
as the swap runs.

zingolib ADR 0024 ruled on this shape already: its Context names
consumers fetching price over clearnet as the failure it was written to
end, and rule 6 answers with mixnet or nothing. zingo-pc honours that for
price, where the display goes dark until the mixnet converges.

Routing swap traffic the same way is a decision with a real fork in it.
Through zingolib is what ADR 0024's one-mint rule asks for and costs a
cross-repo surface; a SOCKS5 agent in main is small and puts transport
policy back in a renderer, which is the divergence that ADR exists to
stop. Deciding that is not this commit's job, so it records the analysis
instead of guessing.

Both notes sit at the handlers as well as in the doc. A file nobody opens
is how ADR 0024 describes its own failure mode: the disclaimers already
promised behaviour no consumer implemented.

Also written down: swapLogo:get fetches any HTTPS URL the renderer names
and caches without bound, the picker tells CDNs which tokens are being
browsed, native/Cargo.toml pins zingolib by branch against ADR 0024 rule
7, and the SwapKit key ships extractable in the bundle with no
client-side fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The swap layer shipped with one test file for 8.5k lines. This covers the
four places a mistake is expensive, chosen by consequence rather than by
size.

Executors decide the deposit address and the memo. Everything else in the
layer can be wrong and cost a confusing screen; those two fields wrong
cost the deposit. The fallback chains that absorb SwapKit's shape drift
are the part most likely to rot silently, so each rung of Maya's memo
probe is pinned, along with what the executors refuse rather than
half-build.

The error classifier decides which remedy the user is sent to. "No route,
try another amount" points at the amount field, "the service is down"
points at waiting, and an edge block points at a VPN. The 403 split
matters most: an HTML body is Cloudflare refusing a region and a JSON
body is the backend refusing the key, and only one of those is fixed by a
VPN.

Fee aggregation feeds the largest amount the user is offered and the
guard that refuses a commit. The conversion exists because SwapKit
denominates fees inconsistently, so both directions are covered, as is
the rate it declines to derive from a zero buy amount rather than
amplifying a fee into a number with nothing behind it.

The History projection decides the number on the row. Outbound shows what
left, inbound what arrived, and an incomplete deposit stays in flight
rather than painted as failed, since the provider is holding those funds
and will refund or accept a top-up.

61 suites, 750 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
swapLogo:get fetched any HTTPS URL the renderer named, which answered
whether an arbitrary host serves an image and reached it from the user's
address. A hardcoded allowlist was not available: logoURI arrives inside
SwapKit's catalog, so anything written here would be a guess that breaks
when the CDN moves.

Main already proxies /tokens, so the answer passes through it. The hosts
named in that response are collected as it goes by and nothing else is
fetched, which makes the allowlist maintain itself and puts it beyond the
renderer's reach. Read with a pattern rather than JSON.parse: the shape of
a catalog entry has drifted often enough that the executors carry fallback
chains for it while the field name has not moved, and it skips a second
parse of a megabyte the renderer is about to parse anyway.

Before the catalog has been through, the set is empty and every logo falls
back to its letter avatar. That is the right way round, since a token is
only ever drawn from a catalog entry.

The cache held data URIs of up to 256 KB with no bound on entries or
bytes, and the picker renders 60 at a time, so browsing a thousand-token
catalog walked it upward for the life of the process. It now evicts
oldest-first under a byte budget. It also remembers a logo that would not
load, which SwapKit's CDN lists more than once, so the picker stops asking
for it on every render. A timeout is not remembered: that says nothing
about the URL.

The Swap screen now says the provider sees the user's IP. The mixnet
modal's promise is scoped to the indexer and true as written, but it is
not what a user reads off a green indicator, and a swap tells SwapKit more
than a send tells an indexer. Routing that traffic through the mixnet is
deferred rather than rejected; saying so is what stops the screen from
implying otherwise in the meantime.

docs/swap-privacy.md records the decision and keeps the options, so
revisiting it starts from the analysis rather than from the beginning.

Verified by hand rather than by the suite: electron.js sits outside jest's
roots. The harvest reads both hosts out of a realistic catalog fragment
and steps over an entry with no logo, and the eviction holds its budget
while keeping the newest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The poller's suite runs a passthrough executor so scheduling and mutation
stay separable, which left the mutation itself uncovered. It is the part
that decides what a swap appears to be doing: the status, the leg hashes,
the realised payout.

Four behaviours carry their own past bug and are now held still. An
unrecognised status keeps whatever the record already says, so a status
SwapKit adds later cannot knock a healthy swap back to unknown. The
all-zero placeholder is refused on the way in and scrubbed if an older
build persisted one. A payout amount is taken only when the asset beside
it matches what the record is buying, which is what stopped the History
row jittering between a hop's intermediate amount and the real one.
Streaming Maya reports itself mid-flight under four different names and
each maps to the same in-progress state.

Writing them turned up a sharp edge in pickLegHash. When the leg for the
target chain is present but empty, the positional fallback can answer with
a leg on another chain. Real responses put the outbound leg last, which is
what keeps it unreachable, so the behaviour is pinned with a note rather
than changed on a guess.

TokenCatalog comes with it, since its failures are the quiet kind: an
asset that routes fine is simply absent, and nobody reports a token they
never saw. That is how an exact-case match against the routability
endpoints once dropped every NEAR asset from the picker. The casing rules
are covered from both sides, along with the fallback to the whole catalog
when routability is unavailable, which prefers showing too much over
showing an empty picker.

63 suites, 811 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The write-up read as a set of directions. It named the API key and where
it ships, enumerated the fields a quote carries, and pointed at which one
mattered most. All of it true, all of it in a public repository, and none
of it needed by the person the document is for.

What a maintainer needs is why the traffic is a problem, what was decided,
and the two ways to revisit it. That is what is left. The concern is
stated as its shape rather than its coordinates: a quote carries wallet
addresses to be answerable at all, so it puts an address the wallet
controls beside something that identifies the machine, which is the pair a
shielded wallet exists to keep apart.

The API key paragraphs are gone. Anyone reviewing the diff meets the build
script anyway, and a reader who is not reviewing has no use for the
pointer.

The comment on swapHttp:request loses the same enumeration and keeps the
part that matters to whoever edits it: the traffic has none of the cover a
send has, the decision was a deferral, and the reasoning is one file away.

PR #349's description was trimmed the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It sat tight under the slippage control, reading as a footnote to that
setting rather than as a statement about the screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every info fetch planned an Orchard drain, including for wallets opened
from a viewing key. Those cannot spend, so they cannot migrate, and the
Dashboard already hides the prompt for them behind the same flag. The plan
was computed for nobody.

It also failed, loudly and three times a launch. Planning needs sync data,
and the load sequence reaches this before the first sync has produced any,
so `console.error` fired on a state that is normal and temporary. The
fields now keep their InfoClass defaults instead, which carry the same
"not known" the plan reports when it fails.

The flag is threaded from where the app already decides it rather than
derived a second time. `getInfoObject` is static and reads no context, so
the two callers hand it down: `fetchInfo` from a `readOnly` field on the
RPC instance, pushed from `Routes` exactly as `setCurrentWallet` already
pushes the wallet, and `LoadingScreen` from the kind it has just read.
LoadingScreen passes its own value because `setReadOnly` has only just
been called and the context still holds the previous wallet's on that tick.

The field starts false, so a wallet whose kind has not been read yet is
treated as able to spend. Being wrong that way costs a plan nobody uses.
Being wrong the other way hides the migration prompt from a wallet with
funds to move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review was the odd one out. Its title sat left-aligned at `large` while
every other modal in the app centres an `xlarge` one, and its body ran
straight down the modal with no scroll region, so a long destination
address pushed the buttons past the bottom edge.

It now uses the shell the rest share: a vertical flex at full height, a
centred title, a scrolling middle, and the buttons pinned below it. The
deposit slip's title moves from `large` to `xlarge` for the same reason,
since the two views belong to one modal and were disagreeing with each
other as well.

The button rows were not centred either, in three places. They asked for
it with `cstyles.center`, which is `text-align: center` and does nothing
to the position of a flex item, so the buttons sat at the start of a row
that looked like it had been told to centre them. They now say
`justifyContent` and drop the class that was reading as an instruction
without being one.

An audit of the other seven swap modals found their titles and single
buttons already on the pattern, so they are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SwapKit says "no route" two ways. It answers 200 with an empty routes[]
beside a providerErrors[] naming each refusal, and it answers 404 with
noRoutesFound. Only the first reached the user as an explanation:
describeEmptyQuote reads the providers' minimums off it and says how much
would work. The second threw, so the screen printed

  SwapKitHttpError: SwapKit quote HTTP 404: No routes found for
  NEAR.USDC-1720... -> ZEC.ZEC / noRoutesFound

at someone whose amount was merely too small.

The service now reads that refusal back into the shape the 200 would have
had, so the caller keeps one path and the existing sentence covers both.
The body is searched for the same providerErrors, so a 404 carrying them
still names the minimum; one without leaves the list out rather than
inventing a figure.

Only the refusal the classifier already calls NoQuoteOrLiquidity takes
this path. A rejected key, a provider outage, a transport failure, and
anything that is not a SwapKit error at all still throw and still reach
the user as the faults they are. Four tests hold that half down, since
swallowing a real error here would be worse than the message it replaces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The swap screen already offered it, so Send was the odd one out: the only
way to reuse a saved Zcash address was to open the Address Book and come
back with it. The button appears where the save-as-contact one does, on
the same field, and only while nothing is typed, which is when a contact
is the useful thing to offer.

Its icon is the address book's, on both screens. Swap was using the list
icon, which reads as a view of what is there rather than as the place it
opens.

Zcash contacts only. The address book holds swap contacts too and those
carry the same `chain`, since swaps are mainnet-only and a Bitcoin address
is stored against the main network with its own `swapChain`. Filtering on
the network alone would have offered a Bitcoin address to a Zcash send.

ContactPicker moves to `common/` now that a second screen wants it, and
takes the chain's label rather than its code. Resolving that in the caller
is what keeps a shared component out of SwapKit's chain vocabulary.

A picked address goes into the field rather than around it, so the
validation, the ZNS check and the contact badge all run as if it had been
typed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ZEC chip fell back to its letter avatar on a first visit to Swap. Its
logoURI pointed at a Google Storage host, and swapLogo:get now only
fetches hosts harvested from SwapKit's catalog as it passes through main.
The chip renders on mount, before that catalog has been asked for, so the
one logo the screen shows first was the one the allowlist could not yet
answer for. A second visit worked, which is what made it look like a
loading quirk rather than a rule.

Fetching it at all was the mistake. ZEC is the fixed side of every swap
here, its mark is ours, and zcash-yellow.png has been sitting in the repo
unreferenced since this branch added it. It is now registered in
chainIcons and the entry carries no logoURI, so the chip draws with no
network round trip and no third party asked for it.

TokenLogo falls back to the bundled chain icon before the letter avatar,
but only for an asset that IS its chain. A token borrowing its chain's
mark would put Ethereum's logo on USDC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pane's height came from a constant subtracted from the window, and
the block above it has no fixed height: the balance row gains and loses a
block with the wallet's pools, the shield button appears with a
transparent balance, and the pending notice and the fetch error each add
a line. The constant was therefore right for one wallet and wrong for the
next. When it was too small the pane extended past the bottom of the
window, and the rows in that overhang could not be reached at all.

Where the list starts is exactly what the constant was approximating, so
it is measured instead. A ResizeObserver on the header catches it changing
under its own conditions rather than only on a window resize, and it
watches the header rather than the pane, whose height is derived from this
measurement and would feed back into itself.

Kept inside History rather than moved into ScrollPaneTop. Send draws its
buttons below its own pane, so a component that decided to fill the window
for everyone would push them off the screen.

Other screens carry the same shape of constant against the same kind of
header. This changes only the one that was reported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…them

The Dashboard's "Last transactions" listed zingolib's transfers only, so
a swap that had just left the wallet was absent from the summary while
sitting at the top of History. A swap moves the user's money; a list of
what their money did is wrong without it. The merge moves out of History
into a hook both screens read, which is what keeps the two from
disagreeing about the same five rows, and the Dashboard renders a swap's
own state rather than a transfer status that cannot tell one awaiting its
deposit from one already being worked on.

The wait on a wallet switch had a cause worth fixing rather than hiding.
The swap store binds off the wallet's UFVK, and `get_ufvk_string` took the
write lock on LIGHTCLIENT for an operation that only reads. That put it
behind every other holder exactly when sync is starting and contending for
the same lock, and the wait showed up as a wallet's swaps arriving late
enough to look lost. It takes the read lock now, alongside the other
read-only calls.

Whether that closes the gap fully needs the running app to say. The
remaining shape, if any, is that History renders once zingolib's transfers
arrive and again when the store answers, so an empty swap list is
indistinguishable from one not yet read. Telling those apart needs a
loaded signal through the context, which is worth doing only if the wait
is still visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What is sold is printed on the left and what is received on the right, for
both directions. The arrow followed the direction instead of the line, so
an inbound swap drew it pointing back at the sold side and the header read
against itself.

Both amounts now go through the same formatter the History row uses, so a
provider's long decimal string does not read as one number in the list and
another in the detail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The arrows lived inside VtModal, which resolved each step against
zingolib's transfers alone. History's list interleaves swaps with those,
so stepping onto a swap found nothing, decided something weird was
happening, and closed the modal. The swap detail had no stepper at all,
which is the same gap seen from the other side.

The step moves to the screen that owns the list. Setting the row is
already enough for History to choose the view, so a swap opens the swap
detail and a transfer opens the transfer one, and neither modal needs to
know the other exists. VtModal is remounted per row, which is what
re-seeds its internals from the row it landed on.

The arrows and the keyboard move into DetailNavigator, so both views carry
one stepper rather than two that could drift.

Messages gets its own step over its own list, since it renders the same
modal and the modal no longer resolves one for itself.

The navigation tests now assert the request rather than the modal's own
index, which is what the modal is responsible for now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both directions showed the ZEC side, which is the one figure the row could
not add anything to. The ZEC leg already has its own row beside it: the
deposit this wallet broadcast for an outbound swap, the payout it received
for an inbound one. Those are zingolib's own transfers and are
deliberately not deduplicated away, so the swap row was repeating what the
row above it already said.

It now carries the counterparty asset, which is the part of a swap nothing
else in the wallet can show. Outbound reads what is being bought, inbound
what was paid.

That brought two things with it. The unit was the wallet's currency name,
so a BTC amount would have been labelled ZEC. And the USD column priced
the amount at the ZEC rate, which for a BTC figure is a wrong number
rather than a missing one; the record already persists each side's unit
price from quote time, so the row carries its own. Zero reaches the
renderers as "USD --", which is what an unpriced quote leaves behind.

Outbound shows the quote-time estimate until the provider reports a
payout. The row's status label is what says the swap is still moving.

The test fixture for an inbound swap swapped its assets without swapping
its price basis, so it priced BTC at 30 and ZEC at 60000. Fixed with the
assertion that caught it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The row carried two labels. The one above is the swap's own state, which
swapRowLabel covers in full, from awaiting a deposit through refunded. The
one below said Calculated, Transmitted or In Mempool, which describe where
a Zcash transaction sits on its way into a block. A swap is not doing
that: it is waiting on a deposit, or on a provider moving funds across
chains. The second line could only restate the first in a vocabulary that
does not apply to it.

Gone from swap rows. The mapped transfer status stays, because it is what
colours the amount when a swap fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
juanky201271 and others added 19 commits August 27, 2026 17:51
Two views of the same list opened onto two different shapes. This one now
follows the other: a title line, then the direction icon with the state
under it, then the amounts beside them.

The arrow carries the same meaning it does on a transfer, read on the ZEC
side, which is the side this wallet holds. Inbound points down and takes
the green a received transfer takes; outbound points up in the plain text
colour. The state keeps its own colour under the icon, since a failed or
refunded swap is the first thing worth seeing on the screen.

The amounts are green like the transfer's figure but at the smaller of the
two sizes. That line carries two amounts and two tickers, and at the
headline size it wraps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pairs with the transfer detail's Transaction Status, so the two views of
the same list name themselves the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The transfer detail prints its type plainly and lets the icon carry the
colour. This one was tinting the state by what it meant, which made the
two headers differ in the one place they should have matched.

Nothing is lost by dropping it: the label says "Swap failed" or "Swap
refunded" outright, so the colour was restating the words. `statusColor`
went with it, having no other caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The facts were a stack of one-per-line rows under headings. They are now
rows of label-over-value columns separated by rules, which is the shape
the transfer detail uses for the same job and fits the same information in
a third of the height.

Provider, direction and route id share a line; the two dates share the
next. The amounts share one of their own, and the total fee joins them
with the breakdown button beside it, since a fee is an amount and had no
business in a section of its own.

Addresses and Transactions keep their headings, now centred. Track this
swap loses its heading and its rule: three buttons that open a tracker say
what they are, and that rule was the last thing on the screen rather than
a separator between two things. The footer is spaced rather than ruled off
for the same reason.

Amounts pass through the display formatter here as they do everywhere
else, so a provider's long decimal does not read as a different number in
this view than in the list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bar above Cancel in the swap detail was the scroll pane's own
horizontal scrollbar. The pane asked for `overflowY: auto` and said
nothing about the other axis, and CSS computes `overflow-x` to `auto`
rather than `visible` once its partner scrolls, so anything a few pixels
too wide draws a full-width bar. The row of tracker buttons was that
something.

`ScrollPaneTop` has always paired the two, which is the pattern followed
here. All six swap panes and the contact picker had the same gap, so all
of them get it rather than the one that happened to overflow first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every row of facts now carries the same top padding the stacked ones
already had, so the rows breathe the same whether they hold one field or
three.

The two remaining headings go up a size. They were set at the same size as
the labels beneath them, which left nothing marking them as headings but
their position.

Field and DetailRow were drifting: one labelled at the body size and broke
its value on word boundaries, the other labelled small and broke anywhere.
They are one label and one value now, and DetailRow differs only by having
a line to itself and a copy button, which is the reason it exists.

The fee button drops the 8px the shared button reserves for sitting beside
another one. At the end of a row it has nothing to sit beside, and that
margin read as the row stopping short of the edge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The breakdown showed the converted figure at ten decimals and the
provider's own amount at whatever it sent, which for an ERC20 fee is
eighteen. One line carried two precisions, and the longer one was the
provider's arithmetic rather than money anyone holds.

Both go through one formatter now, capped at eight: the wallet's own
smallest unit, and the smallest thing any asset here is quoted in. The
formatter takes the provider's string as readily as a number, since that
is how the raw half arrives. The breakdown's total was printing raw too.

A fee under that last place says so rather than reading "0", which would
have meant no fee at all. The wallet already writes a sub-cent USD figure
the same way.

The tests come with it, the module having had none: the string and number
paths, the trimming, a binary fraction's tail, and the sub-threshold case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It borrowed the transaction detail's modal, which holds 800px because
addresses and memos need it. A fee breakdown is a label and an amount per
line, so most of that box was empty to the right.

480px, and centred rather than pinned at the shared modal's left offset,
which a narrow box sitting there would have read as off-centre. It also
takes a max-width, so on a small window it stops at the edges instead of
reaching past them.

Written out in full rather than layered over `.txmodal`. Two single-class
selectors carry the same specificity, so which one won would have come
down to the order the bundler emitted them in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Saving a swap address called `window.prompt`, which Electron does not
implement. The button threw `prompt() is not supported` and nothing
reached the address book.

Asked in a modal on the screen rather than by sending the user to the
Address Book, which is how Send does the same job. Send survives that trip
because its form lives in context; the swap form is local state, so
leaving would discard the amount, the address and the live quote just to
name a contact.

The address book still narrows the chain from the address itself, so the
saved entry needs nothing more than the name.

That was the only browser dialog left in the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four inputs were bare, with a width nudged inline and whatever border the
browser draws. Beside the swap screen's own address field, which sits in a
rounded well that lights up on focus, they read as something the platform
put there rather than part of the screen.

That shape moves out of AssetCard into Swap.module.css, since the asset
search, the contact name, the deposit transaction and the custom slippage
all wanted it. The border and the background live on the wrapper rather
than the input, which is what lets a field hold a button beside its text
and still read as one control, and what gives `:focus-within` something to
light up.

The slippage field also gains a decimal input mode, which the amount field
has had all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The review screen stated four facts one per line, each labelled at the
small size, while the swap detail beside it stated its facts in rows of
label-over-value at the body size. Same information, two designs.

Review now reads as two lines — what you send and what you receive, then
the route and where it lands — in the detail screen's shape.

`Field`, its copyable twin and the row that holds them move out of
SwapDetailModal into DetailField, since the deposit slip had grown its
own copy of the copy row and the review screen was about to grow a third.
The fee breakdown, the save-contact prompt and the slip follow.

Two smaller alignments fall out of it: a field label reads at the body
size and an explanatory note under one reads small, which the custom
slippage field had backwards; and the button row that closes a modal now
takes the same air above it in all nine of them rather than the gap that
separates one field from the next.
Neither line belongs to the field above it — one qualifies both amounts,
the other reports on the whole screen — so both read as a third column
that ran on where they sat left-aligned under the last row.
An outbound deposit is an ordinary ZEC send, so it takes the route Mixnet
Mode dictates and refuses outright while the mixnet is bootstrapping,
unattached or dead. The swap screen never asked. It committed the route
at the provider first and discovered the refusal at the send, leaving a
reserved swap with nothing paid and no way forward inside the app.

Review is now blocked for outbound while the send is, using the same
`sendBlocked` verdict the Send screen respects and the same sentence
describing the route. The privacy note beside it said swaps reach the
provider directly, which is true of the quoting traffic and not of the
deposit; the two halves travel differently and now say so.

Recovery too, since the mixnet can also die after the commit: the
broadcast splits out of the commit so it can be tried again against the
swap already reserved. Re-committing would have opened a second one.

Adds four tests: the retry appears on a failed broadcast, pays the
reserved swap rather than committing another, clears the failure it
followed, and never appears inbound, where the deposit is paid from the
user's other wallet and there is nothing here to send.
The screen quoted with a blank counterparty address and substituted the
real one when mounting the review, on the reading that SwapKit prices
without it and the commit is the first moment it binds anything. The
first half is true; the second is not. The route id is minted at quote
time and carries the addresses it was quoted with, so a provider that
builds the route around the destination — NEAR Intents names it in the
intent, rather than deferring it to a deposit memo the way Maya does —
refuses the route rather than the swap call:

  Invalid route <id>. Must have source and destination addresses, and
  they cannot be dummy addresses

Filling the address in at commit time cannot rescue that. So the address
goes into the quote as soon as there is a valid one, and Review waits for
a quote that carries the address on screen: entering or editing one
re-quotes, and until that lands the routes belong to a different
destination.

Quoting still starts before the address exists, which is what lets prices
stream while the user is typing. What changes is that a route quoted that
way can no longer be committed.

`quoteAddressPair` and `quoteBindsAddress` name the rule once, in swap/,
because the two halves have to agree: what a quote goes out with, and the
test for whether the quote on screen went out with the current address.
Provider, exact amount and the deadline each had a line to themselves,
which pushed the deposit address — the one value on the screen that has
to be read character by character — down the pane behind the others.

They share a line now, and the address and the transaction each get one.
The reprice note stays in the column with the deadline it qualifies
rather than becoming a loose line under the row belonging to nothing.

Provider arrives as `leadingFields` rather than being rendered by the
slip: what belongs beside the amount differs by surface, and the detail
view already names the provider further up its own screen.
A quote that offers one route looked the same whether the other
providers do not trade the pair or merely want a larger amount. Only one
of those is something the user can act on, and there was nothing on
screen to tell them apart — the standing question being why only NEAR
ever appears.

SwapKit reports its refusals in the same response as the routes, with the
minimum stated outright where an amount is the cause. That was read only
when there were no routes at all, so in the common case it went on the
floor. `quote()` now returns it beside the routes.

The list shows them under a rule, greyed and inert: they are not choices,
and mixed in among the selectable rows they would read as options that
happen to be dimmed. Only providers with an executor are listed —
SwapKit refuses on behalf of a dozen it offers, and routes that could
never have been taken teach nothing. A provider that returned neither a
route nor a complaint is reported as not trading the pair, which is what
that silence means; nothing invents a cause for it.

The compare button opens on a single route when there are refusals to
read, since "why only one route?" is the question that list answers.

Also drops a comment left over from the address change: the addresses go
into the quote now, and it still described them arriving at review time.
THORChain's Zcash support is live ahead of the liquidity that makes it
quotable, so the wallet should be ready for the first route rather than
discovering it is not.

Almost all of it was already here: the provider-data variant, the
ephemeral-route rule, the memo requirement, the Midgard host, the
explorer URLs and the poller's hash-discovery gate all named THORChain
already. What was missing was the executor. Mayachain is a THORChain fork
and both answer /v3/swap identically, so the response probing moves into
`extractVaultMemoDeposit` and each executor keeps only its own identity
and persisted shape.

The second half explains why MayaChain has looked quiet. SwapKit counts a
streaming swap and a single-shot one as two providers, and this app knew
only MAYACHAIN_STREAMING and THORCHAIN_STREAMING. A route named MAYACHAIN
was cast to the enum, found to have no executor, and dropped — no route,
no error, nothing on screen. Whether that is what has been happening is
now observable rather than a theory: both forms have executors, and any
route SwapKit offers through a provider we cannot execute is reported
instead of discarded.

The four identifiers are one provider to a user, so the rules that keyed
on them go through `isThorchainFamily` rather than each carrying a list
that goes stale on the next one, and the unavailable list shows one row
per provider — preferring, where the two forms disagree, whichever gave
an actual reason.
Checked `/providers` rather than reasoning about it. On 2026-08-28 three
providers list `zcash`: NEAR and Flashnet as supported and enabled, and
Mayachain Streaming as supported with `enabledChainIds: []` — every chain
switched off, not Zcash in particular. THORChain Streaming does not list
`zcash` at all yet.

That retires the guess in the previous commit. There is no plain
MAYACHAIN or THORCHAIN provider at SwapKit, only the streaming forms, so
routes were never being dropped for want of an executor for one and
Mayachain has been quiet because SwapKit has it switched off. The enum
members, the second executors, and the per-provider row collapsing that
existed to serve them all go.

What stays is what stands on its own: the THORChain executor, ahead of
the routing rather than in response to a drop; the shared vault-and-memo
extraction; and the report of any route offered through a provider with
no executor, which is the thing that would have made this observable
instead of a theory.

The provider list is recorded in the enum with the date it was read, in
the same spirit as the mainnet response captures elsewhere in swap/.
Flashnet came back `rate_limited` and the list printed exactly that. A
bare identifier tells the user nothing they can read — least of all the
part that matters, which is that it is not about their swap and the next
quote may well get through.

Codes are now matched with casing and separators stripped, since SwapKit
writes them snake_case in one place and camelCase in another, and the one
refusal whose meaning is known well enough to state gets a sentence. A
message with a space in it is still passed through: that is prose the
provider wrote, and a specific sentence beats a vague one even when a
developer wrote it. A message without one is an identifier, and falls
back to the plain sentence rather than reaching the screen.

Nothing else is paraphrased. Knowing what the user should take from a
refusal is what makes an entry earnable, and for a code nobody has seen
that would be a guess.
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.

1 participant