Swaps with swapkit - #349
Open
juanky201271 wants to merge 158 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>
`marginnegativetitle` pulls -20px up. That suited a row of bare titles, which is what it was written for, and does not suit this one: the checkbox stands taller than they do, so the pull put it under Add and Clear. Overridden at the top only, to 8px. The negative bottom stays — it hugs the list, and that is where the height this change set out to recover comes from. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clicking the server line on auto offered one answer: rotate. That answers "this one is misbehaving", which is what auto exists for, and leaves a user who wants a particular server with nowhere to say so — the picker only ever opened for a wallet already on `list`, a mode reachable only through the wallet settings screen. A third button opens it. Choosing there switches the wallet to `list`, because naming a server by hand is what that mode means; leaving it on `auto` would describe a wallet sitting on a server nothing auto would have picked. The record in wallets.json is what carries that, through the `wallets:update` the switch already performed. The settings copy is written alongside it. That copy is not this wallet's state — it seeds the next wallet, when there is no current one to read from — and `switchServer` was already writing `serveruri` into it. Writing the uri without the selection left that pair saying a specific server was chosen automatically, which was never true of a hand-picked one. `ConfirmModalClass` gains an optional third choice. Optional because most confirmations do not have one: a dialog that invents a middle answer where there is none only makes the two real ones harder to find. Absent, nothing is drawn and every existing caller reads exactly as before. Only the picker calls `switchServer`, so no rotation is caught by this: that has its own path and stays on auto, which is the whole point of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s List Cancel, Confirm, Servers List. It leads somewhere else rather than settling the question the dialog asked, so it sits past the answer to it instead of between the two halves of one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picking by hand is what puts a wallet on `list`. Getting out of it meant the wallet settings screen, which is a long way to go to stop making a decision. The way back now sits in the place the picking happens. The mode changes and the server does not. Auto means "you may move me", not "move me now": the one in use answered a moment ago, and discarding a working connection to prove the point would cost a reload for nothing. Rotation is what moves it, on its own trigger, when it stops answering. So no reopen either, unlike `switchServer` — nothing the session talks to has changed. Recorded in wallets.json, and in the settings copy that seeds the next wallet, the same pair `switchServer` writes going the other way. Not offered to a wallet already on auto. The picker is reachable from there now, and a button that would change nothing reads as one that failed. Named for what it does rather than `useAutomaticServer`, which the hooks lint rule read as a hook — correctly, since a plain function wearing that prefix is a lie to every reader too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two faults, and the first was mine. I made `switchServer` record the `list` selection, on the claim that the picker was its only caller. It is not: `rotateServer` calls it too, so every rotation turned a wallet that had delegated the choice into one that had made it by hand — the exact opposite of what confirming that dialog means. The selection is the caller's to name now. A pick from the list says `list`, a rotation says nothing and the mode is left alone. The switch moves a server; deciding what the move means on its callers' behalf was what broke it. And Auto now takes the head of the list along with the mode. The registry answers fastest-first, so the head is where an automatic pick lands, and setting the mode while leaving the wallet on a hand-chosen server only half meant it. When the head is already the server in use there is nothing to move to, so only the mode is recorded — moving anyway would reopen the wallet against the server it is already on. It is offered whether or not the wallet is already on auto. Pressing it there asks to be moved to the current best, which is a real request; the condition that hid it was written before Auto did anything but flip a flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"11 about listeners added. Use emitter.setMaxListeners() to increase limit" — and eleven for every other menu channel behind it. The cause was already written down beside the code that suffered from it: contextBridge proxies every function that crosses the boundary, so the object the preload registers is never the object the renderer holds. `off(channel, callback)` therefore asks the emitter to remove something it has never seen. The emitter does not, and does not complain either. Each mount added ten more. What was there was a flag that made the listeners which could not be removed no-op instead. That fixed the behaviour and left the leak, which is what has now grown loud enough to notice. `on` returns its disposer, made in the preload and closing over the exact function registered there. Nothing has to be matched, so nothing can fail to match. `off` and `removeListener` are gone from the bridge entirely: they could not work from the renderer, and leaving them was leaving the trap armed. The compiler found all fifteen call sites. The `active` flag goes with them, and its twelve guards. A listener that is genuinely unregistered has nothing left to silence. Both mocks return a disposer now, for the same reason the bridge does — one that returned nothing would let a component forget to unsubscribe and still pass. `resetMocks` wipes implementations before every test, so the shared one is re-armed in the global setup rather than leaving each suite to discover "cancel is not a function" for itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rotating walks the wallet down the list, remembering each server it moved away from so the next automatic pick does not walk straight back onto one. That memory is auto's own bookkeeping, and it outlived the mode it was kept for. Naming a selection clears it. Saying "this server" or "start choosing for me again" is the user deciding, and the exclusions from before that decision mean nothing after it — left in place they would haunt the next rotation with rejections the new mode never made, until the wallet ran out of servers to move to without having tried any of them. The seam is already there and needed no inventing: a rotation names no selection, which is right, because it is the thing doing the remembering. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auto picking the third row on a fresh start is not a fault, and the reason is written where the pick happens: the registry ranks by its own ping from its own vantage point, so the wallet races the best few from where the user actually is and takes whoever answers first. The list narrows the field; it does not decide. The incoherence was mine. The Auto button I added took the head of the list, which is a different rule — so pressing it landed somewhere a restart would not, which is the one thing a button called Auto must never do. It races now, over the same candidates, through the same `selectFastestServer` a launch uses. The race takes as long as the probes take, so the button says so and refuses to start a second one while the first runs. A race nobody wins falls back to the head, which is the registry's own best guess and the right thing to be left with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The milliseconds beside each server were the registry's ping, measured from wherever the registry sits. That is a fine way to narrow a field and a poor way to tell this user which server is near them — and it is why auto landing on the third row looked wrong when it was not. The picker measures them itself now, through the same probe the launch race uses. Two answers to "how far is this server" that disagreed about what they measured would be worse than one of them not existing. A sweep, not a race. A race ends on the first reply and never learns what the rest would have said, which is precisely what a list of servers exists to show. It costs the slowest answer instead of the fastest — the wrong trade at launch, where the point is to get online, and the right one on a screen opened on purpose to compare. The registry's number holds the place until ours lands, marked borrowed with a tilde rather than passed off as measured here. A server that was asked and stayed quiet says so, which is the one thing the registry's own number can never tell this user. Sorted once, when every answer is in. Reordering as they arrived would move rows under a cursor already travelling towards one. The launch is untouched. It still races three, still for the reason written there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five more pixels between it and the logo. Both places that draw it — the sidebar and the loading screen — use the same component, so they keep matching. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twenty now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two before this widened the gap under the version and towards the logo, which was not the gap that was tight. The bottom goes back to the ten it had, and the five lands on top, where the container's own padding was all it had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A transaction carrying a fee stacked the two figures, making its row half again as tall as one without — a whole extra block of vertical space per transaction, in a list read by scrolling. They sit side by side now, fee then amount, hard against the right edge. Bottom-aligned. Both are a figure with its fiat value under it, and the fee carries a label on top of that; aligning the ends puts the two fiat lines level and the two ZEC figures level above them, where aligning the starts would have set the amount against the fee's label rather than its number. Sized to content instead of to a fifth of the row, which only worked while they were stacked and narrow — two figures side by side need more, and how much more depends on the numbers. The address and memo take what is left. The amount's top padding goes with the stacking it was holding it clear of, and two `float: right` rules go with it: a float does nothing to a flex item, and the right-alignment those wanted was already coming from the class beside them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two bare figures on one line need saying which is which, and the fee was the only one that did. Drawn whether or not there is a fee. A row that named its amount only when something else stood beside it would be labelling the neighbour rather than the number — and it costs a line on rows without one, which is the price of the label meaning the same thing everywhere. The gap between them goes from 16px to 24px. At sixteen the two columns of figures read as one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two figures on one line need saying which is which. One on its own is the amount by being the only thing there, and the label cost every row without a fee a line to say what was never in doubt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Windows build stopped at validation: "configuration has an unknown property 'zip'". It is not a deprecated key, it is not a misplaced key — `zip` has no configuration section at all. The error's own list of valid properties names `dmg`, `nsis`, `msi`, `appx`, `deb` and the rest, and zip is not among them. It was added alongside the nym staging work and never did anything; 26.8.1 validates where the version before it did not, so a silent no-op became a stop. Removing it changes no output. Nothing was reading it, so the Windows zip has always been named by the root `artifactName` — and nothing downstream cares either: the hook does not mention artifact names and CI collects `dist/*.zip` by glob. Verified by running the remaining config through app-builder-lib's own scheme.json, which now passes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A user whose server had stopped answering tried to pick another one and got "Zingo PC could not start", with EPERM renaming `wallets.json.3290898086` onto `wallets.json`. Saving the list finishes with a rename: the store writes a temporary file and moves it over the real one, so a crash mid-write cannot leave half a list. On Windows that move fails with EPERM whenever anything holds the destination for an instant — Defender reading a file that just changed, the search indexer, a syncing folder, another copy of the app. It clears in tens of milliseconds, and the writer underneath makes exactly one attempt (write-file-atomic 2.4.3). Half a second of patience now, across four attempts, spent only on the codes that mean busy. A full disk or a read-only file is not a matter of asking again and still raises at once. The trap was that the failure removed the way out. The save is what changing server needs, so being unable to save left them unable to fix the thing that was wrong — an unreachable server became an app that would not open. If it is still held after all four, the message names what to look at rather than an operating-system rule: antivirus, a syncing folder, another Zingo PC window. And the fatal dialog runs it through `userFacingError`, so the one message a user is asked to paste into an issue is the fault itself and not four layers of IPC around it. No test: `public/electron.js` has no suite, jest covering `src/` only. The helper was exercised directly from the file — retries then succeeds, raises a non-busy code immediately, and gives up with the message above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Screen-filling padlocks. Every icon in the packaged Windows build draws at
the SVG's intrinsic size instead of one em.
FontAwesome ships the rules that size them — `svg-inline--fa` sets height
and width — and adds them by creating a `<style>` element the first time an
icon renders. Production forbids exactly that: `style-src 'self'`, no
`unsafe-inline`. The injection is dropped, and nothing sizes the SVGs.
Mine, and recent. The icon migration moved thirty-six call sites from an
icon font to `<FontAwesomeIcon>` — a stylesheet webpack bundles, for one
the library injects at runtime. That trade is invisible in development,
where the policy does allow inline styles, which is how it passed every
check we ran and shipped anyway.
The stylesheet is imported now, so webpack bundles it and it arrives from
`'self'` like every other style, with `autoAddCss` off so the library does
not add the copy it already has. The policy does not move: this was a
library asking for an exception it does not need.
Verified on a production build rather than assumed — the emitted CSS
carries `.svg-inline--fa{...height:1em...width:var(--fa-width,1.25em)}` and
`index.html` links the sheet that holds it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It was borrowing the history detail's modal, which is 800px wide because it shows addresses and transaction ids. This holds a heading, a sentence, four percentages and one small input. Four hundred, and centred. The rest of that class came from the width too: at 800px there is no room to centre, so it is pinned 12.5% from the left. With half the width there is room, so it takes the middle. It also stops reaching into the history stylesheet for it. A swap sheet dressed by the transaction list is one rename away from a surprise, and neither file said the other was reading it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A user's log, every ten seconds, for as long as the app was open: sync: wallet height 34100000 is more than 100 blocks ahead of best chain height 3470916 Count the digits. The birthday is 3410000 with an extra zero — some thirty million blocks past the tip. zingolib refuses to sync a wallet claiming to start in the future, so the wallet never syncs, ever, and the only place that says so is a console the user does not have. The check here had a floor and no ceiling: at least the network activation height, and nothing about the other end. A slipped digit needs the other end. The tip comes from the server already chosen for this wallet, through the same call the settings screen uses to decide a server works. An unreachable server leaves it unknown, and an unknown tip does not block a creation someone may be doing offline on purpose — the floor still holds either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The .dat path had the same missing ceiling as the typed one, and it said so about the floor without acting on it: the modal opened and the restore carried on regardless, no `return` in sight. The continuing is right and stays. A birthday recorded in a .dat is metadata; a wrong one stops the wallet syncing, and refusing the restore over it would leave someone without their wallet instead — worse than the fault it is guarding against. What it lacked was the other end, which is where a slipped digit lands: a birthday past the tip puts the wallet ahead of the chain, and sync refuses outright rather than catching up. Both messages now say which bound was crossed and what it means, instead of "is invalid". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sync that cannot run does not recover on its own, and the poll fires every ten seconds for as long as the app is open. All of it went to a console the user does not have. What they saw was a wallet that never advanced, with nothing to say why or for how long — the one report that reached us did so because a user sent their log. It goes to `fetchError` now, which the Dashboard and History already render in the error colour. Once per distinct reason: repeating it every cycle would be the same silence in a different key, and clearing it the moment the poll answers again, so a transient failure does not sit there after it has passed. Stripped of the IPC wrappers through `userFacingError`, because the last clause is the one that says something. "wallet height 34100000 is more than 100 blocks ahead of best chain height 3470916" tells someone what is wrong with their wallet; the four layers around it do not. `lastPollSyncError` was already collected for this and read by nobody — it fed `SyncStatusType.lastError`, which no screen displays. It does the deduplication now, which is a use. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`server health: https://na.zec.rocks:443 answered in 110785ms`, for a call the same native module answers in 150ms from another process on the same machine at the same moment. And a log that goes quiet for a minute at a time and then completes everything at once. Nothing was slow. Everything was waiting. Neon's `cx.task` runs each native call on the libuv thread pool, and each one blocks its thread with `RT.block_on` for the whole network round trip. The pool is four threads. The five-second cycle fires eleven native calls at once, with the sync poll, the wallet save and `run_sync` on top, and a sync holds its thread for as long as a sync takes. Past four, the rest queue — and the wait is charged to whatever asked next, which is why a server that answers instantly was billed for two minutes. Sixteen, set before anything can create the pool. An idle thread costs a stack. A mitigation, not a cure. The cure is a native layer that does not hold a thread while the network answers, and that lives on the other side of the boundary. This also has to land before the server dot can grade on how long a probe took: while our own queue is what makes probes slow, timing them would blame the server for our arithmetic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A green dot sat over a wallet that would not move, because the light asked one question — did it answer — and a server taking two minutes answers. Slowness is its own verdict now, below silence and above health. A server taking seconds is not down; it answers. Calling that healthy is what made the light useless exactly when it was needed. Two seconds, argued from measurement rather than taste, which is what `lastDurationMs` was being kept for. The eighteen servers this wallet offers on mainnet answer this same probe in 64–483ms from a domestic connection, and the app's own probe against its active server sits at 136–147ms. Two seconds is four times the slowest of those and well under the fifteen the probe waits before calling it dead. Three in a row, like every other verdict here: one slow answer is a moment, three is the server. One quick answer clears it. An answer nobody timed is not evidence of speed, so it leaves the run where it was. Amber, with unstable. Both mean "answering, but not to be relied on", and a colour of its own would claim a distinction the user cannot act on differently — the tooltip is where they part, and it says what the user will actually notice: the wallet will feel stuck. Deliberately after the thread-pool fix. While every native call shared four threads, a probe could be billed a hundred seconds for a server answering in a tenth of one, and grading on that would have blamed the server for our own queue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen sync launches in eighteen seconds, from a log where the poll is supposed to run every five. zingolib answers a concurrent launch with a clean "already running" and carries on, which is what made this look free. It is not. Every ask costs a thread from the pool and a round trip to the server, and the poll asks from both of its branches as well as from the five-second cycle. While a server misbehaves the answers stop arriving and the asks do not, so the launches pile up — which is how a health probe against a server answering the same call in 150ms came back after 47 seconds. The measurement that settles it: the same server, probed from another process at the same moment, answered in 110ms. Nothing was slow. We were queueing behind ourselves. One launch in flight at a time, the same guard `probeServerHealth` beside it has kept all along. A rescan is not covered — a deliberate act by the user, which clears the timers first, and dropping it would ignore them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The root, which the three changes before this one only worked around. The cycle is a five-second timer whose slowest member takes sixty. `poll_sync` waits out a full timeout when a server misbehaves — measured three times in one log at exactly sixty seconds — and holds a pool thread throughout. Nothing waited for the previous cycle: the interval fires and drops the promise. So twelve cycles could be alive at once, the pool saturated in about sixteen seconds, and everything then queued behind work already being done. That is why the health probes grew: 47s, then 68s, then 136s, then 160s, against a server answering the same call in 110ms from another process at the same moment. The server was never slow. The queue was getting longer. A second identical ask answers the same question as the first. It cannot arrive sooner, and it costs the thread the next different question needs — so it is dropped, not queued. Keyed per request rather than per cycle, so a stuck `poll_sync` does not stop the balance refreshing. The two hand-rolled flags fold into it: the health probe keeps its own interval, which is a cadence and not a lock, and gives up its concurrency flag; the sync launch becomes another name. What came before was mitigation and worth saying so. Sixteen threads bought twelve seconds before saturation. Guarding the sync launch removed one producer of several. Neither reached a timer that starts work every five seconds without asking whether the last lot finished. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two things made every wallet call wait for the one before it, and both are here. The first is the lock. Fifty-three endpoints took `LIGHTCLIENT.write()` and four took `read()`; mobile's equivalents are thirty-three and twenty. So a balance could not be read while a status was being read, for no reason other than the guard each endpoint happened to spell out. Twenty-one now take the shared lock, and which ones was not a judgement call: the helper hands the closure `&LightClient`, so anything needing to mutate does not compile against it. All twenty-one compiled first time, which is the compiler confirming mobile's list rather than me asserting it. The second is the thread pool. `cx.task` schedules on libuv's, and libuv's holds four threads. Every endpoint here blocks its thread for a whole wallet operation, so four blocked calls left nothing for the fifth — including the server health probe, which needs no wallet lock at all. A run whose sync held the exclusive lock for fifty-five seconds billed that probe 40.6 seconds against a server answering in 180ms, finishing 166ms after the sync released, because that is when a thread came free. The green light was never reporting the server. It was reporting our own queue. Raising `UV_THREADPOOL_SIZE` does not reach it — measured separately, and the next commit carries that — so the work goes to tokio's blocking pool instead, through `spawn_promise`. Eight concurrent calls now start in the same millisecond and finish in 566ms, against 3151ms of work between them. To make any of this testable the endpoints split: a plain `*_string` function that does the work, and the neon wrapper that dispatches it. A `FunctionContext` cannot be built in a test, so while the work lived inside the endpoint there was no way to assert which lock it took. Nineteen lock-discipline tests, ported from mobile, now call each read-only endpoint from another thread while the test holds a read guard: one that takes the exclusive lock queues behind it and times out. Verified by putting one back on the exclusive lock and watching its test fail at the guard's timeout rather than at an assertion. Three smaller things, in the same file and the same spirit. `cause_chain` walks `source()` so a failure arrives whole: `SyncError` prints "server error", the `ServerError` under it prints "server request failed", and the `tonic::Status` under that — the only one naming what the server did — prints nothing, so `to_string()` returned the least useful of the three. An hour of `sync: server error` told us nothing that any other server failure would not have. Forty-six wrap sites take the chain now. `change_server` dials off the lock, so a switch to a server that has stopped answering cannot hold the exclusive lock for as long as the dial takes to give up. No user reaches it today — the renderer switches by reopening the wallet — but whoever wires it up should not inherit the freeze. And `wallet_kind` had a stray closing parenthesis that mobile and zingolib both lack. None of this shortens a call. A sync that holds the lock for a minute still holds it, and that is upstream. What it stops is one slow endpoint taking every other endpoint down with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Minimize the window and the app stops for a minute at a time, then catches up in a burst. Chromium throttles a hidden page's timers, and the renderer's five-second work cycle — the sync poll, the wallet save, the server health probe, all of it — drops to one wake-up per minute. Measured. A run left in the background reported eight stalls, of 59993, 60000, 59999, 59997, 60001, 60000 and 59997 milliseconds. Exactly a minute each, to the millisecond: a scheduler's quantum, not anything blocking. Over the same nine minutes the main process's own loop never once ran late, so nothing was stuck. The renderer was asleep. With `backgroundThrottling` off, the same test over seven minutes in the background found no gap above 7.2s, which is jitter. A wallet that is minimized still has to sync, so this is the right default for this app rather than a workaround. The line that used to set `UV_THREADPOOL_SIZE` goes with it, because it never did anything. libuv reads that variable once, when it creates the pool, and that happens during Electron's boot — before any of the app's own JavaScript runs. Measured too: sixteen concurrent pool calls from an Electron main script that had just assigned "16" still completed in four batches of four. Set in the environment before launch it works, but an installed .exe has no such environment. The fix is on the other side of the boundary and the previous commit carries it. A line that reads like a safeguard and is not one is worse than no line, so it is gone rather than left with a caveat. Both loops keep a probe that speaks only when it was late, which is what caught this. Silence is the healthy reading. They cost a timer a second and they are the difference between knowing which loop stalled and guessing: three separate explanations for these stalls survived until one probe fired and the other stayed quiet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every launch showed the same thing: the indicator turns green almost at once, then yellow, then spends the next minute actually building the tunnel before going green again. The first green is false, and green-yellow-green teaches the user that the light means nothing. At startup no wallet is open yet, so `mixnet_status` throws and the wallet's own mode is unknown. The snapshot took that silence as readiness and reported `ready` on the strength of the proxy's phase alone — which only says its listener accepts TCP. That is precisely what zingolib's attach readiness gate exists to say is not enough: a listener accepting a dial proves nothing about the mixnet carrying data. So an unheard wallet now reports `bootstrapping`, which is what is actually happening. Yellow, then green, with nothing in between. Not only confusing, either. `ready` is one of the two modes that leave sends unblocked, so the false green also opened the send screen over a transport nobody had confirmed. The wallet core refuses such a send on the route, but the UI is meant to be fail-closed on its own rather than leaning on that. `bootstrapping` rather than `unattached` because the proxy is up and the tunnel is being built, and because `unattached` would paint a red "Mixnet unavailable" over an ordinary launch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The probe reported to `console.log`, which in development goes to the terminal and in a packaged app goes nowhere anyone will look. `startup.log` collects only what the renderer's console emits, and the renderer's console is silenced in production on purpose, so nothing from the main process reached it. That leaves the probe inert in exactly the case it was added for: a stall reported from a machine we cannot attach to. A minute of silence with the window in the background took a day to identify with the probe in hand; the same report from a user would have arrived with nothing. It now appends to `startup.log` when packaged, the way the wallet-dir diagnostics already do — path resolved per call, failures swallowed, since a probe that throws is worse than one that says nothing. Development keeps the terminal line and gains no file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.