Repository navigation
Add HoudiniSwap swap plugin - #469
Conversation
a17062d to
b676f23
Compare
7107efd to
7d0fa3a
Compare
|
bugbot run |
d3655ea to
8495fc5
Compare
8495fc5 to
2dd7a79
Compare
2dd7a79 to
b83888a
Compare
8e73076 to
384ebb9
Compare
peachbits
left a comment
There was a problem hiding this comment.
Nothing here blocks on its own. Two changes follow from the requests on EdgeApp/edge-core-js#730, one noted inline and one below, and one branch can be removed later once address labels exist.
src/util/swapHelpers.ts:126
With the swapSend action type from the core PR, this accepts 'swapSend' alongside 'swap'; the fields it reads (toAsset.nativeAmount, payoutAddress, isEstimate, orderId) exist on both.
|
Both changes are in: the plugin writes |
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.
b7984a6 to
e61ed08
Compare
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
- 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.
e61ed08 to
e37f281
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit e37f281. Configure here.

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 interaction 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. Adexroute is the exception. A plain request that no private or standard route serves may take one, ranked below both, from a native coin on an EVM chain, priced on the send side, with no token approval to sign. Every Houdini route into Monad is a dex route, so this is what makes a Monad destination reachable at all. A privacy request never takes one: a dex route is an on-chain contract call the user's own wallet signs, which links the two sides in public.Dex orders. A dex order names
addressFromand comes back with the call to sign rather than a deposit address, so the spend sendsmetadata.valuetometadata.towithmetadata.dataas a hex memo, bounded by the requested amount exactly as a deposit is. A route Houdini would broadcast itself (offChain), or one missing any of those three fields, is declined and the next candidate gets its turn. After the broadcastPOST /dex/confirmTxreports the txid, which is what starts the order on Houdini's side; a failure there is logged rather than reported as a failed send, since the funds have already left the wallet.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, 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. The lookup itself trusts its query to scope the answer: the native query asks formainnet=true, the catalogue's own definition of a chain's coin, and takes the row it gets back, while a contract token matches on its address alone. Re-testing rows only lost chains: TON's coin carries a contract-style address, and abitcoincashquery answers with rows onbch. Replaying that predicate against the live catalogue for every mapped chain (2026-09-10) resolves 35 of 38, the three misses beingcelo,fantomandpolkadot, which have no mainnet native at all.monad(MON) androbinhood(Robinhood) are mapped; both resolve.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 pins the receive side, so its order is bounded by the route it was created on: the smaller of that route's quoted send amount plus 1% for provider rounding, and its from-side ceiling. The quoted amount does the real work, since routes commonly publishmax: 9007199254740991; a live fixed-rate order restated its quote at +0.012%.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, read from the same single parse of the body that produces the error message, 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 first shipped inedge-core-js2.51.0; it did not exist in the core this branch compiled against, which is whyEdgeSwapRequestPlugindeclares it locally. That makes the ordering a release requirement rather than a preference: 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. Both published together:edge-core-js2.51.0 andedge-exchange-plugins2.58.0.Same-asset is allowed here, for private requests only. 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, and only for a request carryingprivacy: 'required'. A same-asset request without it is the same no-op and is declined. The blocked-token half of the helper applies either way. That is the first of the two commits here, so the shared change reviews on its own.Testing. 264 mocha tests pass,
tscand eslint clean. 45 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 41 offline behaviors driven from scripted local responses, including a coin whose row carries a contract-style address, a row naming its chain differently from the query (both fail on the previous predicate and pass on this one), 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, a reverse quote's deposit bounded by the route's from-side ceiling and by its own quoted send amount (with an order that restates its quote within rounding accepted), and a same-asset request declined unless it asks for privacy. 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). The dex path has five offline cases: the route taken anddex/confirmTxposting the txid after broadcast, a quote needing a token approval declined, a non-EVM source declined, an off-chain route declined, and a transaction value above the request refused. Three more cover the confirmation retry: a faileddex/confirmTxis retried, a confirmation that never lands still completes the send, and a confirmation the provider refused is not retried. In-app, live quotes through this plugin were exercised on the iOS simulator via the Stealth Send UI, through to executed private orders. A pasted Monad address from a Polygon wallet quoted 521.0386 MON for 124.88 POL and slid to the success scene; Houdini's order2JrCoeLG36zo7pdRRWcwYnrecordsisDex: trueand the source transaction hash this plugin confirmed. On the current head, a 12 USD Dash to Ethereum send through a standard route (1 DASH = 0.02090847 ETH) reached the success scene; the same pair priced by the receive side sent 0.22986803 DASH for a guaranteed 0.0047724 ETH on a fixed quote whose route publishedmax: 9007199254740991, so the quoted-amount bound was the one in force; and a same-asset private TRX send (77.054 TRX,privacy: 'required'through core) reached the success scene.In the app on the iOS simulator, with this branch and edge-core-js#730 linked into edge-react-gui#6066: a Stealth Swap of 1,043.63 S (27.99 USD) from Sonic to 20.2316 TON through the private route, driven to the success scene. TON could not resolve a token id on the previous lookup, so no quote into it was possible before.
Note
High Risk
New swap path that builds signed spends from provider responses, enforces privacy vs transparent routing, and depends on coordinated edge-core-js release for
privacyandswapSendsemantics.Overview
Adds a HoudiniSwap central swap plugin wired into the plugin registry, backed by a new Edge→Houdini chain name map and the partner v2 API (
Authorization: key:secret, CORS bypass for server-side calls).The plugin quotes and creates orders for private (and optionally standard/dex) routes, including exact-out pricing via
amountType=receive, swap-to-address payouts (swapSendsaved actions with optionaldestinationTagfrom synthetic wallets), and limited on-chain dex execution with post-broadcastdex/confirmTx. Shared swap plumbing gains optionalprivacy: 'required'on requests,checkInvalidTokenIds({ allowSameAsset })so private same-asset mixer flows are allowed only for Houdini, andmakeSwapPluginQuoteacceptance ofswapSendactions.Behavior is heavily guarded around rate limits, token-id caching, deposit amount trust bounds, and route fallbacks (e.g. 409 static deposit in use). 130+ tests cover fixture-based acceptance flows and scripted offline edge cases.
Reviewed by Cursor Bugbot for commit e37f281. Bugbot is set up for automated code reviews on this repo. Configure here.
Test evidence
384ebb9Add the HoudiniSwap swap plugin
agent proof 1216251688512498 01 stealth send toggle
agent proof 1216251688512498 06 stealth swap houdini only
monad dex quote armed
monad dex success
monad dex txdetails