Skip to content

fix(stellar): read and validate Horizon strict-receive responses - #251

Merged
truthixify merged 1 commit into
wraith-protocol:developfrom
izzetemredemir:fix/236-horizon-strict-receive
Oct 16, 2026
Merged

truthixify merged 1 commit into
wraith-protocol:developfrom
izzetemredemir:fix/236-horizon-strict-receive

Conversation

@izzetemredemir

Copy link
Copy Markdown
Contributor

Summary

  • Reads the real Horizon envelope. findStrictReceivePath cast the response to a bare array, but Horizon answers { "_embedded": { "records": [...] } }. Records are now read from _embedded.records. The endpoint is not paginated (the response has no _links/cursor), so all records come in one page; this is documented on the function and in docs/stellar-path-payment.md.

  • Fixes the request too. While testing against horizon-testnet I found every live call failed with HTTP 400 before parsing:

    • destination_asset=native is not a Horizon parameter (invalid_field: destination_asset_type);
    • the dummy source_account fails the strkey checksum;
    • Horizon rejects source_account and source_assets together.

    The request now sends source_assets plus destination_asset_type / destination_asset_code / destination_asset_issuer. This goes a little beyond the issue title, but without it the parsing fix can't be exercised against a real server.

  • Validates every record before use: source_amount and destination_amount are positive Stellar amounts (≤ 7 decimals, within int64 stroops); destination_amount equals destAmount; source and destination assets are exactly sendAsset and receiveAsset; every asset's type matches its code and has a valid issuer; path is an array of at most 5 assets (Asset path<5> in the XDR). One bad record rejects the whole response.

  • Rejects oversized payloads without buffering them: the body is read as a stream and abandoned (stream cancelled) once it passes 256 KiB; a declared Content-Length over the limit is rejected before reading; more than 100 records is rejected. Error bodies are also read only up to 2 KiB, and a failure while reading one still surfaces as RPCRequestError with the status. Without a body stream (some fetch polyfills) the limit is still checked in bytes.

  • Timeouts: the request goes through the same AttemptDeadline as the SDK's Horizon client (10 s for headers, 30 s total, RPCTimeoutError), so a server that never answers or trickles the body can't hang the call. No retries are added; the defaults are not configurable here, to keep the options unchanged.

  • Input checks: destAmount must be a positive amount with at most 7 decimals and is checked before the request, so the destination_amount comparison always applies. Trailing slashes on horizonUrl are stripped, as createHorizonClient does.

  • Typed errors: new HorizonResponseError (WRAITH/NETWORK/HORIZON_RESPONSE, a WraithNetworkError with url, field, reason; error.field names the rejected value, e.g. _embedded.records[0].source_amount). A non-2xx status now throws the existing RPCRequestError instead of a plain Error. "No payment path found" stays a plain Error with the same message. Untrusted values are truncated in messages.

  • Picks the cheapest valid record (smallest source_amount) instead of trusting the server's order.

  • HorizonResponseError is exported from the package root, so etc/sdk.api.md is regenerated; it is added to docs/errors.md, and there is a ### Fixed entry in the CHANGELOG.

Design choices you may want differently: one invalid record rejects the whole response rather than being skipped (a server sending malformed paths is not trusted for the others), and there is no retry on 429/5xx, as before; I can route this through createHorizonClient with a size-capped read in a follow-up if you prefer.

Behaviour changes for callers: errors thrown on HTTP failures are now RPCRequestError (previously Error('Horizon path finding failed: …')), malformed responses throw HorizonResponseError, slow responses throw RPCTimeoutError, and an invalid destAmount throws before any request. Successful results keep the same shape. "No payment path found" is still a plain Error with the same message; happy to give it a typed error too if you prefer.

Validation

Hostile fixtures in test/chains/stellar/path-payment.test.ts (22 table cases, plus an endless body without Content-Length that must be cancelled after ~256 KiB, an oversized Content-Length with an endless body, an endless 500 error body, a 503 whose body fails to arrive, a no-stream response over the byte limit, a huge field value, a server that never answers, and invalid destAmount values): non-JSON body, bare array, missing _embedded, non-array records, 101 records, non-object record, wrong source / destination asset, asset type that doesn't fit the code, native asset carrying an issuer, unknown asset type, negative / zero / exponent / 8-decimal / over-int64 / numeric amounts, destination amount mismatch, non-array path, 6-hop path, non-object hop, hop with an invalid issuer. The old mocks returned a bare array, which is why the tests never caught the envelope bug; they now use real Response objects with Horizon's shape.

Negative check: run against the code on develop, 39 of the 47 tests in path-payment.test.ts fail (the 8 that pass are the unchanged buildPathStealthPayment tests).

Live check against horizon-testnet (built dist/):

develop:  XLM -> 1 XLM   Error: Horizon path finding failed: 400 Bad Request
          USDC -> 1 XLM  Error: Horizon path finding failed: 400 Bad Request
          XLM -> 1 USDC  Error: Horizon path finding failed: 400 Bad Request
branch:   XLM -> 1 XLM   sourceAmount 1.0000000, path (direct)
          USDC -> 1 XLM  sourceAmount 0.0692525, path USDC (another issuer)
          XLM -> 1 USDC  sourceAmount 1.1211059, path (direct)
pnpm run format:check                                        # ok
pnpm build                                                   # ok
pnpm api:check                                               # ok (etc/sdk.api.md updated)
pnpm test:types                                              # ok
pnpm test                                                    # 84 files, 1490 passed / 5 skipped; sdk-svelte 10 passed
pnpm test:exports && pnpm pack:check && pnpm test:compat     # ok
pnpm size                                                    # ok — Stellar ESM 30.22 / 30.5 kB, CJS 38.65 / 39.5 kB (+0.97 / +0.99 kB)
pnpm --filter @wraith-protocol/test-vectors differential     # passed
pnpm exec vitest run test/chains/stellar/properties.test.ts  # 12 passed

The Stellar bundle is now close to its size limit (about 0.3 kB left in ESM); flagging it in case you'd rather raise the limit or trim elsewhere.

Closes #236

findStrictReceivePath expected a bare array, but Horizon answers with a
_embedded.records envelope, and it sent destination_asset plus a dummy
source_account, which Horizon rejects with 400. Every live call failed.

- send source_assets and destination_asset_type/_code/_issuer
- read _embedded.records; the endpoint is not paginated
- validate every record: amounts, asset type/code/issuer, that source,
  destination and destination_amount match the request, and at most 5
  path hops; reject more than 100 records
- read the body as a stream and stop past 256 KiB; cap error bodies too
- use the Horizon client's default connect/request timeouts
- validate destAmount before the request; strip a trailing slash
- return the record with the smallest source_amount
- add HorizonResponseError for malformed or oversized responses and
  throw RPCRequestError for non-2xx statuses

Closes wraith-protocol#236
@drips-wave

drips-wave Bot commented Oct 16, 2026

Copy link
Copy Markdown

@izzetemredemir Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

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

The strict-receive parser now matches Horizon's envelope and request shape, validates assets, amounts and path bounds, caps both success and error bodies, and applies the shared deadline across headers and streaming reads. I reran all 47 focused tests plus build, API extraction, and bundle-size checks on the merge ref; all pass.

@truthixify
truthixify merged commit 24a332b into wraith-protocol:develop Oct 16, 2026
22 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.

[Wave 10] Parse and validate Horizon strict-receive responses

2 participants