Add HoudiniSwap swap plugin - #469
Open
j0ntz wants to merge 7 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
2 times, most recently
from
July 30, 2026 07:56
5eb17b5 to
7107efd
Compare
- 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). - Max quotes via getMaxSwappable; 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
July 30, 2026 20:39
7107efd to
7d0fa3a
Compare
Contributor
Author
|
bugbot run |
A non-OK `GET /tokens` returned a miss, which the quote path turns into `SwapCurrencyError`. That is indistinguishable from the provider answering "no such token", so a bad minute at the API reached the user as a pair Houdini cannot route. Throw instead, naming the status, the same way the quote path surfaces its own failures.
A quote resolves both legs with `Promise.all`, so a same-asset quote asks the same question twice at once. The cache stored results, which are only available after the await, so both legs missed and spent two calls against a rate-limited API on one answer. Cache the in-flight promise instead, and drop it on rejection so a failure still does not stick.
`RATE_LIMIT_MAX_DELAY_MS` was applied on top of the API's `retryAfter`, so any window longer than 30s was truncated. Houdini's 1-per-minute exchange budget reports `retryAfter` near 60, so the retry fired while still inside the window, drew another 429, and spent the retries for nothing. The cap now bounds only our own doubling; the provider's floor always survives.
`getMaxSwappable` runs the quote function once to size the spend and the real quote runs it again. The sizing pass created a real exchange, spending one of Houdini's one-per-minute slots, so the real create was rate limited and every max quote stalled for the retry window. The probe now builds its spend shape from the quote alone, standing in the user's own refund address for the deposit address it does not have.
`fetchHoudini` retried every 429 behind the window the API reported, which on the one-per-minute exchange budget is about 60s. A quote id lives about that long, so the retry POSTed a quote the API had already expired: the user waited the full minute and got an expired-quote error instead of the rate limit. The create call now passes the quote's own expiry, and a wait that outlives it fails as a rate limit immediately.
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 234da7f. Configure here.
`validUntil` arrives as Unix seconds inside a string ("1783037880"), which
`new Date` reads as an invalid date. The expiry guard therefore fell back to
a fresh 60s window on every quote and could never fire. Parse the number
first, keeping the date parse as a fallback.
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.



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
HoudiniSwap swap plugin: privacy-routed CEX swaps via Houdini's v2 partner API (
initOptions: { apiKey, apiSecret }, auth headerapiKey:apiSecret, requests forced through Edge's CORS proxy because the partner API rejects browser-origin calls from the core WebView).Behavior:
quoteFor: 'to') map toGET /quotes?amountType=receive&fixed=true. The API only prices exact-out on fixed-rate quotes, and its private routing does not serve exact-out today (verified live: exact-out returns standard routes only, across pairs). So reverse quotes fall back to standard fixed-rate routes, which still settle through Houdini (the recipient never sees the sender's address) but use a single exchange leg. Private routes remain preferred whenever the API offers them. Alternates considered: rejecting reverse quotes entirely (breaks the flip-input guarantee semantics the product spec requires), or emulating exact-out by inverting a forward quote (loses the receive-side guarantee).synthetic://) skips the typed-address lookup (addressTypeMap) since it holds exactly one pasted, caller-validated address, and may expose destination memos via agetMemosmethod (detected at runtime through 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/TON/RUNE are mapped; IBC-family chains (coreum/osmosis/axelar) stay unmapped because Houdini reports nomemoNeededflag and a permissive^.*$address validation for them.STATIC_DEPOSIT_IN_USE, hit live during testing); order creation falls through to the next-best in-range route.min/max) are from-side, reverse limits (minOut/maxOut) receive-side; a reverse quote must also clear the route's from-side bounds with its own pricedamountIn(the API enforces this at order creation, verified live). API-level errors with a human-readablemessage(e.g. "Amount is too low, minimum is 25 USD") surface as that message instead of raw JSON.getMaxSwappable; zcash destinations use transparent addresses (ChangeNow'saddressTypeMapprecedent).Testing: mocha acceptance suite (
test/houdini.test.ts) runs in the normalnpm test(unlike the earlier out-of-suite harness) with disk-cached fixtures that replay offline within the partner API budget: forward BTC→ETH and ETH→USDC private swaps, a reverse BTC→ETH swap priced by the receive amount, and a synthetic memo-chain destination (ETH→XRP) asserting the entered destination tag reaches the create-exchange body. All fixtures recorded against the live API.tsc, eslint, and the full suite pass (48 tests), andverify-repo.shPASSED.A later round added eleven offline behaviors to the same file, driven from scripted local responses rather than recorded fixtures, because a fixture replays one canned answer per URL and 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). They cover both spellings of a native coin's missing contract address, the private-only filter declining a transparent-only pair, private preferred over a better-priced standard route, dex routes never taken, same-asset allowed, a chain with no native declined before any quote goes out, the fixed-versus-floating label on forward and reverse quotes, and the rate limit both retried and exhausted. The same round also taught
resolveTokenIdto memoize misses, not just hits.celo,fantomandpolkadothave no entry inGET /tokens?mainnet=trueand thetonchain carries one token and no native, and a quote naming any of them was spending a lookup every time to be told the same thing. The decline itself was always correct: no match throws the sameSwapCurrencyErrorthe whitelist check throws. A lookup the provider FAILED to answer is deliberately left uncached, since a rate limit says nothing about whether a chain is served. In-app: live quotes through this plugin were exercised on the iOS sim via the Stealth Send UI (quote retrieval verified; order execution hit the shared dev key's free-tier 10-exchanges/day cap, with the tag-bearing order creation covered by the live-recorded fixture test).Review round: six provider-interaction fixes. Cursor Bugbot came back online mid-run and reviewed every push; each finding below was verified against the code or the recorded fixtures before being fixed.
GET /tokensreturned a miss, which the quote path turns intoSwapCurrencyError, indistinguishable from the provider answering "no such token". It now throws, naming the status, the way the quote path already surfaces its own failures. The failure is still not cached.Promise.all, so a same-asset quote asked the same question twice at once and both missed, because the cache stored results and results only exist after the await. It now caches the in-flight promise and drops it on rejection.RATE_LIMIT_MAX_DELAY_MSwas applied on top of the API'sretryAfter, truncating it. Houdini's one-per-minute exchange budget reportsretryAfternear 60, so the retry fired inside the window, drew another 429, and spent the retries for nothing. The cap now bounds only our own doubling.validUntilarrives as Unix seconds inside a string ("1783037880"), whichnew Datereads as an invalid date, so the parse reads the number first.getMaxSwappableruns the quote function once to size the spend and the real quote runs it again; the sizing pass was creating a real exchange, spending one of the one-per-minute slots and guaranteeing the real create was rate limited. The sizing pass now builds its spend shape from the quote alone, standing in the user's own refund address for the deposit address it does not have.VALIDATION_ERRORsets the top-levelmessageto a generic "Validation Failed" and puts the actionable text underfields.<name>.message, so an expired quote reached the user as "Validation Failed". Field messages now win over the top-level one, which is generic exactly when they are present; a specific top-level message with no fields still surfaces unchanged.Seven tests were added for these, including one asserting a 60s
retryAfteragainst a quote with 20s left returns in under 5s rather than sleeping through the window. 57 tests in the suite.One earlier finding was rejected with reasoning: typed limit errors are already thrown wherever a native limit is knowable, and the 422 path carries only a USD figure, so classifying it would replace the provider's specific minimum with a generic string.
Note
Medium Risk
New third-party swap path with real fund routing, privacy semantics, and rate-limited order creation; shared swap validation changed but scoped to an opt-in flag.
Overview
Adds a new HoudiniSwap central swap plugin registered as
houdini, wired to Houdini’s v2 partner API with API-key auth and CORS-bypassed fetches.The plugin quotes forward and exact-out reverse swaps, ranks private routes ahead of standard when privacy is not required, declines when
privacy: 'required'and only transparent routes exist, and never uses dex routes. It resolves Houdini token IDs at runtime (memoized lookups, including stable misses), supports swap-to-address synthetic destinations with destination tags, handles rate limits withretryAfter-aware backoff, falls through on STATIC_DEPOSIT_IN_USE, and skips creating an exchange on thegetMaxSwappableprobe so max quotes do not burn the 1/min exchange slot.Shared helpers gain
checkInvalidTokenIdsallowSameAsset(for same-asset mixer flows) andEdgeSwapRequestPlugin.privacyis documented on the plugin request type. A largehoudinichain mapping and fixture-backed plus scripted acceptance tests cover quoting, ordering, limits, and offline edge cases.Reviewed by Cursor Bugbot for commit 491daab. Bugbot is set up for automated code reviews on this repo. Configure here.