Swaps with swapkit - #349
Open
juanky201271 wants to merge 48 commits into
Open
Conversation
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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tomlpinsopreturn_on_proposal. Swaps need two things that branch carries anddevcannot express:OpReturnData, because the Maya and THORChain memo rides in an OP_RETURN, androute_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 makesderive_refund_addressespublic. 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.SwapKitClientspeaks REST and types every failure.SwapServiceorchestrates quote, commit, and the broadcast bookkeeping.providers/is a strategy per provider, since the/v3/swapshape is provider-specific and has drifted across revisions; each executor produces one uniformDepositInstructions.SwapStorepersists records per wallet, encrypted throughsafeStorage.SwapPollerdrives/trackon two cadences and stops when nothing is left to ask.src/components/swap/renders it.DepositSlipis shared between the post-commit view and the detail view, because an inbound deposit is rarely paid in one sitting.public/electron.jsperforms the swap layer's HTTP and storage in main, which the renderer's CSP andfile://origin both require.native/src/lib.rsaddsderive_refund_addressand threadsop_returnandroute_via_ephemeralthroughsend.Suggested reading order
src/swap/index.ts— the surface, and a map of the rest.src/swap/SwapService.ts— quote, commit, and the fee arithmetic behind the balance guard.src/swap/providers/— where the deposit address and the memo come from. Highest consequence.src/swap/SwapStore.ts,SwapPoller.ts— persistence and the tracking loop.src/components/swap/Swap.tsx→SwapExecute.tsx→DepositSlip.tsx.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,
/trackhandling, 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 checkandyarn neonpass 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-Bytrailers where that applies.