Skip to content

Add HoudiniSwap swap plugin - #469

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

Add HoudiniSwap swap plugin#469
j0ntz wants to merge 7 commits into
masterfrom
jon/stealth-send-swap

Conversation

@j0ntz

@j0ntz j0ntz commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

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

HoudiniSwap swap plugin: privacy-routed CEX swaps via Houdini's v2 partner API (initOptions: { apiKey, apiSecret }, auth header apiKey:apiSecret, requests forced through Edge's CORS proxy because the partner API rejects browser-origin calls from the core WebView).

Behavior:

  • Forward quotes take private (multi-exchange) routes only. Judgement call: Houdini's standard single-CEX routes are served better by our direct provider integrations; privacy routing is this provider's identity in Edge. Alternate (also competing with standard routes on forward quotes) documented as rejected.
  • Reverse quotes (quoteFor: 'to') map to GET /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).
  • Swap-to-address destinations. A core-built synthetic destination wallet (id prefix synthetic://) skips the typed-address lookup (addressTypeMap) since it holds exactly one pasted, caller-validated address, and may expose destination memos via a getMemos method (detected at runtime through 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/TON/RUNE are mapped; IBC-family chains (coreum/osmosis/axelar) stay unmapped because Houdini reports no memoNeeded flag and a permissive ^.*$ address validation for them.
  • Route fallback on held deposits. 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); order creation falls through to the next-best in-range route.
  • Limit handling. Forward limits (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 priced amountIn (the API enforces this at order creation, verified live). API-level errors with a human-readable message (e.g. "Amount is too low, minimum is 25 USD") surface as that message instead of raw JSON.
  • Max quotes via getMaxSwappable; zcash destinations use transparent addresses (ChangeNow's addressTypeMap precedent).

Testing: mocha acceptance suite (test/houdini.test.ts) runs in the normal npm 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), and verify-repo.sh PASSED.

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 resolveTokenId to memoize misses, not just hits. celo, fantom and polkadot have no entry in GET /tokens?mainnet=true and the ton chain 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 same SwapCurrencyError the 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.

  • A failed token lookup no longer reads as an unsupported pair. A non-OK GET /tokens returned a miss, which the quote path turns into SwapCurrencyError, 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.
  • Concurrent askers share one lookup. A quote resolves both legs with 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.
  • A retry window longer than 30s is honored. RATE_LIMIT_MAX_DELAY_MS was applied on top of the API's retryAfter, truncating it. Houdini's one-per-minute exchange budget reports retryAfter near 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.
  • And a retry never outlives the quote it would resend. Honoring a 60s window exposed the other half: a quote id lives about that long, so waiting it out POSTs a quote the API has already expired, hanging the user for a minute to report the wrong reason. The create call passes the candidate quote's own expiry and fails as a rate limit immediately when the wait would land past it. validUntil arrives as Unix seconds inside a string ("1783037880"), which new Date reads as an invalid date, so the parse reads the number first.
  • A max quote creates one exchange, not two. getMaxSwappable runs 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.
  • A validation failure reports its real reason. VALIDATION_ERROR sets the top-level message to a generic "Validation Failed" and puts the actionable text under fields.<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 retryAfter against 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 with retryAfter-aware backoff, falls through on STATIC_DEPOSIT_IN_USE, and skips creating an exchange on the getMaxSwappable probe so max quotes do not burn the 1/min exchange slot.

Shared helpers gain checkInvalidTokenIds allowSameAsset (for same-asset mixer flows) and EdgeSwapRequestPlugin.privacy is documented on the plugin request type. A large houdini chain 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.

@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 2 times, most recently from 5eb17b5 to 7107efd Compare July 30, 2026 07:56
- 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
j0ntz force-pushed the jon/stealth-send-swap branch 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
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.
Comment thread src/swap/central/houdini.ts
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.
Comment thread src/swap/central/houdini.ts Outdated
`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.
Comment thread src/swap/central/houdini.ts
Comment thread src/swap/central/houdini.ts
`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.
Comment thread src/swap/central/houdini.ts
`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.

@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 234da7f. Configure here.

Comment thread src/swap/central/houdini.ts
`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.
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