Skip to content

Add HoudiniSwap swap plugin - #469

Merged
j0ntz merged 2 commits into
masterfrom
jon/stealth-send-swap
Oct 5, 2026
Merged

j0ntz merged 2 commits into
masterfrom
jon/stealth-send-swap

Conversation

@j0ntz

@j0ntz j0ntz commented Jul 3, 2026 •

Copy link
Copy Markdown
Contributor

Technical Design Document

stealth-send-swap.md

CHANGELOG

Does this branch warrant an entry to the CHANGELOG?

  • Yes
  • No

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 is Authorization: <key>:<secret> with no Bearer prefix; every endpoint returns 402 without it. And the partner API is server-to-server and answers browser-origin requests with 403, so every call passes corsBypass: 'always' to route through the native fetch rather than the core WebView's.

Route selection. A request carrying privacy: 'required' takes private (multi-exchange) routes only, which is what makes a stealth flow private. Without it, standard routes 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. A dex route 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 addressFrom and comes back with the call to sign rather than a deposit address, so the spend sends metadata.value to metadata.to with metadata.data as 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 broadcast POST /dex/confirmTx reports 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 to GET /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 sends fixed=true, so isEstimate is !reverseQuote rather 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 a getMemos method detected at runtime behind a local guarded type, so this package keeps compiling against published edge-core-js. The memo is forwarded as destinationTag on order creation. Memo chains XRP, XLM, ATOM, HBAR and RUNE are mapped; IBC-family chains (coreum, osmosis, axelar) stay unmapped because Houdini reports no memoNeeded flag 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 same SwapCurrencyError the 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-OK GET /tokens therefore 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 for mainnet=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 a bitcoincash query answers with rows on bch. Replaying that predicate against the live catalogue for every mapped chain (2026-09-10) resolves 35 of 38, the three misses being celo, fantom and polkadot, which have no mainnet native at all. monad (MON) and robinhood (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 retryAfter the API reports, and four things keep that budget honest:

  • The local exponential cap bounds our own doubling only, never the provider's window. A 30s ceiling on Houdini's roughly 60s window meant the retry fired while still inside it, drew another 429, and spent the retries for nothing.
  • A wait that would outlive what it is resending fails immediately as a rate limit. A quote id lives about a minute, so honoring a 60s window and re-POSTing the same id hangs the user for that minute and then reports an expired quote, blaming the wrong thing. The create call passes the candidate quote's own expiry into the wrapper.
  • validUntil arrives as Unix seconds inside a string, which new Date reads as an invalid date, so the parse reads the number first or that guard can never fire.
  • getMaxSwappable runs 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: its spendInfo sets skipChecks: true, without which an EVM engine rejects the spend-to-self match with SpendToSelfError and fails every max swap from an EVM wallet, and it clamps rather than throwing SwapAboveLimitError, 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 biggystring through a decimal-string expansion that covers scientific notation at both ends; comparison and sorting go through biggystring too, since String(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: a from quote refuses an order whose inAmount exceeds 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 publish max: 9007199254740991; a live fixed-rate order restated its quote at +0.012%.

Cleaners. The deposit tag uses asOptionalBlank(asNumberString) rather than asOptional(asString): a numeric tag is the common shape on memo chains (the valid tag 0 included) and would take the whole order down against a string-only cleaner, while an empty string would become an empty EdgeMemo on 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-readable code, 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 priced amountIn. VALIDATION_ERROR sets a generic top-level "Validation Failed" and puts the actionable text under fields.<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.privacy is added by EdgeApp/edge-core-js#730 and first shipped in edge-core-js 2.51.0; it did not exist in the core this branch compiled against, which is why EdgeSwapRequestPlugin declares it locally. That makes the ordering a release requirement rather than a preference: publish this package against a core that lacks the field and privateOnly reads false on 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-js 2.51.0 and edge-exchange-plugins 2.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 an allowSameAsset option that only Houdini passes, and only for a request carrying privacy: '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, tsc and 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's skipChecks flag, 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 and dex/confirmTx posting 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 failed dex/confirmTx is 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 order 2JrCoeLG36zo7pdRRWcwYn records isDex: true and 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 published max: 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 privacy and swapSend semantics.

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 (swapSend saved actions with optional destinationTag from synthetic wallets), and limited on-chain dex execution with post-broadcast dex/confirmTx. Shared swap plumbing gains optional privacy: 'required' on requests, checkInvalidTokenIds({ allowSameAsset }) so private same-asset mixer flows are allowed only for Houdini, and makeSwapPluginQuote acceptance of swapSend actions.

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

384ebb9
Add 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

Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts Outdated
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 3 times, most recently from 7107efd to 7d0fa3a Compare July 30, 2026 20:39
Comment thread src/swap/central/houdini.ts
@j0ntz

j0ntz commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

bugbot run

Comment thread src/swap/central/houdini.ts Outdated
Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts Outdated
Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 2 times, most recently from d3655ea to 8495fc5 Compare August 17, 2026 21:18

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/swap/central/houdini.ts
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 8495fc5 to 2dd7a79 Compare August 17, 2026 21:33
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch from 2dd7a79 to b83888a Compare August 25, 2026 22:47
@j0ntz
j0ntz marked this pull request as draft August 27, 2026 23:00
@j0ntz
j0ntz force-pushed the jon/stealth-send-swap branch 3 times, most recently from 8e73076 to 384ebb9 Compare September 15, 2026 00:33

@peachbits peachbits left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
@j0ntz

j0ntz commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Both changes are in: the plugin writes swapSend for a synthetic destination, and makeSwapPluginQuote in swapHelpers.ts accepts swapSend alongside swap. The synthetic short-circuit stays until address labels exist.

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
j0ntz force-pushed the jon/stealth-send-swap branch from b7984a6 to e61ed08 Compare October 5, 2026 18:18
@j0ntz
j0ntz marked this pull request as ready for review October 5, 2026 22:52
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
- 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
j0ntz force-pushed the jon/stealth-send-swap branch from e61ed08 to e37f281 Compare October 5, 2026 23:06

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 3 potential issues.

Fix All in Cursor

❌ 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.

Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
@j0ntz
j0ntz merged commit d6c56db into master Oct 5, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants