Skip to content

Add HoudiniSwap swap plugin - #469

Open
j0ntz wants to merge 2 commits into
masterfrom
jon/stealth-send-swap
Open

Add HoudiniSwap swap plugin#469
j0ntz wants to merge 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 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 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. Dex routes are never taken.

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 today, 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 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-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.

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.

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

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 an allowSameAsset option 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, tsc and eslint clean, verify-repo.sh PASSED. 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: 'required by declining when only transparent routes exist, and never use dex routes. Forward quotes are estimates; reverse quoteFor: 'to quotes 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 per retryAfter without waiting past quote expiry.

Shared helpers gain checkInvalidTokenIds allowSameAsset (used only here for same-asset mixer flows) and EdgeSwapRequestPlugin.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.

@j0ntz

j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

📸 Test evidence (live quotes through the plugin on the iOS sim)

agent proof 1216251688512498 01 stealth send toggle

agent proof 1216251688512498 01 stealth send toggle

agent proof 1216251688512498 06 stealth swap houdini only

agent proof 1216251688512498 06 stealth swap houdini only

Captured by the agent's in-app test run (build-and-test).

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

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 8495fc5. Configure here.

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 8495fc5 to 2dd7a79 Compare August 17, 2026 21:33
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.

1 participant