Add HoudiniSwap swap plugin - #469
Draft
j0ntz wants to merge 2 commits into
Draft
Conversation
Contributor
Author
6 tasks
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
from
July 30, 2026 01:55
a17062d to
b676f23
Compare
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
3 times, most recently
from
July 30, 2026 20:39
7107efd to
7d0fa3a
Compare
Contributor
Author
|
bugbot run |
2 tasks
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
2 times, most recently
from
August 17, 2026 21:18
d3655ea to
8495fc5
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 8495fc5. Configure here.
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
from
August 17, 2026 21:33
8495fc5 to
2dd7a79
Compare
checkInvalidTokenIds rejects a swap from an asset to itself, which for an ordinary provider is a no-op the user cannot have meant. Routing an asset to itself through a privacy provider is the point rather than a mistake, so those plugins opt out with allowSameAsset.
- Privacy-routed CEX swaps via Houdini's v2 partner API: forward quotes take private (multi-exchange) routes only; reverse quotes price by the receive amount (amountType=receive, fixed-rate), which Houdini's private routing does not serve today, so they fall back to standard fixed-rate routes that still settle through Houdini. - Swap-to-address destinations: synthetic destination wallets skip the typed-address lookup and may carry destination memos, forwarded as destinationTag on order creation (memo chains XRP/XLM/ATOM/HBAR/TON/RUNE are mapped; IBC-family chains stay unmapped until Houdini's metadata firms up). - Falls through to the next-best route when a fixed-rate route's static deposit address is held by another live order (409). - Houdini allows one exchange per minute, so the plugin spends that budget carefully: getMaxSwappable sizes its spend from the quote alone rather than creating a throwaway exchange, the two legs of one quote share a single in-flight token lookup, the API's retryAfter is a floor the local backoff cap never truncates, and a wait that would outlive the quote fails as a rate limit instead of POSTing a quote the API has already expired. - validUntil arrives as Unix seconds inside a string, so it is parsed as a number with the date parse kept as a fallback. - A non-OK GET /tokens throws with its status rather than answering with a miss, which the quote path would otherwise surface as a pair Houdini cannot route. - Zcash destinations use transparent addresses; requests ride Edge's CORS proxy (the partner API rejects browser-origin calls). - Mocha acceptance suite with disk-cached fixtures replays offline and stays inside the partner API budget.
j0ntz
force-pushed
the
jon/stealth-send-swap
branch
from
August 25, 2026 22:47
2dd7a79 to
b83888a
Compare
j0ntz
marked this pull request as draft
August 27, 2026 23:00
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.



Technical Design Document
stealth-send-swap.md
CHANGELOG
Does this branch warrant an entry to the CHANGELOG?
Dependencies
none (compiles against published edge-core-js; the synthetic-destination interplay is runtime-only and guarded)
Description
Asana task
The HoudiniSwap swap plugin: privacy-routed CEX swaps through Houdini's v2 partner API,
initOptions: { apiKey, apiSecret }. Two transport facts decide whether any call works at all, and neither is obvious. Auth isAuthorization: <key>:<secret>with noBearerprefix; every endpoint returns 402 without it. And the partner API is server-to-server and answers browser-origin requests with 403, so every call passescorsBypass: 'always'to route through the native fetch rather than the core WebView's.Route selection. A request carrying
privacy: 'required'takesprivate(multi-exchange) routes only, which is what makes a stealth flow private. Without it,standardroutes are acceptable too, ranked below private. The distinction decides what a user can send: Houdini serves no private route under 25 USD but serves standard routes down to 10, so a plain swap-and-send between those figures is only possible on a standard route. A standard route still settles through Houdini, so the recipient never sees the sender's address, but it uses a single exchange leg that can relink the two sides, and the caller cannot inspect which route it got. That is why a privacy request declines rather than substituting. Dex routes are never taken.Reverse quotes (
quoteFor: 'to') map toGET /quotes?amountType=receive&fixed=true. The API prices exact-out on fixed-rate quotes alone, which its private routing does not serve today, so they fall back to standard fixed-rate routes. Only that path sendsfixed=true, soisEstimateis!reverseQuoterather than a constant, and a forward quote reports itself as an estimate whether its route is private or standard. Rejecting reverse quotes outright would break the flip input's guarantee semantics; emulating exact-out by inverting a forward quote loses the receive-side guarantee.Swap-to-address destinations. A core-built synthetic destination wallet (id prefix
synthetic://) skips the typed-address lookup, since it holds exactly one pasted, caller-validated address, and may expose destination memos through agetMemosmethod detected at runtime behind a local guarded type, so this package keeps compiling against published edge-core-js. The memo is forwarded asdestinationTagon order creation. Memo chains XRP, XLM, ATOM, HBAR and RUNE are mapped; IBC-family chains (coreum, osmosis, axelar) stay unmapped because Houdini reports nomemoNeededflag and a permissive^.*$address validation for them.The chain table answers what Houdini calls a chain, not whether Houdini serves it. Those are different questions with different lifetimes: the name is stable, while what is served changes whenever the provider adds or drops a native coin. Servedness is discovered at runtime by
resolveTokenId, which declines with the sameSwapCurrencyErrorthe whitelist check raises, and which memoizes misses as well as hits so an unserved chain costs one call per ten-minute window rather than one per quote. The window cuts both ways: without it, a chain Houdini lists later in the session stays refused until the app restarts. A lookup the provider FAILED to answer is deliberately left uncached at all: a rate limit says nothing about whether a chain is served, and caching it would turn one bad minute into a chain that stays dead for the whole window. A non-OKGET /tokenstherefore throws with its status rather than returning a miss, which the quote path would otherwise surface as a pair Houdini cannot route.Rate limits. Houdini is an aggregator behind Cloudflare that allows one exchange per minute, and a 429 arriving where a quote was expected reads exactly like an unavailable pair. Every call goes through one wrapper that retries behind the
retryAfterthe API reports, and four things keep that budget honest:validUntilarrives as Unix seconds inside a string, whichnew Datereads as an invalid date, so the parse reads the number first or that guard can never fire.getMaxSwappableruns the quote function once to size the spend and the real quote runs it again, so the sizing pass builds its spend shape from the quote alone rather than creating a throwaway exchange, standing in the user's own refund address for the deposit address it does not have. Standing in the user's own address forces two more properties on that probe: itsspendInfosetsskipChecks: true, without which an EVM engine rejects the spend-to-self match withSpendToSelfErrorand fails every max swap from an EVM wallet, and it clamps rather than throwingSwapAboveLimitError, since the probe deliberately quotes the full pre-fee balance and an above-limit balance still makes a max swap once the fee comes off. The route maximum is enforced on the real quote only.Amount safety. Provider amounts arrive as JSON floats, so they reach
biggystringthrough a decimal-string expansion that covers scientific notation at both ends; comparison and sorting go throughbiggystringtoo, sinceString(smallFloat)can produce notation a string comparison misreads. Rounding to whole atomic units carries a direction: a minimum rounds UP so the floor Edge enforces never lands below the provider's own, and a maximum, the receive amount and the deposit amount round DOWN so none is ever larger than what the provider honors. The deposit amount is a trust boundary as well: afromquote refuses an order whoseinAmountexceeds what the user requested, before it becomes a signed spend. A reverse quote makes no such comparison, since the user pinned the receive side and there is nothing local to bound the send side against.Cleaners. The deposit tag uses
asOptionalBlank(asNumberString)rather thanasOptional(asString): a numeric tag is the common shape on memo chains (the valid tag0included) and would take the whole order down against a string-only cleaner, while an empty string would become an emptyEdgeMemoon the deposit. Every response is cleaned, and a cleaner failure logs the payload, which is the one error whose message otherwise says nothing about what arrived.Other behavior. A fixed-rate route's static deposit address can be held by another live order (HTTP 409
STATIC_DEPOSIT_IN_USE, hit live during testing), so order creation falls through to the next-best in-range route; that case is recognized by the envelope's machine-readablecode, not by searching the response text, so a code appearing inside a human-readable message cannot trigger it. Forward limits (min/max) are from-side and reverse limits (minOut/maxOut) receive-side, and a reverse quote must also clear the route's from-side bounds with its own pricedamountIn.VALIDATION_ERRORsets a generic top-level "Validation Failed" and puts the actionable text underfields.<name>.message, so field messages win over the top-level one; a specific top-level message with no fields surfaces unchanged. Zcash destinations use transparent addresses.Release ordering.
EdgeSwapRequest.privacyis added by EdgeApp/edge-core-js#730 and does not exist in the publishededge-core-js, which is whyEdgeSwapRequestPlugindeclares it locally. That makes the ordering load-bearing rather than cosmetic: publish this package against a core that lacks the field andprivateOnlyreadsfalseon every request, so a stealth send takes a standard route and reports no error. This package must not publish ahead of the core release that carries the field.Same-asset is allowed here, and only here. Every other central plugin rejects a swap from an asset to itself through the shared
checkInvalidTokenIds, which is right for a provider where it would be a no-op the user cannot have meant. Routing an asset to itself through a mixer is this provider's dominant flow, so the shared helper gained anallowSameAssetoption that only Houdini passes. The blocked-token half of the helper still applies; only the same-asset rejection is waived. That is the first of the two commits here, so the shared change reviews on its own.Testing. 121 mocha tests pass,
tscand eslint clean. 31 of those are this plugin's: 4 acceptance tests replaying fixtures recorded against the live API (forward BTC to ETH and ETH to USDC private swaps, a reverse BTC to ETH swap priced by the receive amount, and a synthetic memo-chain destination asserting the entered tag reaches the create-exchange body), and 27 offline behaviors driven from scripted local responses, including the probe'sskipChecksflag, the probe clamping an above-limit balance, the trust boundary refusing an inflated deposit amount, the rounding direction on both limits, a numeric and a blank deposit tag, the 409 fallthrough to the next route, and a reverse quote's deposit bounded by the route's from-side ceiling. The offline half exists because a recorded fixture replays one canned answer per URL, which cannot express a SEQUENCE of statuses (the backoff needs 429 then 200) or a route mix the live API will not produce on demand (a pair offering transparent routes and no private one). In-app, live quotes through this plugin were exercised on the iOS simulator via the Stealth Send UI, through to executed private orders.Note
Medium Risk
New third-party swap path handles real deposits, privacy routing, and rate-limit/order semantics; mistakes could mis-quote, leak route type, or stall swaps, but changes are isolated to a new plugin with broad test coverage.
Overview
Adds a HoudiniSwap central swap plugin wired into the plugin registry, with an Edge→Houdini chain mapping table and partner API integration (
apiKey/apiSecret, CORS-bypass fetch).Quotes resolve Houdini token IDs with session memoization (including misses), rank private over standard routes, honor
privacy: 'requiredby declining when only transparent routes exist, and never use dex routes. Forward quotes are estimates; reversequoteFor: 'toquotes use fixed receive pricing. Swap-to-address synthetic destinations forward destination tags; max-quote sizing skips creating an exchange to avoid the 1/min exchange budget. Rate limits retry perretryAfterwithout waiting past quote expiry.Shared helpers gain
checkInvalidTokenIdsallowSameAsset(used only here for same-asset mixer flows) andEdgeSwapRequestPlugin.privacy. Acceptance and offline scripted tests plus disk fixtures cover quoting, orders, and edge cases.Reviewed by Cursor Bugbot for commit 2dd7a79. Bugbot is set up for automated code reviews on this repo. Configure here.