Add HoudiniSwap swap plugin - #469
Open
j0ntz wants to merge 2 commits into
Open
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
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.
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.
- 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 17, 2026 21:33
8495fc5 to
2dd7a79
Compare
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 session rather than one per quote. A lookup the provider FAILED to answer is deliberately left uncached: 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 session. 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.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. 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.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. 57 mocha tests pass,
tscand eslint clean,verify-repo.shPASSED. 24 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 20 offline behaviors driven from scripted local responses. 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.